Skip to content

Commit f63a583

Browse files
abdulraqeeb33AR Abdul Azeez
andauthored
fix: Remove throwing "initWithContext was not called or timed out", introduced in 5.4.0 (#2408)
Co-authored-by: AR Abdul Azeez <abdul@onesignal.com>
1 parent 1bd49af commit f63a583

File tree

9 files changed

+239
-675
lines changed

9 files changed

+239
-675
lines changed

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt

Lines changed: 0 additions & 135 deletions
This file was deleted.

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import java.util.concurrent.atomic.AtomicInteger
2525
* - Small bounded queues (10 tasks) to prevent memory bloat
2626
* - Reduced context switching overhead
2727
* - Efficient thread management with controlled resource usage
28+
*
29+
* Made public to allow mocking in tests via IOMockHelper.
2830
*/
29-
internal object OneSignalDispatchers {
31+
object OneSignalDispatchers {
3032
// Optimized pool sizes based on CPU cores and workload analysis
3133
private const val IO_CORE_POOL_SIZE = 2 // Increased for better concurrency
3234
private const val IO_MAX_POOL_SIZE = 3 // Increased for better concurrency
@@ -35,7 +37,7 @@ internal object OneSignalDispatchers {
3537
private const val KEEP_ALIVE_TIME_SECONDS =
3638
30L // Keep threads alive longer to reduce recreation
3739
private const val QUEUE_CAPACITY =
38-
10 // Small queue that allows up to 10 tasks to wait in queue when all threads are busy
40+
200 // Increased to handle more queued operations during init, while still preventing memory bloat
3941
internal const val BASE_THREAD_NAME = "OneSignal" // Base thread name prefix
4042
private const val IO_THREAD_NAME_PREFIX =
4143
"$BASE_THREAD_NAME-IO" // Thread name prefix for I/O operations

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt

Lines changed: 73 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import com.onesignal.common.modules.IModule
99
import com.onesignal.common.services.IServiceProvider
1010
import com.onesignal.common.services.ServiceBuilder
1111
import com.onesignal.common.services.ServiceProvider
12-
import com.onesignal.common.threading.CompletionAwaiter
1312
import com.onesignal.common.threading.OneSignalDispatchers
1413
import com.onesignal.common.threading.suspendifyOnIO
1514
import com.onesignal.core.CoreModule
@@ -39,19 +38,16 @@ import com.onesignal.user.internal.identity.IdentityModelStore
3938
import com.onesignal.user.internal.properties.PropertiesModelStore
4039
import com.onesignal.user.internal.resolveAppId
4140
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
41+
import kotlinx.coroutines.CompletableDeferred
4242
import kotlinx.coroutines.CoroutineDispatcher
43-
import kotlinx.coroutines.TimeoutCancellationException
4443
import kotlinx.coroutines.runBlocking
4544
import kotlinx.coroutines.withContext
46-
import kotlinx.coroutines.withTimeout
47-
48-
private const val MAX_TIMEOUT_TO_INIT = 30_000L // 30 seconds
4945

5046
internal class OneSignalImp(
5147
private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO,
5248
) : IOneSignal, IServiceProvider {
53-
@Volatile
54-
private var initAwaiter = CompletionAwaiter("OneSignalImp")
49+
50+
private val suspendCompletion = CompletableDeferred<Unit>()
5551

5652
@Volatile
5753
private var initState: InitState = InitState.NOT_STARTED
@@ -263,7 +259,6 @@ internal class OneSignalImp(
263259
suspendifyOnIO {
264260
internalInit(context, appId)
265261
}
266-
initState = InitState.SUCCESS
267262
return true
268263
}
269264

@@ -306,22 +301,16 @@ internal class OneSignalImp(
306301
) {
307302
Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)")
308303

309-
if (!initState.isSDKAccessible()) {
310-
throw IllegalStateException("Must call 'initWithContext' before 'login'")
311-
}
304+
waitForInit(operationName = "login")
312305

313-
waitForInit()
314306
suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) }
315307
}
316308

317309
override fun logout() {
318310
Logging.log(LogLevel.DEBUG, "Calling deprecated logout()")
319311

320-
if (!initState.isSDKAccessible()) {
321-
throw IllegalStateException("Must call 'initWithContext' before 'logout'")
322-
}
312+
waitForInit(operationName = "logout")
323313

324-
waitForInit()
325314
suspendifyOnIO { logoutHelper.logout() }
326315
}
327316

@@ -333,34 +322,82 @@ internal class OneSignalImp(
333322

334323
override fun <T> getAllServices(c: Class<T>): List<T> = services.getAllServices(c)
335324

336-
private fun waitForInit() {
337-
val completed = initAwaiter.await()
338-
if (!completed) {
339-
throw IllegalStateException("initWithContext was not called or timed out")
325+
/**
326+
* Blocking version that waits for initialization to complete.
327+
* Uses runBlocking to bridge to the suspend implementation.
328+
* Waits indefinitely until init completes and logs how long it took.
329+
*
330+
* @param operationName Optional operation name to include in error messages (e.g., "login", "logout")
331+
*/
332+
private fun waitForInit(operationName: String? = null) {
333+
runBlocking(ioDispatcher) {
334+
waitUntilInitInternal(operationName)
340335
}
341336
}
342337

343338
/**
344339
* Notifies both blocking and suspend callers that initialization is complete
345340
*/
346341
private fun notifyInitComplete() {
347-
initAwaiter.complete()
342+
suspendCompletion.complete(Unit)
348343
}
349344

350-
private suspend fun suspendUntilInit() {
345+
/**
346+
* Suspend version that waits for initialization to complete.
347+
* Waits indefinitely until init completes and logs how long it took.
348+
*
349+
* @param operationName Optional operation name to include in error messages (e.g., "login", "logout")
350+
*/
351+
private suspend fun suspendUntilInit(operationName: String? = null) {
352+
waitUntilInitInternal(operationName)
353+
}
354+
355+
/**
356+
* Common implementation for waiting until initialization completes.
357+
* Waits indefinitely until init completes (SUCCESS or FAILED) to ensure consistent state.
358+
* Logs how long initialization took when it completes.
359+
*
360+
* @param operationName Optional operation name to include in error messages (e.g., "login", "logout")
361+
*/
362+
private suspend fun waitUntilInitInternal(operationName: String? = null) {
351363
when (initState) {
352364
InitState.NOT_STARTED -> {
353-
throw IllegalStateException("Must call 'initWithContext' before use")
365+
val message = if (operationName != null) {
366+
"Must call 'initWithContext' before '$operationName'"
367+
} else {
368+
"Must call 'initWithContext' before use"
369+
}
370+
throw IllegalStateException(message)
354371
}
355372
InitState.IN_PROGRESS -> {
356-
Logging.debug("Suspend waiting for init to complete...")
357-
try {
358-
withTimeout(MAX_TIMEOUT_TO_INIT) {
359-
initAwaiter.awaitSuspend()
360-
}
361-
} catch (e: TimeoutCancellationException) {
362-
throw IllegalStateException("initWithContext was timed out after $MAX_TIMEOUT_TO_INIT ms")
373+
Logging.debug("Waiting for init to complete...")
374+
375+
val startTime = System.currentTimeMillis()
376+
377+
// Wait indefinitely until init actually completes - ensures consistent state
378+
// Function only returns when initState is SUCCESS or FAILED
379+
// NOTE: This is a suspend function, so it's non-blocking when called from coroutines.
380+
// However, if waitForInit() (which uses runBlocking) is called from the main thread,
381+
// it will block the main thread indefinitely until init completes, which can cause ANRs.
382+
// This is intentional per PR #2412: "ANR is the lesser of two evils and the app can recover,
383+
// where an uncaught throw it can not." To avoid ANRs, call SDK methods from background threads
384+
// or use the suspend API from coroutines.
385+
suspendCompletion.await()
386+
387+
// Log how long initialization took
388+
val elapsed = System.currentTimeMillis() - startTime
389+
val message = if (operationName != null) {
390+
"OneSignalImp initialization completed before '$operationName' (took ${elapsed}ms)"
391+
} else {
392+
"OneSignalImp initialization completed (took ${elapsed}ms)"
393+
}
394+
Logging.debug(message)
395+
396+
// Re-check state after waiting - init might have failed during the wait
397+
if (initState == InitState.FAILED) {
398+
throw IllegalStateException("Initialization failed. Cannot proceed.")
363399
}
400+
// initState is guaranteed to be SUCCESS here - consistent state
364401
}
365402
InitState.FAILED -> {
366403
throw IllegalStateException("Initialization failed. Cannot proceed.")
@@ -377,23 +414,7 @@ internal class OneSignalImp(
377414
}
378415

379416
private fun <T> waitAndReturn(getter: () -> T): T {
380-
when (initState) {
381-
InitState.NOT_STARTED -> {
382-
throw IllegalStateException("Must call 'initWithContext' before use")
383-
}
384-
InitState.IN_PROGRESS -> {
385-
Logging.debug("Waiting for init to complete...")
386-
waitForInit()
387-
}
388-
InitState.FAILED -> {
389-
throw IllegalStateException("Initialization failed. Cannot proceed.")
390-
}
391-
else -> {
392-
// SUCCESS
393-
waitForInit()
394-
}
395-
}
396-
417+
waitForInit()
397418
return getter()
398419
}
399420

@@ -407,8 +428,9 @@ internal class OneSignalImp(
407428
// because Looper.getMainLooper() is not mocked. This is safe to ignore.
408429
Logging.debug("Could not check main thread status (likely in test environment): ${e.message}")
409430
}
431+
// Call suspendAndReturn directly to avoid nested runBlocking (waitAndReturn -> waitForInit -> runBlocking)
410432
return runBlocking(ioDispatcher) {
411-
waitAndReturn(getter)
433+
suspendAndReturn(getter)
412434
}
413435
}
414436

@@ -508,7 +530,8 @@ internal class OneSignalImp(
508530
) = withContext(ioDispatcher) {
509531
Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)")
510532

511-
suspendUntilInit()
533+
suspendUntilInit(operationName = "login")
534+
512535
if (!isInitialized) {
513536
throw IllegalStateException("'initWithContext failed' before 'login'")
514537
}
@@ -520,7 +543,7 @@ internal class OneSignalImp(
520543
withContext(ioDispatcher) {
521544
Logging.log(LogLevel.DEBUG, "logoutSuspend()")
522545

523-
suspendUntilInit()
546+
suspendUntilInit(operationName = "logout")
524547

525548
if (!isInitialized) {
526549
throw IllegalStateException("'initWithContext failed' before 'logout'")

0 commit comments

Comments
 (0)