Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ object OneSignalDispatchers {
* thread (Activity-lifecycle handler, `JobService.onStartJob`, etc.), the construction cost
* — which includes a `kotlinx.coroutines.BuildersKt.launch` that hits
* `ThreadPoolExecutor.execute` and `LinkedBlockingQueue.offer` synchronously — is paid on
* the calling thread, blocking the main thread for many seconds on cold start. See SDK-4507.
* the calling thread, blocking the main thread for many seconds on cold start.
*
* Calling [prewarm] from a non-time-sensitive spot in [com.onesignal.OneSignal.initWithContext]
* shifts that cost to a dedicated `OneSignal-prewarm` daemon thread, so the first
Expand All @@ -199,31 +199,69 @@ object OneSignalDispatchers {
* on the caller. Failures are logged and swallowed because the executors retain their
* existing fallback paths (e.g. `Dispatchers.IO.limitedParallelism(1)` for [SerialIO]) and a
* failed prewarm will simply mean the first production caller pays the original cost.
*
* **Best-effort, not a hard guarantee.** Because [prewarm] is fire-and-forget, a caller that
* dispatches immediately afterward can still win the lazy-init race and pay construction on
* its own thread. The fix relies on placing [prewarm] at cold-start entry points where there
* is meaningful lead time (e.g. a `goAsync()` handoff or `initWithContext` work) before the
* first `suspendify*` / `launchOn*` dispatch.
*
* [suspendifyOnIO], [suspendifyOnDefault], [launchOnIO], and [launchOnDefault] route through
* [IO] / [Default] only when [ThreadingMode.useBackgroundThreading] is true. With that mode
* off, they use `Dispatchers.IO` / `Dispatchers.Default`, so [prewarm] mainly benefits future
* calls after the mode flips; [suspendifyOnSerialIO] always routes through [SerialIO].
*
* The known main-thread cold-start entry points:
* | Entry point | Class |
* |---|---|
* | `initWithContext` / `initWithContextSuspend` | `OneSignalImp` |
* | `onStartJob` | `core.services.SyncJobService` |
* | `onReceive` | `FCMBroadcastReceiver`, `NotificationDismissReceiver`, `BootUpReceiver`, `UpgradeReceiver` |
* | `onMessage` / registration callbacks | `ADMMessageHandler`, `ADMMessageHandlerJob` |
* | `onNewToken` / `onMessageReceived` | `OneSignalHmsEventBridge` |
* | `processIntent` / `processOpen` | `NotificationOpenedActivityBase`, `NotificationOpenedActivityHMS` |
*
* When adding a new cold-start entry point (receiver, job, activity trampoline, push bridge),
* call [prewarm] at the top of it before the first dispatch.
Comment thread
claude[bot] marked this conversation as resolved.
*/
@Suppress("TooGenericExceptionCaught")
fun prewarm() {
if (prewarmStarted) return
synchronized(prewarmLock) {
if (prewarmStarted) return
prewarmStarted = true
}
val prewarmThread = Thread(
{
try {
// Each launch* call below triggers the corresponding lazy chain
// (executor -> dispatcher -> scope) and submits an empty coroutine,
// which forces the worker thread(s) to start as well.
launchOnIO { /* warm IOScope + ioExecutor */ }
launchOnDefault { /* warm DefaultScope + defaultExecutor */ }
launchOnSerialIO { /* warm SerialIOScope + serialIOExecutor */ }
} catch (e: Exception) {
Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}")
}
},
"$BASE_THREAD_NAME-prewarm",
)
prewarmThread.isDaemon = true
prewarmThread.priority = Thread.NORM_PRIORITY - 2
prewarmThread.start()
try {
val prewarmThread = Thread(
{
try {
// Each launch* call below triggers the corresponding lazy chain
// (executor -> dispatcher -> scope) and submits an empty coroutine,
// which forces the worker thread(s) to start as well.
launchOnIO { /* warm IOScope + ioExecutor */ }
launchOnDefault { /* warm DefaultScope + defaultExecutor */ }
launchOnSerialIO { /* warm SerialIOScope + serialIOExecutor */ }
} catch (e: Throwable) {
synchronized(prewarmLock) { prewarmStarted = false }
Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}", e)
}
Comment thread
claude[bot] marked this conversation as resolved.
},
"$BASE_THREAD_NAME-prewarm",
)
prewarmThread.isDaemon = true
prewarmThread.priority = Thread.NORM_PRIORITY - 2
prewarmThread.start()
} catch (t: Throwable) {
// Constructing, configuring, or starting the daemon can itself fail before the body
// ever runs (e.g. OutOfMemoryError "unable to create new native thread", a
// SecurityManager denial, or InternalError). Swallow it so a prewarm failure never
// propagates onto the cold-start entry point's caller thread, and reset the guard so a
// later entry point can retry. The dispatchers keep their lazy fallbacks, so the only
// cost of a failed prewarm is that the first real dispatch pays the original
// construction cost.
synchronized(prewarmLock) { prewarmStarted = false }
Logging.warn("OneSignalDispatchers.prewarm failed to start daemon: ${t.message}", t)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ enum class FeatureFlag(
),
;

/**
* Whether this flag's [key] is present in [enabledKeys] (the set of enabled feature keys
* resolved from remote config / local overrides).
*/
fun isEnabledIn(enabledKeys: Set<String>): Boolean {
return enabledKeys.contains(key)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.debug.internal.logging.Logging
import kotlinx.serialization.json.JsonObject

/**
* Resolves backend-driven [FeatureFlag] state for the current device run and exposes the
* enabled set to the rest of the SDK. State is derived from cached/remote config and
* [FeatureActivationMode] latching rules.
*/
interface IFeatureManager {
/**
* Whether [feature] is enabled for the current run, after applying remote config and
* [FeatureActivationMode] latching rules.
*/
fun isEnabled(feature: FeatureFlag): Boolean

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -667,47 +667,9 @@ internal class OneSignalImp(
}

when (observedState) {
InitState.NOT_STARTED -> {
val message =
if (operationName != null) {
"Must call 'initWithContext' before '$operationName'"
} else {
"Must call 'initWithContext' before use"
}
throw IllegalStateException(message)
}

InitState.IN_PROGRESS -> {
Logging.debug("Waiting for init to complete...")

val startTime = System.currentTimeMillis()

// Wait indefinitely until init actually completes - ensures consistent state
// Function only returns when initState is SUCCESS or FAILED
// NOTE: This is a suspend function, so it's non-blocking when called from coroutines.
// However, if waitForInit() (which uses runBlocking) is called from the main thread,
// it will block the main thread indefinitely until init completes, which can cause ANRs.
// This is intentional per PR #2412: "ANR is the lesser of two evils and the app can recover,
// where an uncaught throw it can not." To avoid ANRs, call SDK methods from background threads
// or use the suspend API from coroutines.
completionToAwait!!.await()

// Log how long initialization took
val elapsed = System.currentTimeMillis() - startTime
val message =
if (operationName != null) {
"OneSignalImp initialization completed before '$operationName' (took ${elapsed}ms)"
} else {
"OneSignalImp initialization completed (took ${elapsed}ms)"
}
Logging.debug(message)
InitState.NOT_STARTED -> throw IllegalStateException(notInitializedMessage(operationName))

// Re-check state after waiting - init might have failed during the wait
if (initState == InitState.FAILED) {
throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.")
}
// initState is guaranteed to be SUCCESS here - consistent state
}
InitState.IN_PROGRESS -> awaitInitCompletion(completionToAwait!!, operationName)

InitState.FAILED -> {
throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.")
Expand All @@ -719,6 +681,48 @@ internal class OneSignalImp(
}
}

private fun notInitializedMessage(operationName: String?): String =
if (operationName != null) {
"Must call 'initWithContext' before '$operationName'"
} else {
"Must call 'initWithContext' before use"
}

private suspend fun awaitInitCompletion(
completionToAwait: CompletableDeferred<Unit>,
operationName: String?,
) {
Logging.debug("Waiting for init to complete...")

val startTime = System.currentTimeMillis()

// Wait indefinitely until init actually completes - ensures consistent state
// Function only returns when initState is SUCCESS or FAILED
// NOTE: This is a suspend function, so it's non-blocking when called from coroutines.
// However, if waitForInit() (which uses runBlocking) is called from the main thread,
// it will block the main thread indefinitely until init completes, which can cause ANRs.
// This is intentional per PR #2412: "ANR is the lesser of two evils and the app can recover,
// where an uncaught throw it can not." To avoid ANRs, call SDK methods from background threads
// or use the suspend API from coroutines.
completionToAwait.await()

// Log how long initialization took
val elapsed = System.currentTimeMillis() - startTime
val message =
if (operationName != null) {
"OneSignalImp initialization completed before '$operationName' (took ${elapsed}ms)"
} else {
"OneSignalImp initialization completed (took ${elapsed}ms)"
}
Logging.debug(message)

// Re-check state after waiting - init might have failed during the wait
if (initState == InitState.FAILED) {
throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.")
}
// initState is guaranteed to be SUCCESS here - consistent state
}

private suspend fun <T> suspendAndReturn(getter: () -> T): T {
suspendUntilInit()
return getter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@ class JwtTokenStore(
private val internalUpdateListeners = EventProducer<IJwtUpdateListener>()
private val publicInvalidatedListeners = EventProducer<IUserJwtInvalidatedListener>()

/** Subscribes an SDK-internal listener notified when a stored JWT changes. */
fun addInternalUpdateListener(listener: IJwtUpdateListener) = internalUpdateListeners.subscribe(listener)

/** Unsubscribes a previously added SDK-internal [IJwtUpdateListener]. */
fun removeInternalUpdateListener(listener: IJwtUpdateListener) = internalUpdateListeners.unsubscribe(listener)

/** Subscribes a developer-facing listener notified when a JWT is invalidated. */
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
publicInvalidatedListeners.subscribe(listener)

/** Unsubscribes a previously added developer-facing [IUserJwtInvalidatedListener]. */
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
publicInvalidatedListeners.unsubscribe(listener)

/** Returns the JWT stored for [externalId], or `null` if none is held. */
fun getJwt(externalId: String): String? {
synchronized(tokens) {
ensureLoaded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ package com.onesignal.core.services

import android.app.job.JobParameters
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.core.internal.background.IBackgroundManager
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.mocks.IOMockHelper
import com.onesignal.mocks.IOMockHelper.awaitIO
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import io.mockk.unmockkAll
import io.mockk.unmockkObject
import io.mockk.verify
import io.mockk.verifyOrder

private class Mocks {
val syncJobService = spyk(SyncJobService(), recordPrivateCalls = true)
Expand All @@ -34,10 +38,31 @@ class SyncJobServiceTests : FunSpec({
mocks = Mocks() // fresh instance for each test
mockkObject(OneSignal)
every { OneSignal.getService<IBackgroundManager>() } returns mocks.mockBackgroundManager
// IOMockHelper owns the OneSignalDispatchers object mock (incl. the prewarm() stub) for the
// whole spec; every onStartJob below calls prewarm(). Clear only its recorded calls so the
// ordering test's count starts at zero, while keeping IOMockHelper's stubbed answers.
clearMocks(OneSignalDispatchers, answers = false)
}

afterAny {
unmockkAll()
// Tear down only the mock this spec owns. OneSignalDispatchers and the ThreadUtils statics
// are owned by IOMockHelper and torn down in its afterSpec — unmockkAll() here would strip
// them after the first test and break the remaining ones.
unmockkObject(OneSignal)
}

test("onStartJob calls prewarm before suspendifyOnIO") {
coEvery { OneSignal.initWithContext(any()) } returns false

mocks.syncJobService.onStartJob(mocks.jobParameters)

verify(exactly = 1) { OneSignalDispatchers.prewarm() }
// prewarm() must run before the first suspendifyOnIO dispatch, otherwise the cold-init cost
// would already be paid on the caller (main) thread by the time the helper is entered.
verifyOrder {
OneSignalDispatchers.prewarm()
suspendifyOnIO(any<suspend () -> Unit>())
}
}

test("onStartJob returns true when initWithContext fails") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ package com.onesignal
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnDefault
import com.onesignal.core.internal.application.OneSignalInternalActivity
import com.onesignal.notifications.internal.open.INotificationOpenedProcessorHMS
Expand Down Expand Up @@ -79,6 +80,9 @@ class NotificationOpenedActivityHMS :
}

private fun processOpen(intent: Intent?) {
// HMS notification-open trampoline runs on the main thread and can cold-start the process;
// warm dispatchers before the first suspendifyOnDefault dispatch.
OneSignalDispatchers.prewarm()
suspendifyOnDefault {
if (!OneSignal.initWithContext(applicationContext)) {
return@suspendifyOnDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import android.content.Intent
import android.os.Bundle
import com.onesignal.OneSignal
import com.onesignal.common.AndroidUtils
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnDefault
import com.onesignal.core.internal.application.OneSignalInternalActivity
import com.onesignal.notifications.internal.open.INotificationOpenedProcessor
Expand All @@ -53,6 +54,9 @@ abstract class NotificationOpenedActivityBase :
}

internal open fun processIntent() {
// Notification-open trampoline runs on the main thread and can cold-start the process;
// warm dispatchers before the first suspendifyOnDefault dispatch.
Comment thread
claude[bot] marked this conversation as resolved.
OneSignalDispatchers.prewarm()
suspendifyOnDefault {
if (!OneSignal.initWithContext(applicationContext)) {
return@suspendifyOnDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Bundle
import com.huawei.hms.push.RemoteMessage
import com.onesignal.OneSignal
import com.onesignal.common.JSONUtils
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnDefault
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.core.internal.time.ITime
Expand Down Expand Up @@ -39,6 +40,8 @@ object OneSignalHmsEventBridge {
) {
if (firstToken.compareAndSet(true, false)) {
Logging.info("OneSignalHmsEventBridge onNewToken - HMS token: $token Bundle: $bundle")
// HMS can cold-start the process before initWithContext; warm dispatchers first.
OneSignalDispatchers.prewarm()
suspendifyOnIO {
val registerer = OneSignal.getService<IPushRegistratorCallback>()
registerer.fireCallback(token)
Expand All @@ -64,6 +67,8 @@ object OneSignalHmsEventBridge {
context: Context,
message: RemoteMessage,
) {
// HMS can cold-start the process before initWithContext; warm dispatchers first.
OneSignalDispatchers.prewarm()
suspendifyOnDefault {
if (!OneSignal.initWithContext(context)) {
return@suspendifyOnDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager
Expand All @@ -39,6 +40,10 @@ class BootUpReceiver : BroadcastReceiver() {
context: Context,
intent: Intent,
) {
// Boot can cold-start the process before initWithContext. Warm dispatchers before
// goAsync() so the daemon has lead time before the first suspendifyOnIO dispatch.
OneSignalDispatchers.prewarm()

val pendingResult = goAsync()
// in background, init onesignal and begin enqueueing restore work
suspendifyOnIO {
Expand Down
Loading
Loading