From c51092c47ab0b032cba49e1a3cb099394b2c4ad7 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 23 Jun 2026 20:45:04 +0530 Subject: [PATCH 1/5] fix: [SDK-4794] prewarm dispatchers at cold-start entry points Production OTel showed the first launchOnIO/launchOnSerialIO on a cold start constructing the ThreadPoolExecutor + dispatcher + scope synchronously on the main thread (Activity trampoline, JobService.onStartJob, FCM goAsync), blocking it for seconds (SDK-4507 family). prewarm() shifts that construction onto the low-priority OneSignal-prewarm daemon. Call prewarm() at the cold-start entry points so the daemon gets a head start before the first dispatch: - receivers: FCMBroadcastReceiver, NotificationDismissReceiver, BootUpReceiver, UpgradeReceiver (before goAsync()) - ADM: ADMMessageHandler, ADMMessageHandlerJob - HMS: OneSignalHmsEventBridge.onNewToken / onMessageReceived - notification-open trampolines: NotificationOpenedActivityBase, NotificationOpenedActivityHMS These join the existing prewarm() calls in OneSignalImp.initWithContext* and SyncJobService.onStartJob. prewarm() is idempotent and fire-and-forget, so the calls are cheap. It is a best-effort head start, not a hard guarantee: a caller that dispatches immediately can still win the lazy-init race, so prewarm is placed where there is real lead time before the first dispatch. - reword prewarm() KDoc as a best-effort head start with the entry-point table - stub OneSignalDispatchers.prewarm() in IOMockHelper so mockkObject specs don't spawn the daemon - add PrewarmEntryPointTests, SyncJobServiceTests and FCMBroadcastReceiverTests coverage for the explicit prewarm calls Co-authored-by: Cursor --- .../common/threading/OneSignalDispatchers.kt | 17 +++++ .../core/services/SyncJobServiceTests.kt | 11 ++++ .../NotificationOpenedActivityHMS.kt | 4 ++ .../NotificationOpenedActivityBase.kt | 4 ++ .../bridges/OneSignalHmsEventBridge.kt | 5 ++ .../notifications/receivers/BootUpReceiver.kt | 5 ++ .../receivers/FCMBroadcastReceiver.kt | 6 ++ .../receivers/NotificationDismissReceiver.kt | 5 ++ .../receivers/UpgradeReceiver.kt | 5 ++ .../services/ADMMessageHandler.kt | 5 ++ .../services/ADMMessageHandlerJob.kt | 5 ++ .../receivers/FCMBroadcastReceiverTests.kt | 62 +++++++++++++++++++ .../receivers/PrewarmEntryPointTests.kt | 61 ++++++++++++++++++ .../java/com/onesignal/mocks/IOMockHelper.kt | 6 ++ 14 files changed, 201 insertions(+) create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 76a6abcd1e..610b65ad30 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -199,6 +199,23 @@ 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. 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. */ fun prewarm() { if (prewarmStarted) return diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt index 8a09f9b847..cb600fc4ff 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt @@ -2,6 +2,7 @@ package com.onesignal.core.services import android.app.job.JobParameters import com.onesignal.OneSignal +import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.background.IBackgroundManager import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging @@ -40,6 +41,16 @@ class SyncJobServiceTests : FunSpec({ unmockkAll() } + test("onStartJob calls prewarm before suspendifyOnIO (SDK-4794)") { + mockkObject(OneSignalDispatchers) + every { OneSignalDispatchers.prewarm() } returns Unit + coEvery { OneSignal.initWithContext(any()) } returns false + + mocks.syncJobService.onStartJob(mocks.jobParameters) + + verify(exactly = 1) { OneSignalDispatchers.prewarm() } + } + test("onStartJob returns true when initWithContext fails") { // Given val syncJobService = mocks.syncJobService diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt index 65f14e35ca..a84ba8fe81 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt @@ -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 @@ -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 diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt index 393367c038..1080e064d8 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt @@ -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 @@ -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. + OneSignalDispatchers.prewarm() suspendifyOnDefault { if (!OneSignal.initWithContext(applicationContext)) { return@suspendifyOnDefault diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt index 45f3a36d5e..4b6c64833b 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt @@ -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 @@ -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() registerer.fireCallback(token) @@ -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 diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt index 22a601d172..3e2b77dd6d 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/BootUpReceiver.kt @@ -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 @@ -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 { diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt index c117dac793..a47cdf3df3 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/FCMBroadcastReceiver.kt @@ -5,6 +5,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.bundle.INotificationBundleProcessor @@ -25,6 +26,11 @@ class FCMBroadcastReceiver : BroadcastReceiver() { return } + // FCM can cold-start the process before initWithContext. Warm dispatchers before goAsync() + // so the prewarm daemon gets a head start during the handoff, making the dispatchers more + // likely to be warm by the time the suspendifyOnIO below submits its work. + OneSignalDispatchers.prewarm() + val pendingResult = goAsync() // process in background suspendifyOnIO { diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt index c16720874e..039e5695cb 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/NotificationDismissReceiver.kt @@ -28,6 +28,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.open.INotificationOpenedProcessor @@ -39,6 +40,10 @@ class NotificationDismissReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { + // A dismiss 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() suspendifyOnIO { diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt index 51572a658b..35018f6954 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/receivers/UpgradeReceiver.kt @@ -31,6 +31,7 @@ import android.content.Context import android.content.Intent import android.os.Build 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 @@ -48,6 +49,10 @@ class UpgradeReceiver : BroadcastReceiver() { return } + // App upgrade 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() // init OneSignal and enqueue restore work in background diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt index 7bfbf8e4c6..71039eb318 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt @@ -3,6 +3,7 @@ package com.onesignal.notifications.services import android.content.Intent import com.amazon.device.messaging.ADMMessageHandlerBase 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.bundle.INotificationBundleProcessor @@ -15,6 +16,8 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { val context = applicationContext val bundle = intent.extras ?: return + // ADM can cold-start the process before initWithContext; warm dispatchers first. + OneSignalDispatchers.prewarm() suspendifyOnIO { if (!OneSignal.initWithContext(context)) { Logging.warn("onMessage skipped due to failed OneSignal init") @@ -29,6 +32,7 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { override fun onRegistered(newRegistrationId: String) { Logging.info("ADM registration ID: $newRegistrationId") + OneSignalDispatchers.prewarm() suspendifyOnIO { val registerer = OneSignal.getService() registerer.fireCallback(newRegistrationId) @@ -44,6 +48,7 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { ) } + OneSignalDispatchers.prewarm() suspendifyOnIO { val registerer = OneSignal.getService() registerer.fireCallback(null) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt index 919007749f..5b94d82bea 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import com.amazon.device.messaging.ADMMessageHandlerJobBase 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.bundle.INotificationBundleProcessor @@ -22,6 +23,8 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { val safeContext = context.applicationContext + // ADM can cold-start the process before initWithContext; warm dispatchers first. + OneSignalDispatchers.prewarm() suspendifyOnIO { if (!OneSignal.initWithContext(safeContext)) { Logging.warn("onMessage skipped due to failed OneSignal init") @@ -39,6 +42,7 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { ) { Logging.info("ADM registration ID: $newRegistrationId") + OneSignalDispatchers.prewarm() suspendifyOnIO { val registerer = OneSignal.getService() registerer.fireCallback(newRegistrationId) @@ -63,6 +67,7 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { ) } + OneSignalDispatchers.prewarm() suspendifyOnIO { val registerer = OneSignal.getService() registerer.fireCallback(null) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt new file mode 100644 index 0000000000..6a0c92e5ab --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt @@ -0,0 +1,62 @@ +package com.onesignal.notifications.receivers + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.OneSignal +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.mocks.IOMockHelper +import io.kotest.core.spec.style.FunSpec +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify + +@RobolectricTest +class FCMBroadcastReceiverTests : FunSpec({ + listener(IOMockHelper) + + beforeAny { + mockkObject(OneSignalDispatchers) + every { OneSignalDispatchers.prewarm() } just runs + mockkObject(OneSignal) + coEvery { OneSignal.initWithContext(any()) } returns false + } + + afterAny { + unmockkAll() + } + + test("FCMBroadcastReceiver.onReceive makes the explicit prewarm() head-start call for a normal push") { + // Scope of this test: it asserts the explicit `OneSignalDispatchers.prewarm()` call in + // onReceive (the goAsync() head start). IOMockHelper stubs `suspendifyOnIO` and prewarm() + // is mocked, so the exactly=1 count is the receiver's own call — this verifies placement, + // not end-to-end cold-init behavior. + val context = ApplicationProvider.getApplicationContext() + val intent = + Intent("com.google.android.c2dm.intent.RECEIVE").apply { + putExtra("from", "sender") + putExtra("message_type", "gcm") + } + + FCMBroadcastReceiver().onReceive(context, intent) + + verify(exactly = 1) { OneSignalDispatchers.prewarm() } + } + + test("FCMBroadcastReceiver.onReceive skips prewarm for token update intents") { + val context = ApplicationProvider.getApplicationContext() + val intent = + Intent("com.google.android.c2dm.intent.RECEIVE").apply { + putExtra("from", "google.com/iid") + } + + FCMBroadcastReceiver().onReceive(context, intent) + + verify(exactly = 0) { OneSignalDispatchers.prewarm() } + } +}) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt new file mode 100644 index 0000000000..449922daaf --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt @@ -0,0 +1,61 @@ +package com.onesignal.notifications.receivers + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.OneSignal +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.mocks.IOMockHelper +import io.kotest.core.spec.style.FunSpec +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify + +/** + * Verifies each cold-start broadcast-receiver entry point makes the explicit + * [OneSignalDispatchers.prewarm] head-start call before its first dispatch (SDK-4794 whack-a-mole + * strategy). [IOMockHelper] stubs `suspendifyOnIO` and `prewarm()` is mocked, so these assert + * placement of the explicit call, not end-to-end cold-init behavior. + * + * Not covered here (no unit-test path): `ADMMessageHandler` / `ADMMessageHandlerJob` (the + * `com.amazon.device.messaging.*` base classes are not on the test classpath) and + * `OneSignalHmsEventBridge` (`com.huawei.hms.push.*`). Those calls are verified by inspection. + */ +@RobolectricTest +class PrewarmEntryPointTests : FunSpec({ + listener(IOMockHelper) + + beforeAny { + mockkObject(OneSignalDispatchers) + every { OneSignalDispatchers.prewarm() } just runs + mockkObject(OneSignal) + coEvery { OneSignal.initWithContext(any()) } returns false + } + + afterAny { + unmockkAll() + } + + test("BootUpReceiver.onReceive prewarms before dispatch") { + val context = ApplicationProvider.getApplicationContext() + BootUpReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) + verify(exactly = 1) { OneSignalDispatchers.prewarm() } + } + + test("UpgradeReceiver.onReceive prewarms before dispatch") { + val context = ApplicationProvider.getApplicationContext() + UpgradeReceiver().onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED)) + verify(exactly = 1) { OneSignalDispatchers.prewarm() } + } + + test("NotificationDismissReceiver.onReceive prewarms before dispatch") { + val context = ApplicationProvider.getApplicationContext() + NotificationDismissReceiver().onReceive(context, Intent()) + verify(exactly = 1) { OneSignalDispatchers.prewarm() } + } +}) diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt index adff0219df..77fd5c07a0 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -85,6 +85,12 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, // OneSignalDispatchers = object that contains launchOnIO and launchOnDefault mockkObject(OneSignalDispatchers) + // ThreadUtils' FF-gated helpers (and several entry points) now call prewarm() eagerly. + // We mock launch* below to run blocks inline, so the real prewarm()'s background daemon + // is pure overhead in tests — stub it to a no-op so specs don't spawn OneSignal-prewarm + // threads on every suspendify*/launchOn* call. + every { OneSignalDispatchers.prewarm() } returns Unit + // Helper function to track async work (suspendifyOnIO, launchOnIO, launchOnDefault) // Note: We use runBlocking with Dispatchers.Unconfined to execute synchronously and deterministically // instead of suspendifyWithCompletion to avoid circular dependency From 26dfd5d040a470aa992e8c4c52a0d480774ff99f Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 23 Jun 2026 23:51:40 +0530 Subject: [PATCH 2/5] fix: address prewarm review feedback (ordering, mock teardown, startup guard) Addresses fadi-george's pre-merge review on the prewarm cold-start PR: - prewarm(): wrap Thread construction, priority assignment, and start() in a try/catch (Throwable) so a daemon-startup failure (e.g. OOM "unable to create new native thread", SecurityManager, InternalError) can never propagate onto a cold-start entry point's caller thread. Reset the started guard on failure so a later entry point can retry; dispatchers keep their lazy fallbacks. Also widen the in-thread catch to Throwable. - Tests now assert ordering, not just that prewarm() was called: prewarm() must run before the first suspendifyOnIO dispatch (verifyOrder) in PrewarmEntryPointTests, FCMBroadcastReceiverTests, and SyncJobServiceTests. - Replace afterAny { unmockkAll() } with teardown of only the spec-owned mock (unmockkObject(OneSignal)). unmockkAll() was stripping IOMockHelper's spec-scoped static/object mocks after the first test. Per-test prewarm() call counts are isolated via clearMocks(OneSignalDispatchers, answers = false). - Remove internal ticket IDs from source/test comments and test names. - Reword the stale IOMockHelper comment that claimed ThreadUtils helpers call prewarm() (the wide approach was rejected; only entry points call it). Co-authored-by: Cursor --- .../common/threading/OneSignalDispatchers.kt | 50 ++++++++++++------- .../core/services/SyncJobServiceTests.kt | 24 +++++++-- .../receivers/FCMBroadcastReceiverTests.kt | 31 ++++++++---- .../receivers/PrewarmEntryPointTests.kt | 37 ++++++++++---- .../java/com/onesignal/mocks/IOMockHelper.kt | 8 +-- 5 files changed, 101 insertions(+), 49 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 610b65ad30..863acd8ddc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -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 @@ -223,24 +223,36 @@ object OneSignalDispatchers { 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) { + Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}") + } + }, + "$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}") + } } /** diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt index cb600fc4ff..7b603a199f 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt @@ -3,6 +3,7 @@ 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 @@ -10,14 +11,16 @@ 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) @@ -35,20 +38,31 @@ class SyncJobServiceTests : FunSpec({ mocks = Mocks() // fresh instance for each test mockkObject(OneSignal) every { OneSignal.getService() } 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 (SDK-4794)") { - mockkObject(OneSignalDispatchers) - every { OneSignalDispatchers.prewarm() } returns Unit + 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 Unit>()) + } } test("onStartJob returns true when initWithContext fails") { diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt index 6a0c92e5ab..ff0416ce76 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/FCMBroadcastReceiverTests.kt @@ -6,36 +6,41 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.OneSignal import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.mocks.IOMockHelper import io.kotest.core.spec.style.FunSpec +import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.every -import io.mockk.just import io.mockk.mockkObject -import io.mockk.runs -import io.mockk.unmockkAll +import io.mockk.unmockkObject import io.mockk.verify +import io.mockk.verifyOrder @RobolectricTest class FCMBroadcastReceiverTests : FunSpec({ listener(IOMockHelper) beforeAny { - mockkObject(OneSignalDispatchers) - every { OneSignalDispatchers.prewarm() } just runs + // IOMockHelper owns the OneSignalDispatchers object mock (incl. the prewarm() stub) for the + // whole spec. Clear only its recorded calls so each test's prewarm() count starts at zero, + // while keeping IOMockHelper's stubbed answers (answers = false). + clearMocks(OneSignalDispatchers, answers = false) mockkObject(OneSignal) coEvery { OneSignal.initWithContext(any()) } returns 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 mid-spec and break the remaining tests. + unmockkObject(OneSignal) } - test("FCMBroadcastReceiver.onReceive makes the explicit prewarm() head-start call for a normal push") { + test("FCMBroadcastReceiver.onReceive makes the explicit prewarm() head-start call before dispatch for a normal push") { // Scope of this test: it asserts the explicit `OneSignalDispatchers.prewarm()` call in - // onReceive (the goAsync() head start). IOMockHelper stubs `suspendifyOnIO` and prewarm() - // is mocked, so the exactly=1 count is the receiver's own call — this verifies placement, - // not end-to-end cold-init behavior. + // onReceive (the goAsync() head start) happens before the suspendifyOnIO dispatch. + // IOMockHelper stubs `suspendifyOnIO` (run inline) and prewarm(), so this verifies + // placement/ordering, not end-to-end cold-init behavior. val context = ApplicationProvider.getApplicationContext() val intent = Intent("com.google.android.c2dm.intent.RECEIVE").apply { @@ -46,6 +51,10 @@ class FCMBroadcastReceiverTests : FunSpec({ FCMBroadcastReceiver().onReceive(context, intent) verify(exactly = 1) { OneSignalDispatchers.prewarm() } + verifyOrder { + OneSignalDispatchers.prewarm() + suspendifyOnIO(any Unit>()) + } } test("FCMBroadcastReceiver.onReceive skips prewarm for token update intents") { diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt index 449922daaf..99cdcecef2 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/receivers/PrewarmEntryPointTests.kt @@ -6,21 +6,21 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.OneSignal import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.mocks.IOMockHelper import io.kotest.core.spec.style.FunSpec +import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.every -import io.mockk.just import io.mockk.mockkObject -import io.mockk.runs -import io.mockk.unmockkAll +import io.mockk.unmockkObject import io.mockk.verify +import io.mockk.verifyOrder /** * Verifies each cold-start broadcast-receiver entry point makes the explicit - * [OneSignalDispatchers.prewarm] head-start call before its first dispatch (SDK-4794 whack-a-mole - * strategy). [IOMockHelper] stubs `suspendifyOnIO` and `prewarm()` is mocked, so these assert - * placement of the explicit call, not end-to-end cold-init behavior. + * [OneSignalDispatchers.prewarm] head-start call before its first dispatch. [IOMockHelper] stubs + * `suspendifyOnIO` (run inline) and `prewarm()`, so these assert placement/ordering of the explicit + * call, not end-to-end cold-init behavior. * * Not covered here (no unit-test path): `ADMMessageHandler` / `ADMMessageHandlerJob` (the * `com.amazon.device.messaging.*` base classes are not on the test classpath) and @@ -31,31 +31,48 @@ class PrewarmEntryPointTests : FunSpec({ listener(IOMockHelper) beforeAny { - mockkObject(OneSignalDispatchers) - every { OneSignalDispatchers.prewarm() } just runs + // IOMockHelper owns the OneSignalDispatchers object mock (incl. the prewarm() stub) for the + // whole spec. Clear only its recorded calls here so each test's prewarm() count starts at + // zero, while keeping IOMockHelper's stubbed answers (answers = false). + clearMocks(OneSignalDispatchers, answers = false) mockkObject(OneSignal) coEvery { OneSignal.initWithContext(any()) } returns 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 mid-spec and break the remaining tests. + unmockkObject(OneSignal) } test("BootUpReceiver.onReceive prewarms before dispatch") { val context = ApplicationProvider.getApplicationContext() BootUpReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) verify(exactly = 1) { OneSignalDispatchers.prewarm() } + verifyOrder { + OneSignalDispatchers.prewarm() + suspendifyOnIO(any Unit>()) + } } test("UpgradeReceiver.onReceive prewarms before dispatch") { val context = ApplicationProvider.getApplicationContext() UpgradeReceiver().onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED)) verify(exactly = 1) { OneSignalDispatchers.prewarm() } + verifyOrder { + OneSignalDispatchers.prewarm() + suspendifyOnIO(any Unit>()) + } } test("NotificationDismissReceiver.onReceive prewarms before dispatch") { val context = ApplicationProvider.getApplicationContext() NotificationDismissReceiver().onReceive(context, Intent()) verify(exactly = 1) { OneSignalDispatchers.prewarm() } + verifyOrder { + OneSignalDispatchers.prewarm() + suspendifyOnIO(any Unit>()) + } } }) diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt index 77fd5c07a0..b78d517cf4 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -85,10 +85,10 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, // OneSignalDispatchers = object that contains launchOnIO and launchOnDefault mockkObject(OneSignalDispatchers) - // ThreadUtils' FF-gated helpers (and several entry points) now call prewarm() eagerly. - // We mock launch* below to run blocks inline, so the real prewarm()'s background daemon - // is pure overhead in tests — stub it to a no-op so specs don't spawn OneSignal-prewarm - // threads on every suspendify*/launchOn* call. + // Cold-start entry points (receivers, activities, push bridges, JobService) call prewarm() + // before dispatching. We mock launch*/suspendify* below to run blocks inline, so the real + // prewarm()'s background daemon is pure overhead in tests — stub it to a no-op so specs + // don't spawn OneSignal-prewarm threads through that entry-point code. every { OneSignalDispatchers.prewarm() } returns Unit // Helper function to track async work (suspendifyOnIO, launchOnIO, launchOnDefault) From 5ab4d8392b9ee11d07147b2d08b9011a600377e0 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 24 Jun 2026 00:14:59 +0530 Subject: [PATCH 3/5] test: stub prewarm() in NotificationOpenedActivityTest via IOMockHelper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processIntent() now calls OneSignalDispatchers.prewarm() at this entry point. The Robolectric spec drives setup() on the test thread without IOMockHelper, so the real prewarm() ran — spawning the OneSignal-prewarm daemon and lazy-initing the IO/Default/SerialIO executors + scopes, which persist across the JVM for later test classes. Register listener(IOMockHelper) so prewarm() is stubbed to a no-op, matching the pattern already used in FCMBroadcastReceiverTests and PrewarmEntryPointTests. Co-authored-by: Cursor --- .../activities/NotificationOpenedActivityBaseTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt index 6e162131e5..805ec0d4f6 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.mocks.IOMockHelper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.robolectric.Robolectric @@ -38,6 +39,10 @@ class TestNotificationOpenedActivity : NotificationOpenedActivityBase() { @RobolectricTest class NotificationOpenedActivityTest : FunSpec({ + // processIntent() now calls OneSignalDispatchers.prewarm(); IOMockHelper stubs it to a no-op so + // setup() doesn't spawn the real OneSignal-prewarm daemon + executors that persist across the JVM. + listener(IOMockHelper) + test("finishSafely should be called on main thread") { val controller = Robolectric.buildActivity(TestNotificationOpenedActivity::class.java) val activity = controller.setup().get() From 78c58c5f3be8c851fac70d745f77de554a96fb42 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 24 Jun 2026 00:56:08 +0530 Subject: [PATCH 4/5] fix: resolve detekt violations in core module Address the 12 weighted issues failing :OneSignal:core:detekt: - Add KDoc to IFeatureManager/isEnabled, FeatureFlag.isEnabledIn, and the public JwtTokenStore listener/getter functions (UndocumentedPublicClass/Function). - Extract notInitializedMessage() and awaitInitCompletion() from waitUntilInitInternal() to get under the LongMethod threshold. - Suppress TooGenericExceptionCaught on prewarm(), where catching Throwable is intentional so a prewarm failure never propagates onto a cold-start caller. Co-authored-by: Cursor --- .../common/threading/OneSignalDispatchers.kt | 1 + .../core/internal/features/FeatureFlag.kt | 4 + .../core/internal/features/FeatureManager.kt | 9 ++ .../com/onesignal/internal/OneSignalImp.kt | 84 ++++++++++--------- .../user/internal/jwt/JwtTokenStore.kt | 5 ++ 5 files changed, 63 insertions(+), 40 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 863acd8ddc..d7435d9248 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -217,6 +217,7 @@ object OneSignalDispatchers { * 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. */ + @Suppress("TooGenericExceptionCaught") fun prewarm() { if (prewarmStarted) return synchronized(prewarmLock) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt index c7fb69804b..75355f64b1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -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): Boolean { return enabledKeys.contains(key) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index 41f54c68a8..b026735ec0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -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 /** 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 aaaaa04350..05b2cb3780 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 @@ -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.") @@ -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, + 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 suspendAndReturn(getter: () -> T): T { suspendUntilInit() return getter() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt index 6e53c4a045..44d8841f06 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt @@ -30,16 +30,21 @@ class JwtTokenStore( private val internalUpdateListeners = EventProducer() private val publicInvalidatedListeners = EventProducer() + /** 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() From 239cd160c6eb773ada0cc9c6e6ec8a3a50ee6deb Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 23 Jun 2026 13:01:33 -0700 Subject: [PATCH 5/5] fix: address prewarm review follow-ups Co-authored-by: Cursor --- .../common/threading/OneSignalDispatchers.kt | 14 +++++++++++--- .../NotificationOpenedActivityBaseTest.kt | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index d7435d9248..3c4c97765d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -204,7 +204,14 @@ object OneSignalDispatchers { * 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. The known main-thread cold-start entry points: + * 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` | @@ -235,7 +242,8 @@ object OneSignalDispatchers { launchOnDefault { /* warm DefaultScope + defaultExecutor */ } launchOnSerialIO { /* warm SerialIOScope + serialIOExecutor */ } } catch (e: Throwable) { - Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}") + synchronized(prewarmLock) { prewarmStarted = false } + Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}", e) } }, "$BASE_THREAD_NAME-prewarm", @@ -252,7 +260,7 @@ object OneSignalDispatchers { // 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}") + Logging.warn("OneSignalDispatchers.prewarm failed to start daemon: ${t.message}", t) } } diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt index 805ec0d4f6..d7032f78b3 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt @@ -5,9 +5,14 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.suspendifyOnDefault import com.onesignal.mocks.IOMockHelper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.verify +import io.mockk.verifyOrder import org.robolectric.Robolectric import org.robolectric.shadows.ShadowLooper @@ -43,6 +48,20 @@ class NotificationOpenedActivityTest : FunSpec({ // setup() doesn't spawn the real OneSignal-prewarm daemon + executors that persist across the JVM. listener(IOMockHelper) + beforeAny { + clearMocks(OneSignalDispatchers, answers = false) + } + + test("processIntent calls prewarm before suspendifyOnDefault") { + Robolectric.buildActivity(TestNotificationOpenedActivity::class.java).setup() + + verify(atLeast = 1) { OneSignalDispatchers.prewarm() } + verifyOrder { + OneSignalDispatchers.prewarm() + suspendifyOnDefault(any Unit>()) + } + } + test("finishSafely should be called on main thread") { val controller = Robolectric.buildActivity(TestNotificationOpenedActivity::class.java) val activity = controller.setup().get()