From 580b0bc6bb240479f5f293aeaeab8808f878deff Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 12 Nov 2025 12:53:47 -0500 Subject: [PATCH 1/6] Handling race conditions --- .../com/onesignal/internal/OneSignalImp.kt | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 99024c3da4..0d591a338e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -263,7 +263,6 @@ internal class OneSignalImp( suspendifyOnIO { internalInit(context, appId) } - initState = InitState.SUCCESS return true } @@ -306,22 +305,48 @@ internal class OneSignalImp( ) { Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'login'") + // Check state and provide appropriate error messages + when (initState) { + InitState.FAILED -> { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } + InitState.NOT_STARTED -> { + throw IllegalStateException("Must call 'initWithContext' before 'login'") + } + InitState.IN_PROGRESS, InitState.SUCCESS -> { + // Continue - these states allow proceeding (will wait if needed) + } } waitForInit() + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } } override fun logout() { Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'logout'") + // Check state and provide appropriate error messages + when (initState) { + InitState.FAILED -> { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } + InitState.NOT_STARTED -> { + throw IllegalStateException("Must call 'initWithContext' before 'logout'") + } + InitState.IN_PROGRESS, InitState.SUCCESS -> { + // Continue - these states allow proceeding (will wait if needed) + } } waitForInit() + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } suspendifyOnIO { logoutHelper.logout() } } @@ -358,6 +383,10 @@ internal class OneSignalImp( withTimeout(MAX_TIMEOUT_TO_INIT) { initAwaiter.awaitSuspend() } + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } } catch (e: TimeoutCancellationException) { throw IllegalStateException("initWithContext was timed out after $MAX_TIMEOUT_TO_INIT ms") } @@ -384,6 +413,10 @@ internal class OneSignalImp( InitState.IN_PROGRESS -> { Logging.debug("Waiting for init to complete...") waitForInit() + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } } InitState.FAILED -> { throw IllegalStateException("Initialization failed. Cannot proceed.") @@ -391,6 +424,10 @@ internal class OneSignalImp( else -> { // SUCCESS waitForInit() + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } } } From 4c8a9e70cd04b4e6010415815d0edc272cb20daa Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 12 Nov 2025 13:21:47 -0500 Subject: [PATCH 2/6] fix test --- .../core/internal/application/SDKInitTests.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt index 318f6cb1c1..04a5fa3453 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt @@ -150,8 +150,19 @@ class SDKInitTests : FunSpec({ accessorThread.join(500) // Then - // should complete even SharedPreferences is unavailable + // should complete even SharedPreferences is unavailable (non-blocking) accessorThread.isAlive shouldBe false + + // Release the SharedPreferences lock so internalInit can complete + trigger.complete() + + // Wait for initialization to complete (internalInit runs asynchronously) + var attempts = 0 + while (!os.isInitialized && attempts < 50) { + Thread.sleep(20) + attempts++ + } + os.isInitialized shouldBe true } @@ -224,12 +235,23 @@ class SDKInitTests : FunSpec({ accessorThread.start() accessorThread.join(500) - os.isInitialized shouldBe true + // initWithContext should return immediately (non-blocking) + // but isInitialized won't be true until internalInit completes + // which requires SharedPreferences to be unblocked accessorThread.isAlive shouldBe true - // release the lock on SharedPreferences + // release the lock on SharedPreferences so internalInit can complete trigger.complete() + // Wait for initialization to complete (internalInit runs asynchronously) + var initAttempts = 0 + while (!os.isInitialized && initAttempts < 50) { + Thread.sleep(20) + initAttempts++ + } + + os.isInitialized shouldBe true + accessorThread.join(500) accessorThread.isAlive shouldBe false os.user.externalId shouldBe externalId From 7e71e06ab4ed47a07a4b15604e474cf6c643728e Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 12 Nov 2025 13:30:58 -0500 Subject: [PATCH 3/6] updating flow to use debugUnitTest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ba3736d0f..653b3bc3a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: "[Test] SDK Unit Tests" working-directory: OneSignalSDK run: | - ./gradlew testReleaseUnitTest --console=plain --continue + ./gradlew testDebugUnitTest --console=plain --continue - name: "[Coverage] Generate JaCoCo merged XML" working-directory: OneSignalSDK run: | From b51b6b9dd0fd12c1152afcb2a62cff88e9bcc9f0 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 2 Dec 2025 11:14:59 -0500 Subject: [PATCH 4/6] removed exceptions and just logging --- .../com/onesignal/internal/OneSignalImp.kt | 152 +++++++++--------- .../core/internal/application/SDKInitTests.kt | 2 +- .../onesignal/internal/OneSignalImpTests.kt | 23 +-- 3 files changed, 89 insertions(+), 88 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 0d591a338e..809f2c96cf 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -10,6 +10,7 @@ import com.onesignal.common.services.IServiceProvider import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider import com.onesignal.common.threading.CompletionAwaiter +import com.onesignal.common.threading.CompletionAwaiter.Companion.ANDROID_ANR_TIMEOUT_MS import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.CoreModule @@ -305,48 +306,16 @@ internal class OneSignalImp( ) { Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - // Check state and provide appropriate error messages - when (initState) { - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before 'login'") - } - InitState.IN_PROGRESS, InitState.SUCCESS -> { - // Continue - these states allow proceeding (will wait if needed) - } - } + waitForInit(operationName = "login") - waitForInit() - // Re-check state after waiting - init might have failed during the wait - if (initState == InitState.FAILED) { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } } override fun logout() { Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") - // Check state and provide appropriate error messages - when (initState) { - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before 'logout'") - } - InitState.IN_PROGRESS, InitState.SUCCESS -> { - // Continue - these states allow proceeding (will wait if needed) - } - } + waitForInit(operationName = "logout") - waitForInit() - // Re-check state after waiting - init might have failed during the wait - if (initState == InitState.FAILED) { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } suspendifyOnIO { logoutHelper.logout() } } @@ -358,29 +327,45 @@ internal class OneSignalImp( override fun getAllServices(c: Class): List = services.getAllServices(c) - private fun waitForInit() { - val completed = initAwaiter.await() - if (!completed) { - throw IllegalStateException("initWithContext was not called or timed out") + /** + * Gets the appropriate timeout based on the current thread context. + * Uses shorter timeout on main thread to prevent ANRs. + */ + private fun getTimeoutForCurrentThread(): Long { + return try { + if (AndroidUtils.isRunningOnMainThread()) { + ANDROID_ANR_TIMEOUT_MS + } else { + MAX_TIMEOUT_TO_INIT + } + } catch (e: RuntimeException) { + // In test environments, AndroidUtils.isRunningOnMainThread() may fail + // because Looper.getMainLooper() is not mocked. Default to longer timeout. + MAX_TIMEOUT_TO_INIT } } /** - * Notifies both blocking and suspend callers that initialization is complete + * Common implementation for waiting until initialization completes. + * Handles all state checks and timeout logic. + * + * @param timeoutMs Timeout in milliseconds + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") */ - private fun notifyInitComplete() { - initAwaiter.complete() - } - - private suspend fun suspendUntilInit() { + private suspend fun waitUntilInitInternal(timeoutMs: Long, operationName: String? = null) { when (initState) { InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") + val message = if (operationName != null) { + "Must call 'initWithContext' before '$operationName'" + } else { + "Must call 'initWithContext' before use" + } + throw IllegalStateException(message) } InitState.IN_PROGRESS -> { - Logging.debug("Suspend waiting for init to complete...") + Logging.debug("Waiting for init to complete...") try { - withTimeout(MAX_TIMEOUT_TO_INIT) { + withTimeout(timeoutMs) { initAwaiter.awaitSuspend() } // Re-check state after waiting - init might have failed during the wait @@ -388,7 +373,11 @@ internal class OneSignalImp( throw IllegalStateException("Initialization failed. Cannot proceed.") } } catch (e: TimeoutCancellationException) { - throw IllegalStateException("initWithContext was timed out after $MAX_TIMEOUT_TO_INIT ms") + Logging.warn("OneSignalImp is taking longer than normal! (timeout: ${timeoutMs}ms). Proceeding anyway, but operations may fail if initialization is not complete.", e) + // Re-check state after timeout - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } } } InitState.FAILED -> { @@ -400,37 +389,47 @@ internal class OneSignalImp( } } + /** + * Blocking version that waits for initialization to complete. + * Uses runBlocking to bridge to the suspend implementation. + * Preserves context-aware timeout behavior (shorter on main thread to prevent ANRs). + * + * @param timeoutMs Optional timeout in milliseconds. If not provided, uses context-aware timeout. + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private fun waitForInit(timeoutMs: Long? = null, operationName: String? = null) { + val actualTimeout = timeoutMs ?: getTimeoutForCurrentThread() + runBlocking(ioDispatcher) { + waitUntilInitInternal(actualTimeout, operationName) + } + } + + /** + * Suspend version that waits for initialization to complete. + * Uses context-aware timeout (shorter on main thread to prevent ANRs). + * + * @param timeoutMs Optional timeout in milliseconds. If not provided, uses context-aware timeout. + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun suspendUntilInit(timeoutMs: Long? = null, operationName: String? = null) { + val actualTimeout = timeoutMs ?: getTimeoutForCurrentThread() + waitUntilInitInternal(actualTimeout, operationName) + } + + /** + * Notifies both blocking and suspend callers that initialization is complete + */ + private fun notifyInitComplete() { + initAwaiter.complete() + } + private suspend fun suspendAndReturn(getter: () -> T): T { suspendUntilInit() return getter() } private fun waitAndReturn(getter: () -> T): T { - when (initState) { - InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") - } - InitState.IN_PROGRESS -> { - Logging.debug("Waiting for init to complete...") - waitForInit() - // Re-check state after waiting - init might have failed during the wait - if (initState == InitState.FAILED) { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - } - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - else -> { - // SUCCESS - waitForInit() - // Re-check state after waiting - init might have failed during the wait - if (initState == InitState.FAILED) { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - } - } - + waitForInit() return getter() } @@ -444,8 +443,9 @@ internal class OneSignalImp( // because Looper.getMainLooper() is not mocked. This is safe to ignore. Logging.debug("Could not check main thread status (likely in test environment): ${e.message}") } + // Call suspendAndReturn directly to avoid nested runBlocking (waitAndReturn -> waitForInit -> runBlocking) return runBlocking(ioDispatcher) { - waitAndReturn(getter) + suspendAndReturn(getter) } } @@ -545,7 +545,7 @@ internal class OneSignalImp( ) = withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - suspendUntilInit() + suspendUntilInit(operationName = "login") if (!isInitialized) { throw IllegalStateException("'initWithContext failed' before 'login'") } @@ -557,7 +557,7 @@ internal class OneSignalImp( withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "logoutSuspend()") - suspendUntilInit() + suspendUntilInit(operationName = "logout") if (!isInitialized) { throw IllegalStateException("'initWithContext failed' before 'logout'") diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt index 04a5fa3453..5844a96809 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt @@ -137,7 +137,7 @@ class SDKInitTests : FunSpec({ // block SharedPreference before calling init val trigger = CompletionAwaiter("Test") val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 1000) + val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) val os = OneSignalImp() // When diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt index e5e49f1ec0..9599d2bd5f 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt @@ -215,14 +215,14 @@ class OneSignalImpTests : FunSpec({ test("waitForInit timeout behavior - this test demonstrates the timeout mechanism") { // This test documents that waitForInit() has timeout protection // In a real scenario, if initWithContext was never called, - // waitForInit() would timeout after 30 seconds and throw an exception + // waitForInit() would timeout after 30 seconds and log a warning (not throw) // Given - a fresh OneSignalImp instance val oneSignalImp = OneSignalImp() - // The timeout behavior is built into CompletionAwaiter.await() - // which waits for up to 30 seconds (or 4.8 seconds on main thread) - // before timing out and returning false + // The timeout behavior is built into waitUntilInitInternal() + // which uses withTimeout() to wait for up to 30 seconds (or 4.8 seconds on main thread) + // before logging a warning and proceeding // NOTE: We don't actually test the 30-second timeout here because: // 1. It would make tests too slow (30 seconds per test) @@ -234,13 +234,13 @@ class OneSignalImpTests : FunSpec({ test("waitForInit timeout mechanism exists - CompletionAwaiter integration") { // This test verifies that the timeout mechanism is properly integrated - // by checking that CompletionAwaiter has timeout capabilities + // by checking that waitUntilInitInternal has timeout capabilities // Given val oneSignalImp = OneSignalImp() - // The timeout behavior is implemented through CompletionAwaiter.await() - // which has a default timeout of 30 seconds (or 4.8 seconds on main thread) + // The timeout behavior is implemented through waitUntilInitInternal() + // which uses withTimeout() with a default timeout of 30 seconds (or 4.8 seconds on main thread) // We can verify the timeout mechanism exists by checking: // 1. The CompletionAwaiter is properly initialized @@ -250,10 +250,11 @@ class OneSignalImpTests : FunSpec({ oneSignalImp.isInitialized shouldBe false // In a real scenario where initWithContext is never called: - // - waitForInit() would call initAwaiter.await() - // - CompletionAwaiter.await() would wait up to 30 seconds - // - After timeout, it would return false - // - waitForInit() would then throw "initWithContext was not called or timed out" + // - waitForInit() would call waitUntilInitInternal() + // - waitUntilInitInternal() would check initState == NOT_STARTED and throw immediately + // - If initState was IN_PROGRESS, it would use withTimeout() to wait up to 30 seconds + // - After timeout during IN_PROGRESS, it would log "OneSignalImp is taking longer than normal!" and proceed + // - waitForInit() throws for NOT_STARTED/FAILED states, but only logs (doesn't throw) on timeout during IN_PROGRESS // This test documents this behavior without actually waiting 30 seconds } From fb729bba9dba895b2f3b0b88feea35f25a15373b Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Tue, 2 Dec 2025 11:22:49 -0500 Subject: [PATCH 5/6] Change unit test command to testReleaseUnitTest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 653b3bc3a6..1ba3736d0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: "[Test] SDK Unit Tests" working-directory: OneSignalSDK run: | - ./gradlew testDebugUnitTest --console=plain --continue + ./gradlew testReleaseUnitTest --console=plain --continue - name: "[Coverage] Generate JaCoCo merged XML" working-directory: OneSignalSDK run: | From 6e2531e67ef57e9147452c71458fe7de89456cc7 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 2 Dec 2025 11:28:48 -0500 Subject: [PATCH 6/6] moving the code a bit --- .../common/threading/CompletionAwaiter.kt | 15 +++- .../com/onesignal/internal/OneSignalImp.kt | 79 +++++++------------ 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt index 880556393b..be58aff76f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt @@ -88,8 +88,19 @@ class CompletionAwaiter( suspendCompletion.await() } - private fun getDefaultTimeout(): Long { - return if (AndroidUtils.isRunningOnMainThread()) ANDROID_ANR_TIMEOUT_MS else DEFAULT_TIMEOUT_MS + /** + * Gets the appropriate timeout based on the current thread context. + * Uses shorter timeout on main thread to prevent ANRs. + * Made internal so it can be reused by other classes. + */ + internal fun getDefaultTimeout(): Long { + return try { + if (AndroidUtils.isRunningOnMainThread()) ANDROID_ANR_TIMEOUT_MS else DEFAULT_TIMEOUT_MS + } catch (e: RuntimeException) { + // In test environments, AndroidUtils.isRunningOnMainThread() may fail + // because Looper.getMainLooper() is not mocked. Default to longer timeout. + DEFAULT_TIMEOUT_MS + } } private fun createTimeoutMessage(timeoutMs: Long): String { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 809f2c96cf..b466366fca 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -10,7 +10,6 @@ import com.onesignal.common.services.IServiceProvider import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider import com.onesignal.common.threading.CompletionAwaiter -import com.onesignal.common.threading.CompletionAwaiter.Companion.ANDROID_ANR_TIMEOUT_MS import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.CoreModule @@ -46,8 +45,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -private const val MAX_TIMEOUT_TO_INIT = 30_000L // 30 seconds - internal class OneSignalImp( private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO, ) : IOneSignal, IServiceProvider { @@ -328,23 +325,39 @@ internal class OneSignalImp( override fun getAllServices(c: Class): List = services.getAllServices(c) /** - * Gets the appropriate timeout based on the current thread context. - * Uses shorter timeout on main thread to prevent ANRs. + * Notifies both blocking and suspend callers that initialization is complete */ - private fun getTimeoutForCurrentThread(): Long { - return try { - if (AndroidUtils.isRunningOnMainThread()) { - ANDROID_ANR_TIMEOUT_MS - } else { - MAX_TIMEOUT_TO_INIT - } - } catch (e: RuntimeException) { - // In test environments, AndroidUtils.isRunningOnMainThread() may fail - // because Looper.getMainLooper() is not mocked. Default to longer timeout. - MAX_TIMEOUT_TO_INIT + private fun notifyInitComplete() { + initAwaiter.complete() + } + + /** + * Blocking version that waits for initialization to complete. + * Uses runBlocking to bridge to the suspend implementation. + * Preserves context-aware timeout behavior (shorter on main thread to prevent ANRs). + * + * @param timeoutMs Optional timeout in milliseconds. If not provided, uses context-aware timeout. + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private fun waitForInit(timeoutMs: Long? = null, operationName: String? = null) { + val actualTimeout = timeoutMs ?: initAwaiter.getDefaultTimeout() + runBlocking(ioDispatcher) { + waitUntilInitInternal(actualTimeout, operationName) } } + /** + * Suspend version that waits for initialization to complete. + * Uses context-aware timeout (shorter on main thread to prevent ANRs). + * + * @param timeoutMs Optional timeout in milliseconds. If not provided, uses context-aware timeout. + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun suspendUntilInit(timeoutMs: Long? = null, operationName: String? = null) { + val actualTimeout = timeoutMs ?: initAwaiter.getDefaultTimeout() + waitUntilInitInternal(actualTimeout, operationName) + } + /** * Common implementation for waiting until initialization completes. * Handles all state checks and timeout logic. @@ -389,40 +402,6 @@ internal class OneSignalImp( } } - /** - * Blocking version that waits for initialization to complete. - * Uses runBlocking to bridge to the suspend implementation. - * Preserves context-aware timeout behavior (shorter on main thread to prevent ANRs). - * - * @param timeoutMs Optional timeout in milliseconds. If not provided, uses context-aware timeout. - * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") - */ - private fun waitForInit(timeoutMs: Long? = null, operationName: String? = null) { - val actualTimeout = timeoutMs ?: getTimeoutForCurrentThread() - runBlocking(ioDispatcher) { - waitUntilInitInternal(actualTimeout, operationName) - } - } - - /** - * Suspend version that waits for initialization to complete. - * Uses context-aware timeout (shorter on main thread to prevent ANRs). - * - * @param timeoutMs Optional timeout in milliseconds. If not provided, uses context-aware timeout. - * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") - */ - private suspend fun suspendUntilInit(timeoutMs: Long? = null, operationName: String? = null) { - val actualTimeout = timeoutMs ?: getTimeoutForCurrentThread() - waitUntilInitInternal(actualTimeout, operationName) - } - - /** - * Notifies both blocking and suspend callers that initialization is complete - */ - private fun notifyInitComplete() { - initAwaiter.complete() - } - private suspend fun suspendAndReturn(getter: () -> T): T { suspendUntilInit() return getter()