diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt index d29b53fcd..54e68250e 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt @@ -2,28 +2,25 @@ package com.iterable.integration.tests import android.content.Intent import android.util.Log -import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage +import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector -import androidx.test.uiautomator.By -import com.iterable.iterableapi.IterableApi -import com.iterable.iterableapi.IterableEmbeddedMessage +import androidx.test.uiautomator.Until import com.iterable.integration.tests.activities.EmbeddedMessageTestActivity +import com.iterable.iterableapi.IterableApi import com.iterable.iterableapi.ui.embedded.IterableEmbeddedView import com.iterable.iterableapi.ui.embedded.IterableEmbeddedViewType -import org.awaitility.Awaitility import org.json.JSONObject import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { @@ -78,28 +75,21 @@ class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { val mainIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, MainActivity::class.java) mainActivityScenario = ActivityScenario.launch(mainIntent) - // Wait for MainActivity to be ready - Awaitility.await() - .atMost(5, TimeUnit.SECONDS) - .pollInterval(500, TimeUnit.MILLISECONDS) - .until { - val state = mainActivityScenario.state - Log.d(TAG, "🔧 MainActivity state: $state") - state == Lifecycle.State.RESUMED - } - - Log.d(TAG, "🔧 MainActivity is ready!") + Log.d(TAG, "🔧 MainActivity launched") // Step 2: Click the "Embedded Messages" button to navigate to EmbeddedMessageTestActivity - Log.d(TAG, "🔧 Step 2: Clicking 'Embedded Messages' button...") - val embeddedButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnEmbeddedMessages")) - if (embeddedButton.exists()) { - embeddedButton.click() - Log.d(TAG, "🔧 Clicked Embedded Messages button successfully") - } else { - Log.e(TAG, "❌ Embedded Messages button not found!") - Assert.fail("Embedded Messages button not found in MainActivity") - } + Log.d(TAG, "🔧 Step 2: Waiting for and clicking 'Embedded Messages' button...") + + // Use UiDevice.wait() which is the proper way to wait for UI elements in UiAutomator + val embeddedButton = uiDevice.wait( + Until.findObject(By.res("com.iterable.integration.tests", "btnEmbeddedMessages")), + 5000 // 5 second timeout + ) + + Assert.assertNotNull("Embedded Messages button should be found", embeddedButton) + embeddedButton.click() + + Log.d(TAG, "🔧 Clicked Embedded Messages button successfully") // Step 3: Wait for EmbeddedMessageTestActivity to load Log.d(TAG, "🔧 Step 3: Waiting for EmbeddedMessageTestActivity to load...") diff --git a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableActionRunnerTest.java b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableActionRunnerTest.java index 99a6ecf5f..84fb255ad 100644 --- a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableActionRunnerTest.java +++ b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableActionRunnerTest.java @@ -12,7 +12,6 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static androidx.test.espresso.intent.Intents.intended; @@ -22,11 +21,8 @@ import static androidx.test.espresso.intent.matcher.IntentMatchers.hasData; import static org.hamcrest.CoreMatchers.allOf; import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) public class IterableActionRunnerTest { @@ -57,9 +53,14 @@ public void testOpenUrlAction() throws Exception { @Test public void testUrlHandlingOverride() throws Exception { - IterableUrlHandler urlHandlerMock = mock(IterableUrlHandler.class); - when(urlHandlerMock.handleIterableURL(any(Uri.class), any(IterableActionContext.class))).thenReturn(true); - IterableTestUtils.initIterableApi(new IterableConfig.Builder().setUrlHandler(urlHandlerMock).build()); + // Use a simple implementation instead of mock for API 36+ compatibility + IterableUrlHandler urlHandler = new IterableUrlHandler() { + @Override + public boolean handleIterableURL(Uri uri, IterableActionContext context) { + return true; + } + }; + IterableTestUtils.initIterableApi(new IterableConfig.Builder().setUrlHandler(urlHandler).build()); JSONObject actionData = new JSONObject(); actionData.put("type", "openUrl"); @@ -73,17 +74,31 @@ public void testUrlHandlingOverride() throws Exception { @Test public void testCustomAction() throws Exception { - IterableCustomActionHandler customActionHandlerMock = mock(IterableCustomActionHandler.class); - IterableTestUtils.initIterableApi(new IterableConfig.Builder().setCustomActionHandler(customActionHandlerMock).build()); + // Track if the custom action handler was called (for API 36+ compatibility) + final boolean[] handlerCalled = {false}; + final IterableAction[] capturedAction = {null}; + final IterableActionContext[] capturedContext = {null}; + + IterableCustomActionHandler customActionHandler = (action, actionContext) -> { + handlerCalled[0] = true; + capturedAction[0] = action; + capturedContext[0] = actionContext; + return false; + }; + + IterableTestUtils.initIterableApi(new IterableConfig.Builder().setCustomActionHandler(customActionHandler).build()); JSONObject actionData = new JSONObject(); actionData.put("type", "customActionName"); IterableAction action = IterableAction.from(actionData); IterableActionRunner.executeAction(getApplicationContext(), action, IterableActionSource.PUSH); - ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(IterableActionContext.class); - verify(customActionHandlerMock).handleIterableCustomAction(eq(action), contextCaptor.capture()); - assertEquals(IterableActionSource.PUSH, contextCaptor.getValue().source); + // Verify the handler was called with correct parameters + assertTrue("Custom action handler should have been called", handlerCalled[0]); + assertNotNull("Action should not be null", capturedAction[0]); + assertEquals("Action type should match", "customActionName", capturedAction[0].getType()); + assertNotNull("Context should not be null", capturedContext[0]); + assertEquals("Source should be PUSH", IterableActionSource.PUSH, capturedContext[0].source); IterableTestUtils.initIterableApi(null); } } diff --git a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java index afab7676b..aa64a1132 100644 --- a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java +++ b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java @@ -18,6 +18,8 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import android.os.AsyncTask; + import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -30,6 +32,12 @@ import static junit.framework.Assert.assertTrue; import static org.junit.Assert.assertThat; +/** + * Tests for IterableRequestTask API responses. + * + * Note: Uses AsyncTask.SERIAL_EXECUTOR for deterministic execution on API 36+ + * where default AsyncTask behavior changed. + */ @RunWith(AndroidJUnit4.class) @MediumTest public class IterableApiResponseTest { @@ -39,20 +47,26 @@ public class IterableApiResponseTest { @Before public void setUp() throws IOException { server = new MockWebServer(); - // Explicitly start the server to ensure it's ready - try { - server.start(); - } catch (IllegalStateException e) { - // Server may already be started by url() call below, which is fine - } + server.start(); IterableApi.overrideURLEndpointPath(server.url("").toString()); createIterableApi(); } @After public void tearDown() throws IOException { - server.shutdown(); - server = null; + // Don't null IterableApi.sharedInstance - causes NPE with in-flight AsyncTasks + if (server != null) { + try { + // Drain any pending responses to prevent test contamination + while (server.takeRequest(100, TimeUnit.MILLISECONDS) != null) { + // Consume and discard + } + server.shutdown(); + } catch (Exception e) { + // Ignore cleanup errors + } + server = null; + } } private void stubAnyRequestReturningStatusCode(int statusCode, JSONObject data) { @@ -84,10 +98,10 @@ public void onSuccess(@NonNull JSONObject data) { signal.countDown(); } }, null); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onSuccess is called", signal.await(1, TimeUnit.SECONDS)); + assertTrue("onSuccess is called", signal.await(10, TimeUnit.SECONDS)); } @Test @@ -103,10 +117,10 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @Test @@ -122,10 +136,10 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @Test @@ -141,10 +155,10 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(5, TimeUnit.SECONDS)); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @@ -162,10 +176,10 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @Test @@ -181,16 +195,19 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @Test public void testResponseCode401AuthError() throws Exception { final CountDownLatch signal = new CountDownLatch(1); + // JWT errors trigger async retry logic which can cause race conditions with test cleanup + // Stub multiple responses for retries, but expect immediate failure callback + stubAnyRequestReturningStatusCode(401, "{\"msg\":\"JWT Authorization header error\",\"code\":\"InvalidJwtPayload\"}"); stubAnyRequestReturningStatusCode(401, "{\"msg\":\"JWT Authorization header error\",\"code\":\"InvalidJwtPayload\"}"); IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, new IterableHelper.FailureHandler() { @@ -200,10 +217,10 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @Test @@ -246,7 +263,7 @@ public void onSuccess(@NonNull JSONObject successData) { } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); server.takeRequest(5, TimeUnit.SECONDS); // Await for the background tasks to complete @@ -261,14 +278,14 @@ public void testMaxRetriesOnMultipleInvalidJwtPayloads() throws Exception { IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, null); IterableRequestTask task = new IterableRequestTask(); - task.execute(request); + task.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); RecordedRequest request1 = server.takeRequest(5, TimeUnit.SECONDS); RecordedRequest request2 = server.takeRequest(5, TimeUnit.SECONDS); RecordedRequest request3 = server.takeRequest(5, TimeUnit.SECONDS); RecordedRequest request4 = server.takeRequest(5, TimeUnit.SECONDS); RecordedRequest request5 = server.takeRequest(5, TimeUnit.SECONDS); - RecordedRequest request6 = server.takeRequest(5, TimeUnit.SECONDS); + RecordedRequest request6 = server.takeRequest(1, TimeUnit.SECONDS); assertNull("Request should be null since retries hit the max of 5", request6); } @@ -280,7 +297,7 @@ public void testResponseCode500() throws Exception { IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, null); IterableRequestTask task = new IterableRequestTask(); - task.execute(request); + task.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); RecordedRequest request1 = server.takeRequest(1, TimeUnit.SECONDS); RecordedRequest request2 = server.takeRequest(5, TimeUnit.SECONDS); @@ -300,10 +317,10 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); - server.takeRequest(1, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(5, TimeUnit.SECONDS)); + server.takeRequest(5, TimeUnit.SECONDS); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } @Test @@ -319,9 +336,9 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { signal.countDown(); } }); - new IterableRequestTask().execute(request); + new IterableRequestTask().executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, request); - server.takeRequest(1, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); + server.takeRequest(5, TimeUnit.SECONDS); + assertTrue("onFailure is called", signal.await(10, TimeUnit.SECONDS)); } } diff --git a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableTestUtils.java b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableTestUtils.java index 16f48fb14..4443f9e3b 100644 --- a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableTestUtils.java +++ b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableTestUtils.java @@ -1,11 +1,86 @@ package com.iterable.iterableapi; +import android.os.Build; + +import java.lang.reflect.Field; + import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static org.mockito.Mockito.mock; +/** + * Utility class for setting up Iterable API in instrumentation tests. + * + *

Handles Android API 36+ compatibility where Mockito's ByteBuddy cannot create + * mocks due to security restrictions on writable dex files.

+ * + * @see + * Android Security: Dynamic Code Loading + */ public class IterableTestUtils { + + static { + // Increase HTTP timeouts for instrumentation tests to accommodate slow emulators + // The default 3-second POST timeout is too short for MockWebServer on emulators + try { + increaseHttpTimeouts(); + android.util.Log.i("IterableTestUtils", "✅ Successfully increased HTTP timeouts for tests"); + } catch (Exception e) { + // If reflection fails, tests will continue with default timeouts + android.util.Log.e("IterableTestUtils", "❌ Could not increase HTTP timeouts for tests", e); + } + } + + /** + * Uses reflection to increase HTTP timeouts in IterableRequestTask for testing. + * This prevents flaky test failures due to emulator performance limitations. + * + * Increases timeouts to 30 seconds to handle even the slowest emulator scenarios. + * The timeout fields are intentionally non-final in production code to enable this. + */ + private static void increaseHttpTimeouts() throws Exception { + Class taskClass = IterableRequestTask.class; + + android.util.Log.d("IterableTestUtils", "Increasing HTTP timeouts for tests..."); + + // Increase POST timeout from 3s to 30s for tests (very generous for slow emulators) + Field postTimeoutField = taskClass.getDeclaredField("POST_REQUEST_DEFAULT_TIMEOUT_MS"); + postTimeoutField.setAccessible(true); + + int oldPostTimeout = postTimeoutField.getInt(null); + postTimeoutField.setInt(null, 30000); + int newPostTimeout = postTimeoutField.getInt(null); + + android.util.Log.i("IterableTestUtils", "✓ POST timeout: " + oldPostTimeout + "ms → " + newPostTimeout + "ms"); + + // Increase GET timeout from 10s to 40s for tests + Field getTimeoutField = taskClass.getDeclaredField("GET_REQUEST_DEFAULT_TIMEOUT_MS"); + getTimeoutField.setAccessible(true); + + int oldGetTimeout = getTimeoutField.getInt(null); + getTimeoutField.setInt(null, 40000); + int newGetTimeout = getTimeoutField.getInt(null); + + android.util.Log.i("IterableTestUtils", "✓ GET timeout: " + oldGetTimeout + "ms → " + newGetTimeout + "ms"); + + // Verify the changes took effect + if (newPostTimeout != 30000 || newGetTimeout != 40000) { + throw new RuntimeException("Failed to update timeouts via reflection"); + } + } + public static void createIterableApi() { - IterableApi.sharedInstance = new IterableApi(mock(IterableInAppManager.class)); + IterableInAppManager inAppManager; + + if (Build.VERSION.SDK_INT >= 36) { + // Android API 36+ blocks Mockito's ByteBuddy from creating dex files in cache + // Pass null instead - the IterableApi constructor supports this + inAppManager = null; + } else { + // On older APIs, use Mockito mock for better test isolation + inAppManager = mock(IterableInAppManager.class); + } + + IterableApi.sharedInstance = new IterableApi(inAppManager); IterableConfig config = new IterableConfig.Builder().build(); initIterableApi(config); IterableApi.getInstance().setEmail("test_email"); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java index 884252383..1348b564e 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableBackgroundInitializer.java @@ -46,12 +46,10 @@ interface QueuedOperation { String getDescription(); } - /** - * Queue for operations called before initialization completes - */ private static class OperationQueue { private final ConcurrentLinkedQueue operations = new ConcurrentLinkedQueue<>(); private volatile boolean isProcessing = false; + private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); void enqueue(QueuedOperation operation) { operations.offer(operation); @@ -59,24 +57,54 @@ void enqueue(QueuedOperation operation) { } void processAll(ExecutorService executor) { - if (isProcessing) return; + if (!canStartProcessing(executor)) { + return; + } + isProcessing = true; + executor.execute(this::processQueuedOperations); + } + + private boolean canStartProcessing(ExecutorService executor) { + if (isProcessing) { + IterableLogger.w(TAG, "Already processing operations, skipping"); + return false; + } + + if (executor == null || executor.isShutdown()) { + IterableLogger.e(TAG, "Cannot process operations: executor unavailable"); + return false; + } + + return true; + } + + + private void processQueuedOperations() { + try { + IterableLogger.d(TAG, "Starting to process queued operations"); - 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); - } + executeOperationOnMainThread(operation); } - isProcessing = false; - // After processing all operations, shut down the executor - IterableLogger.d(TAG, "All queued operations processed, shutting down background executor"); + IterableLogger.d(TAG, "Finished processing queued operations"); + } finally { + isProcessing = false; shutdownBackgroundExecutorAsync(); + } + } + + private void executeOperationOnMainThread(QueuedOperation operation) { + IterableLogger.d(TAG, "Executing queued operation: " + operation.getDescription()); + + mainThreadHandler.post(() -> { + try { + operation.execute(); + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to execute operation: " + operation.getDescription(), e); + } }); } @@ -126,137 +154,180 @@ 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); - } + invokeCallbackOnMainThread(callback); return; } + if (!startInitialization(context, apiKey, config, callback)) { + return; // Already initialized or in progress + } + + IterableLogger.d(TAG, "Starting background initialization"); + backgroundExecutor.execute(() -> runInitializationTask(context, apiKey, config, callback)); + } + + private static boolean startInitialization(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableInitializationCallback callback) { 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; + handleDuplicateInitialization(callback); + return false; } - // Set initializing flag and essential properties inside synchronized block + // Set initializing flag and configure SDK isInitializing = true; IterableApi.sharedInstance._applicationContext = context.getApplicationContext(); IterableApi.sharedInstance._apiKey = apiKey; IterableApi.sharedInstance.config = (config != null) ? config : new IterableConfig.Builder().build(); + return true; } + } - IterableLogger.d(TAG, "Starting background initialization"); + private static void handleDuplicateInitialization(@Nullable IterableInitializationCallback callback) { + IterableLogger.w(TAG, "Initialization already in progress or completed"); + if (callback != null) { + if (isBackgroundInitialized) { + // Already done, call immediately + invokeCallbackOnMainThread(callback); + } else { + // Still running, queue for later + pendingCallbacks.offer(callback); + } + } + } + + private static void runInitializationTask(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + @Nullable IterableInitializationCallback callback) { + long startTime = System.currentTimeMillis(); + ExecutorService initExecutor = createInitExecutor(); + boolean initSucceeded = false; + + try { + initSucceeded = performInitializationWithTimeout(context, apiKey, config, initExecutor, startTime); + } finally { + completeInitialization(callback, startTime, initSucceeded); + shutdownExecutor(initExecutor); + } + } - // Create a separate executor for the actual initialization to enable timeout - ExecutorService initExecutor = Executors.newSingleThreadExecutor(r -> { + private static ExecutorService createInitExecutor() { + return 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; + /** + * @return true if initialization succeeded, false if it timed out or failed + */ + private static boolean performInitializationWithTimeout(@NonNull Context context, + @NonNull String apiKey, + @Nullable IterableConfig config, + ExecutorService initExecutor, + long startTime) { + try { + IterableLogger.d(TAG, "Starting initialization with " + INITIALIZATION_TIMEOUT_SECONDS + "s timeout"); + + Future initFuture = initExecutor.submit(() -> { + IterableLogger.d(TAG, "Executing initialization on background thread"); + IterableApi.initialize(context, apiKey, config); + }); - try { - IterableLogger.d(TAG, "Starting initialization with " + INITIALIZATION_TIMEOUT_SECONDS + " second timeout"); + initFuture.get(INITIALIZATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); - // Submit the actual initialization task - Future initFuture = initExecutor.submit(() -> { - IterableLogger.d(TAG, "Executing initialization on background thread"); - IterableApi.initialize(context, apiKey, config); - }); + long elapsed = System.currentTimeMillis() - startTime; + IterableLogger.d(TAG, "Initialization completed successfully in " + elapsed + "ms"); + return true; - // Wait for initialization with timeout - initFuture.get(INITIALIZATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); - initSucceeded = true; + } catch (TimeoutException e) { + long elapsed = System.currentTimeMillis() - startTime; + IterableLogger.w(TAG, "Initialization timed out after " + elapsed + "ms, continuing anyway"); + initExecutor.shutdownNow(); + return false; - long elapsedTime = System.currentTimeMillis() - startTime; - IterableLogger.d(TAG, "Background initialization completed successfully in " + elapsedTime + "ms"); + } catch (Exception e) { + long elapsed = System.currentTimeMillis() - startTime; + IterableLogger.e(TAG, "Initialization error after " + elapsed + "ms, continuing anyway", e); + return false; + } + } - } 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(); + private static void completeInitialization(@Nullable IterableInitializationCallback callback, + long startTime, + boolean succeeded) { + // Update state + synchronized (initLock) { + isBackgroundInitialized = true; + isInitializing = false; + } - } catch (Exception e) { - long elapsedTime = System.currentTimeMillis() - startTime; - IterableLogger.e(TAG, "Background initialization encountered error after " + elapsedTime + "ms, but continuing", e); + // Process queued operations on background thread, each operation runs on main thread + operationQueue.processAll(backgroundExecutor); + + // Notify callbacks on main thread + notifyInitializationComplete(callback, startTime, succeeded); + } + + private static void notifyInitializationComplete(@Nullable IterableInitializationCallback callback, + long startTime, + boolean succeeded) { + new Handler(Looper.getMainLooper()).post(() -> { + long totalTime = System.currentTimeMillis() - startTime; + if (succeeded) { + IterableLogger.d(TAG, "Notifying callbacks after " + totalTime + "ms"); + } else { + IterableLogger.w(TAG, "Notifying callbacks after timeout/error (" + totalTime + "ms)"); } - // Always mark as completed and call callbacks regardless of success/timeout/failure - synchronized (initLock) { - isBackgroundInitialized = true; - isInitializing = false; + // Call the original callback + invokeCallbackSafely(callback); + + // Call all pending callbacks from duplicate initialization attempts + IterableInitializationCallback pending; + while ((pending = pendingCallbacks.poll()) != null) { + invokeCallbackSafely(pending); } + }); + } - // 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"); - } + private static void invokeCallbackSafely(@Nullable IterableInitializationCallback callback) { + if (callback != null) { + try { + callback.onSDKInitialized(); + } catch (Exception e) { + IterableLogger.e(TAG, "Exception in initialization callback", e); + } + } + } - // 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); - } - } + private static void invokeCallbackOnMainThread(@Nullable IterableInitializationCallback callback) { + if (callback != null) { + new Handler(Looper.getMainLooper()).post(() -> invokeCallbackSafely(callback)); + } + } - } catch (Exception e) { - IterableLogger.e(TAG, "Exception in initialization completion notification", e); + private static void shutdownExecutor(ExecutorService executor) { + try { + if (!executor.isShutdown()) { + executor.shutdown(); + if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { + executor.shutdownNow(); } - }); - - // 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); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } } /** diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java index bd387d64c..1c209fca3 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAsyncInitializationTest.java @@ -1329,8 +1329,18 @@ public void onSDKInitialized() { // Wait for initialization assertTrue("Initialization should complete", waitForAsyncInitialization(initLatch, 3)); - // All operations should be processed - Thread.sleep(200); + boolean queueEmpty = false; + for (int i = 0; i < 50; i++) { // Try for up to 5 seconds (50 * 100ms) + Thread.sleep(100); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + if (IterableBackgroundInitializer.getQueuedOperationCount() == 0) { + queueEmpty = true; + break; + } + } + + assertTrue("All operations should be processed within timeout", queueEmpty); assertEquals("All operations should be processed", 0, IterableBackgroundInitializer.getQueuedOperationCount()); }