From 808d1cc49e9033a9040a31b1db9f166dac496db2 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Thu, 4 Sep 2025 10:27:58 -0400 Subject: [PATCH] refactor: move initialization process off main thread --- .../src/main/java/com/onesignal/IOneSignal.kt | 9 +- .../src/main/java/com/onesignal/OneSignal.kt | 4 +- .../common/threading/LatchAwaiter.kt | 82 ++++ .../core/activities/PermissionsActivity.kt | 27 +- .../onesignal/core/services/SyncJobService.kt | 14 +- .../java/com/onesignal/internal/InitState.kt | 39 ++ .../com/onesignal/internal/OneSignalImp.kt | 436 ++++++++++-------- .../common/threading/LatchAwaiterTests.kt | 88 ++++ .../core/internal/application/SDKInitTests.kt | 281 +++++++++++ .../internal/operations/OperationRepoTests.kt | 4 +- .../NotificationOpenedActivityHMS.kt | 12 +- .../NotificationOpenedActivityBase.kt | 36 +- .../bridges/OneSignalHmsEventBridge.kt | 62 +-- .../impl/NotificationGenerationWorkManager.kt | 14 +- .../impl/NotificationLifecycleService.kt | 6 +- .../impl/ReceiveReceiptWorkManager.kt | 7 +- .../impl/NotificationRestoreWorkManager.kt | 7 +- .../notifications/receivers/BootUpReceiver.kt | 20 +- .../receivers/FCMBroadcastReceiver.kt | 41 +- .../receivers/NotificationDismissReceiver.kt | 25 +- .../receivers/UpgradeReceiver.kt | 26 +- .../services/ADMMessageHandler.kt | 16 +- .../services/ADMMessageHandlerJob.kt | 20 +- 23 files changed, 956 insertions(+), 320 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LatchAwaiter.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/LatchAwaiterTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt index cb707e4fe4..71d495e9e1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -85,9 +85,16 @@ interface IOneSignal { */ fun initWithContext( context: Context, - appId: String?, + appId: String, ): Boolean + /** + * Initialize the OneSignal SDK, suspend until initialization is completed + * + * @param context The Android context the SDK should use. + */ + suspend fun initWithContext(context: Context): Boolean + /** * Login to OneSignal under the user identified by the [externalId] provided. The act of * logging a user into the OneSignal SDK will switch the [user] context to that specific user. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt index 580cd63252..422859afc3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -204,8 +204,8 @@ object OneSignal { * THIS IS AN INTERNAL INTERFACE AND SHOULD NOT BE USED DIRECTLY. */ @JvmStatic - fun initWithContext(context: Context): Boolean { - return oneSignal.initWithContext(context, null) + suspend fun initWithContext(context: Context): Boolean { + return oneSignal.initWithContext(context) } /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LatchAwaiter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LatchAwaiter.kt new file mode 100644 index 0000000000..195a6372c7 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LatchAwaiter.kt @@ -0,0 +1,82 @@ +package com.onesignal.common.threading + +import com.onesignal.common.AndroidUtils +import com.onesignal.debug.internal.logging.Logging +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * This class allows blocking execution until asynchronous initialization or completion is signaled, with support for configurable timeouts and detailed logging for troubleshooting. + * It is designed for scenarios where certain tasks, such as SDK initialization, must finish before continuing. + * When used on the main/UI thread, it applies a shorter timeout and logs a thread stack trace to warn developers, helping to prevent Application Not Responding (ANR) errors caused by blocking the UI thread. + * + * Usage: + * val awaiter = LatchAwaiter("OneSignal SDK Init") + * awaiter.release() // when done + */ +class LatchAwaiter( + private val componentName: String = "Component", +) { + companion object { + const val DEFAULT_TIMEOUT_MS = 30_000L // 30 seconds + const val ANDROID_ANR_TIMEOUT_MS = 4_800L // Conservative ANR threshold + } + + private val latch = CountDownLatch(1) + + /** + * Releases the latch to unblock any waiting threads. + */ + fun release() { + latch.countDown() + } + + /** + * Wait for the latch to be released with an optional timeout. + * + * @return true if latch was released before timeout, false otherwise. + */ + fun await(timeoutMs: Long = getDefaultTimeout()): Boolean { + val completed = + try { + latch.await(timeoutMs, TimeUnit.MILLISECONDS) + } catch (e: InterruptedException) { + Logging.warn("Interrupted while waiting for $componentName", e) + logAllThreads() + false + } + + if (!completed) { + val message = createTimeoutMessage(timeoutMs) + Logging.warn(message) + } + + return completed + } + + private fun getDefaultTimeout(): Long { + return if (AndroidUtils.isRunningOnMainThread()) ANDROID_ANR_TIMEOUT_MS else DEFAULT_TIMEOUT_MS + } + + private fun createTimeoutMessage(timeoutMs: Long): String { + return if (AndroidUtils.isRunningOnMainThread()) { + "Timeout waiting for $componentName after ${timeoutMs}ms on the main thread. " + + "This can cause ANRs. Consider calling from a background thread." + } else { + "Timeout waiting for $componentName after ${timeoutMs}ms." + } + } + + private fun logAllThreads(): String { + val allThreads = Thread.getAllStackTraces() + val sb = StringBuilder() + for ((thread, stack) in allThreads) { + sb.append("ThreadDump Thread: ${thread.name} [${thread.state}]\n") + for (element in stack) { + sb.append("\tat $element\n") + } + } + + return sb.toString() + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt index 212d892546..f545d4b01d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/activities/PermissionsActivity.kt @@ -8,11 +8,14 @@ import android.os.Bundle import android.os.Handler import androidx.core.app.ActivityCompat import com.onesignal.OneSignal +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.R import com.onesignal.core.internal.permissions.impl.RequestPermissionService import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class PermissionsActivity : Activity() { private var requestPermissionService: RequestPermissionService? = null @@ -22,21 +25,29 @@ class PermissionsActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!OneSignal.initWithContext(this)) { - finishActivity() - return - } - if (intent.extras == null) { // This should never happen, but extras is null in rare crash reports finishActivity() return } - requestPermissionService = OneSignal.getService() - preferenceService = OneSignal.getService() + // init in background + suspendifyOnThread { + val initialized = OneSignal.initWithContext(this) - handleBundleParams(intent.extras) + // finishActivity() and handleBundleParams must be called from main + withContext(Dispatchers.Main) { + if (!initialized) { + finishActivity() + return@withContext + } + + requestPermissionService = OneSignal.getService() + preferenceService = OneSignal.getService() + + handleBundleParams(intent.extras) + } + } } override fun onNewIntent(intent: Intent) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt index ef98e5388c..8c52bca025 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt @@ -35,13 +35,14 @@ import com.onesignal.debug.internal.logging.Logging class SyncJobService : JobService() { override fun onStartJob(jobParameters: JobParameters): Boolean { - if (!OneSignal.initWithContext(this)) { - return false - } - - var backgroundService = OneSignal.getService() - suspendifyOnThread { + // init OneSignal in background + if (!OneSignal.initWithContext(this)) { + jobFinished(jobParameters, false) + return@suspendifyOnThread + } + + val backgroundService = OneSignal.getService() backgroundService.runBackgroundServices() Logging.debug("LollipopSyncRunnable:JobFinished needsJobReschedule: " + backgroundService.needsJobReschedule) @@ -49,7 +50,6 @@ class SyncJobService : JobService() { // Reschedule if needed val reschedule = backgroundService.needsJobReschedule backgroundService.needsJobReschedule = false - jobFinished(jobParameters, reschedule) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt new file mode 100644 index 0000000000..4de391be86 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt @@ -0,0 +1,39 @@ +package com.onesignal.internal + +/** + * Represents the current initialization state of the OneSignal SDK. + * + * This enum is used to track the lifecycle of SDK initialization, ensuring that operations like `login`, + * `logout`, or accessing services are only allowed when the SDK is fully initialized. + */ +internal enum class InitState { + /** + * SDK initialization has not yet started. + * Calling SDK-dependent methods in this state will throw an exception. + */ + NOT_STARTED, + + /** + * SDK initialization is currently in progress. + * Calls that require initialization will block (via a latch) until this completes. + */ + IN_PROGRESS, + + /** + * SDK initialization completed successfully. + * All SDK-dependent operations can proceed safely. + */ + SUCCESS, + + /** + * SDK initialization has failed due to an unrecoverable error (e.g., missing app ID). + * All dependent operations should fail fast or throw until re-initialized. + */ + FAILED, + + ; + + fun isSDKAccessible(): Boolean { + return this == IN_PROGRESS || this == SUCCESS + } +} 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 eaa00225e0..828087c24d 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 @@ -14,6 +14,7 @@ import com.onesignal.common.safeString import com.onesignal.common.services.IServiceProvider import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider +import com.onesignal.common.threading.LatchAwaiter import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.CoreModule import com.onesignal.core.internal.application.IApplicationService @@ -53,8 +54,16 @@ import com.onesignal.user.internal.subscriptions.SubscriptionType import org.json.JSONObject internal class OneSignalImp : IOneSignal, IServiceProvider { + @Volatile + private var latchAwaiter = LatchAwaiter("OneSignalImp") + + @Volatile + private var initState: InitState = InitState.NOT_STARTED + override val sdkVersion: String = OneSignalUtils.sdkVersion - override var isInitialized: Boolean = false + + override val isInitialized: Boolean + get() = initState == InitState.SUCCESS override var consentRequired: Boolean get() = configModel?.consentRequired ?: (_consentRequired == true) @@ -83,46 +92,25 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { // we hardcode the DebugManager implementation so it can be used prior to calling `initWithContext` override val debug: IDebugManager = DebugManager() - override val session: ISessionManager get() = - if (isInitialized) { - services.getService() - } else { - throw Exception( - "Must call 'initWithContext' before use", - ) - } - override val notifications: INotificationsManager get() = - if (isInitialized) { - services.getService() - } else { - throw Exception( - "Must call 'initWithContext' before use", - ) - } - override val location: ILocationManager get() = - if (isInitialized) { - services.getService() - } else { - throw Exception( - "Must call 'initWithContext' before use", - ) - } - override val inAppMessages: IInAppMessagesManager get() = - if (isInitialized) { - services.getService() - } else { - throw Exception( - "Must call 'initWithContext' before use", - ) - } - override val user: IUserManager get() = - if (isInitialized) { - services.getService() - } else { - throw Exception( - "Must call 'initWithContext' before use", - ) - } + override val session: ISessionManager + get() = + waitAndReturn { services.getService() } + + override val notifications: INotificationsManager + get() = + waitAndReturn { services.getService() } + + override val location: ILocationManager + get() = + waitAndReturn { services.getService() } + + override val inAppMessages: IInAppMessagesManager + get() = + waitAndReturn { services.getService() } + + override val user: IUserManager + get() = + waitAndReturn { services.getService() } // Services required by this class // WARNING: OperationRepo depends on OperationModelStore which in-turn depends @@ -179,169 +167,218 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { services = serviceBuilder.build() } - override fun initWithContext( - context: Context, - appId: String?, - ): Boolean { - Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") + private fun initEssentials(context: Context) { + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) - synchronized(initLock) { - // do not do this again if already initialized - if (isInitialized) { - Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized") - return true - } + // start the application service. This is called explicitly first because we want + // to make sure it has the context provided on input, for all other startable services + // to depend on if needed. + val applicationService = services.getService() + (applicationService as ApplicationService).start(context) - Logging.log(LogLevel.DEBUG, "initWithContext: SDK initializing") + // Give the logging singleton access to the application service to support visual logging. + Logging.applicationService = applicationService - PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) + // get the current config model, if there is one + configModel = services.getService().model + } - // start the application service. This is called explicitly first because we want - // to make sure it has the context provided on input, for all other startable services - // to depend on if needed. - val applicationService = services.getService() - (applicationService as ApplicationService).start(context) + private fun updateConfig() { + // if requires privacy consent was set prior to init, set it in the model now + if (_consentRequired != null) { + configModel!!.consentRequired = _consentRequired!! + } - // Give the logging singleton access to the application service to support visual logging. - Logging.applicationService = applicationService + // if privacy consent was set prior to init, set it in the model now + if (_consentGiven != null) { + configModel!!.consentGiven = _consentGiven!! + } - // get the current config model, if there is one - configModel = services.getService().model - sessionModel = services.getService().model - operationRepo = services.getService() + if (_disableGMSMissingPrompt != null) { + configModel!!.disableGMSMissingPrompt = _disableGMSMissingPrompt!! + } + } - var forceCreateUser = false + private fun bootstrapServices(): StartupService { + sessionModel = services.getService().model + operationRepo = services.getService() - // initWithContext is called by our internal services/receivers/activities but they do not provide - // an appId (they don't know it). If the app has never called the external initWithContext - // prior to our services/receivers/activities we will blow up, as no appId has been established. - if (appId == null && !configModel!!.hasProperty(ConfigModel::appId.name)) { - val legacyAppId = getLegacyAppId() - if (legacyAppId == null) { - Logging.warn("initWithContext called without providing appId, and no appId has been established!") - return false - } else { - Logging.debug("initWithContext: using cached legacy appId $legacyAppId") - forceCreateUser = true - configModel!!.appId = legacyAppId - } - } + val startupService = StartupService(services) + // bootstrap all services + startupService.bootstrap() + + return startupService + } - // if the app id was specified as input, update the config model with it - if (appId != null) { - if (!configModel!!.hasProperty(ConfigModel::appId.name) || configModel!!.appId != appId) { - forceCreateUser = true + private fun initUser(forceCreateUser: Boolean) { + // create a new local user + if (forceCreateUser || + !identityModelStore!!.model.hasProperty(IdentityConstants.ONESIGNAL_ID) + ) { + val legacyPlayerId = + preferencesService!!.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, + ) + if (legacyPlayerId == null) { + Logging.debug("initWithContext: creating new device-scoped user") + createAndSwitchToNewUser() + operationRepo!!.enqueue( + LoginUserOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + identityModelStore!!.model.externalId, + ), + ) + } else { + Logging.debug("initWithContext: creating user linked to subscription $legacyPlayerId") + + // Converting a 4.x SDK to the 5.x SDK. We pull the legacy user sync values to create the subscription model, then enqueue + // a specialized `LoginUserFromSubscriptionOperation`, which will drive fetching/refreshing of the local user + // based on the subscription ID we do have. + val legacyUserSyncString = + preferencesService!!.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_USER_SYNCVALUES, + ) + var suppressBackendOperation = false + + if (legacyUserSyncString != null) { + val legacyUserSyncJSON = JSONObject(legacyUserSyncString) + val notificationTypes = + legacyUserSyncJSON.safeInt("notification_types") + + val pushSubscriptionModel = SubscriptionModel() + pushSubscriptionModel.id = legacyPlayerId + pushSubscriptionModel.type = SubscriptionType.PUSH + pushSubscriptionModel.optedIn = + notificationTypes != SubscriptionStatus.NO_PERMISSION.value && notificationTypes != SubscriptionStatus.UNSUBSCRIBE.value + pushSubscriptionModel.address = + legacyUserSyncJSON.safeString("identifier") ?: "" + if (notificationTypes != null) { + pushSubscriptionModel.status = + SubscriptionStatus.fromInt(notificationTypes) + ?: SubscriptionStatus.NO_PERMISSION + } else { + pushSubscriptionModel.status = SubscriptionStatus.SUBSCRIBED + } + + pushSubscriptionModel.sdk = OneSignalUtils.sdkVersion + pushSubscriptionModel.deviceOS = Build.VERSION.RELEASE + pushSubscriptionModel.carrier = DeviceUtils.getCarrierName( + services.getService().appContext, + ) ?: "" + pushSubscriptionModel.appVersion = AndroidUtils.getAppVersion( + services.getService().appContext, + ) ?: "" + + configModel!!.pushSubscriptionId = legacyPlayerId + subscriptionModelStore!!.add( + pushSubscriptionModel, + ModelChangeTags.NO_PROPOGATE, + ) + suppressBackendOperation = true } - configModel!!.appId = appId - } - // if requires privacy consent was set prior to init, set it in the model now - if (_consentRequired != null) { - configModel!!.consentRequired = _consentRequired!! - } + createAndSwitchToNewUser(suppressBackendOperation = suppressBackendOperation) - // if privacy consent was set prior to init, set it in the model now - if (_consentGiven != null) { - configModel!!.consentGiven = _consentGiven!! + operationRepo!!.enqueue( + LoginUserFromSubscriptionOperation( + configModel!!.appId, + identityModelStore!!.model.onesignalId, + legacyPlayerId, + ), + ) + preferencesService!!.saveString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, + null, + ) } + } else { + Logging.debug("initWithContext: using cached user ${identityModelStore!!.model.onesignalId}") + } + } - if (_disableGMSMissingPrompt != null) { - configModel!!.disableGMSMissingPrompt = _disableGMSMissingPrompt!! - } + override fun initWithContext( + context: Context, + appId: String, + ): Boolean { + Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") - val startupService = StartupService(services) + // do not do this again if already initialized or init is in progress + synchronized(initLock) { + if (initState.isSDKAccessible()) { + Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") + return true + } - // bootstrap services - startupService.bootstrap() + initState = InitState.IN_PROGRESS + } - if (forceCreateUser || !identityModelStore!!.model.hasProperty(IdentityConstants.ONESIGNAL_ID)) { - val legacyPlayerId = - preferencesService!!.getString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, - ) - if (legacyPlayerId == null) { - Logging.debug("initWithContext: creating new device-scoped user") - createAndSwitchToNewUser() - operationRepo!!.enqueue( - LoginUserOperation( - configModel!!.appId, - identityModelStore!!.model.onesignalId, - identityModelStore!!.model.externalId, - ), - ) - } else { - Logging.debug("initWithContext: creating user linked to subscription $legacyPlayerId") - - // Converting a 4.x SDK to the 5.x SDK. We pull the legacy user sync values to create the subscription model, then enqueue - // a specialized `LoginUserFromSubscriptionOperation`, which will drive fetching/refreshing of the local user - // based on the subscription ID we do have. - val legacyUserSyncString = - preferencesService!!.getString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_USER_SYNCVALUES, - ) - var suppressBackendOperation = false - - if (legacyUserSyncString != null) { - val legacyUserSyncJSON = JSONObject(legacyUserSyncString) - val notificationTypes = legacyUserSyncJSON.safeInt("notification_types") - - val pushSubscriptionModel = SubscriptionModel() - pushSubscriptionModel.id = legacyPlayerId - pushSubscriptionModel.type = SubscriptionType.PUSH - pushSubscriptionModel.optedIn = - notificationTypes != SubscriptionStatus.NO_PERMISSION.value && notificationTypes != SubscriptionStatus.UNSUBSCRIBE.value - pushSubscriptionModel.address = - legacyUserSyncJSON.safeString("identifier") ?: "" - if (notificationTypes != null) { - pushSubscriptionModel.status = SubscriptionStatus.fromInt(notificationTypes) ?: SubscriptionStatus.NO_PERMISSION - } else { - pushSubscriptionModel.status = SubscriptionStatus.SUBSCRIBED - } - - pushSubscriptionModel.sdk = OneSignalUtils.sdkVersion - pushSubscriptionModel.deviceOS = Build.VERSION.RELEASE - pushSubscriptionModel.carrier = DeviceUtils.getCarrierName( - services.getService().appContext, - ) ?: "" - pushSubscriptionModel.appVersion = AndroidUtils.getAppVersion( - services.getService().appContext, - ) ?: "" - - configModel!!.pushSubscriptionId = legacyPlayerId - subscriptionModelStore!!.add( - pushSubscriptionModel, - ModelChangeTags.NO_PROPOGATE, - ) - suppressBackendOperation = true - } + // init in background and return immediately to ensure non-blocking + suspendifyOnThread { + internalInit(context, appId) + } + initState = InitState.SUCCESS + return true + } - createAndSwitchToNewUser(suppressBackendOperation = suppressBackendOperation) + /** + * Called from internal classes only. Remain suspend until initialization is fully completed. + */ + override suspend fun initWithContext(context: Context): Boolean { + Logging.log(LogLevel.DEBUG, "initWithContext(context: $context)") - operationRepo!!.enqueue( - LoginUserFromSubscriptionOperation( - configModel!!.appId, - identityModelStore!!.model.onesignalId, - legacyPlayerId, - ), - ) - preferencesService!!.saveString( - PreferenceStores.ONESIGNAL, - PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, - null, - ) - } - } else { - Logging.debug("initWithContext: using cached user ${identityModelStore!!.model.onesignalId}") + // do not do this again if already initialized or init is in progress + synchronized(initLock) { + if (initState.isSDKAccessible()) { + Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") + return true } - // schedule service starts out of main thread - startupService.scheduleStart() + initState = InitState.IN_PROGRESS + } + + val result = internalInit(context, null) + initState = if (result) InitState.SUCCESS else InitState.FAILED + return result + } - isInitialized = true - return true + private fun internalInit( + context: Context, + appId: String?, + ): Boolean { + initEssentials(context) + + var forceCreateUser = false + if (appId != null) { + // If new appId is different from stored one, flag user recreation + if (!configModel!!.hasProperty(ConfigModel::appId.name) || configModel!!.appId != appId) { + forceCreateUser = true + } + configModel!!.appId = appId + } else { + // appId is null — fallback to legacy + if (!configModel!!.hasProperty(ConfigModel::appId.name)) { + val legacyAppId = getLegacyAppId() + if (legacyAppId == null) { + Logging.warn("suspendInitInternal: no appId provided or found in legacy config.") + initState = InitState.FAILED + latchAwaiter.release() + return false + } + forceCreateUser = true + configModel!!.appId = legacyAppId + } } + + updateConfig() + val startupService = bootstrapServices() + initUser(forceCreateUser) + startupService.scheduleStart() + latchAwaiter.release() + return true } override fun login( @@ -350,10 +387,12 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { ) { Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - if (!isInitialized) { - throw Exception("Must call 'initWithContext' before 'login'") + if (!initState.isSDKAccessible()) { + throw IllegalStateException("Must call 'initWithContext' before 'login'") } + waitForInit() + var currentIdentityExternalId: String? = null var currentIdentityOneSignalId: String? = null var newIdentityOneSignalId: String = "" @@ -402,10 +441,12 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { override fun logout() { Logging.log(LogLevel.DEBUG, "logout()") - if (!isInitialized) { - throw Exception("Must call 'initWithContext' before 'logout'") + if (!initState.isSDKAccessible()) { + throw IllegalStateException("Must call 'initWithContext' before 'logout'") } + waitForInit() + // only allow one login/logout at a time synchronized(loginLock) { if (identityModelStore!!.model.externalId == null) { @@ -504,4 +545,29 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { override fun getServiceOrNull(c: Class): T? = services.getServiceOrNull(c) override fun getAllServices(c: Class): List = services.getAllServices(c) + + private fun waitForInit() { + latchAwaiter.await() + } + + 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() + } + InitState.FAILED -> { + throw IllegalStateException("Initialization failed. Cannot proceed.") + } + else -> { + // SUCCESS + waitForInit() + } + } + + return getter() + } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/LatchAwaiterTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/LatchAwaiterTests.kt new file mode 100644 index 0000000000..90a9050ade --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/LatchAwaiterTests.kt @@ -0,0 +1,88 @@ +package com.onesignal.common.threading + +import com.onesignal.common.AndroidUtils +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.longs.shouldBeLessThan +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockkObject +import kotlinx.coroutines.delay + +class LatchAwaiterTests : FunSpec({ + + lateinit var awaiter: LatchAwaiter + + beforeEach { + Logging.logLevel = LogLevel.NONE + awaiter = LatchAwaiter("TestComponent") + } + + context("successful initialization") { + + test("completes immediately when already successful") { + // Given + awaiter.release() + + // When + val completed = awaiter.await(0) + + // Then + completed shouldBe true + } + } + + context("waiting behavior - holds until completion") { + + test("waits for delayed completion") { + val completionDelay = 300L + val timeoutMs = 2000L + + val startTime = System.currentTimeMillis() + + // Simulate delayed success from another thread + suspendifyOnThread { + delay(completionDelay) + awaiter.release() + } + + val result = awaiter.await(timeoutMs) + val duration = System.currentTimeMillis() - startTime + + result shouldBe true + duration shouldBeGreaterThan (completionDelay - 50) + duration shouldBeLessThan (completionDelay + 150) // buffer + } + } + + context("timeout scenarios") { + + beforeEach { + mockkObject(AndroidUtils) + every { AndroidUtils.isRunningOnMainThread() } returns true + } + + test("await returns false when timeout expires") { + val timeoutMs = 200L + val startTime = System.currentTimeMillis() + + val completed = awaiter.await(timeoutMs) + val duration = System.currentTimeMillis() - startTime + + completed shouldBe false + duration shouldBeGreaterThan (timeoutMs - 50) + duration shouldBeLessThan (timeoutMs + 150) + } + + test("timeout of 0 returns false immediately") { + val startTime = System.currentTimeMillis() + val completed = awaiter.await(0) + val duration = System.currentTimeMillis() - startTime + + completed shouldBe false + duration shouldBeLessThan 20L + } + } +}) 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 new file mode 100644 index 0000000000..8f53336496 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt @@ -0,0 +1,281 @@ +package com.onesignal.core.internal.application + +import android.content.Context +import android.content.ContextWrapper +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.threading.LatchAwaiter +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.internal.OneSignalImp +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.maps.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking + +@RobolectricTest +class SDKInitTests : FunSpec({ + + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + afterAny { + val context = getApplicationContext() + val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + } + + test("OneSignal accessors throw before calling initWithContext") { + val os = OneSignalImp() + + shouldThrow { + os.user + } + shouldThrow { + os.inAppMessages + } + shouldThrow { + os.session + } + shouldThrow { + os.notifications + } + shouldThrow { + os.location + } + } + + test("initWithContext with no appId blocks and will return false") { + // Given + // block SharedPreference before calling init + val trigger = LatchAwaiter("Test") + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val os = OneSignalImp() + var initSuccess = true + + // When + val accessorThread = + Thread { + // this will block until after SharedPreferences is released + runBlocking { + initSuccess = os.initWithContext(blockingPrefContext) + } + } + + accessorThread.start() + accessorThread.join(500) + + accessorThread.isAlive shouldBe true + + // release SharedPreferences + trigger.release() + + accessorThread.join(500) + accessorThread.isAlive shouldBe false + + // always return false because appId is missing + initSuccess shouldBe false + os.isInitialized shouldBe false + } + + test("initWithContext with appId does not block") { + // Given + // block SharedPreference before calling init + val trigger = LatchAwaiter("Test") + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger, 1000) + val os = OneSignalImp() + + // When + val accessorThread = + Thread { + os.initWithContext(blockingPrefContext, "appId") + } + + accessorThread.start() + accessorThread.join(500) + + // Then + // should complete even SharedPreferences is unavailable + accessorThread.isAlive shouldBe false + os.isInitialized shouldBe true + } + + test("accessors will be blocked if call too early after initWithContext with appId") { + // Given + // block SharedPreference before calling init + val trigger = LatchAwaiter("Test") + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val os = OneSignalImp() + + val accessorThread = + Thread { + os.initWithContext(blockingPrefContext, "appId") + os.user // This should block until either trigger is released or timed out + } + + accessorThread.start() + accessorThread.join(500) + + accessorThread.isAlive shouldBe true + + // release the lock on SharedPreferences + trigger.release() + + accessorThread.join(1000) + accessorThread.isAlive shouldBe false + os.isInitialized shouldBe true + } + + test("ensure adding tags right after initWithContext with appId is successful") { + // Given + val context = getApplicationContext() + val os = OneSignalImp() + val tagKey = "tagKey" + val tagValue = "tagValue" + val testTags = mapOf(tagKey to tagValue) + + // When + os.initWithContext(context, "appId") + os.user.addTags(testTags) + + // Then + val tags = os.user.getTags() + tags shouldContain (tagKey to tagValue) + } + + test("ensure login called right after initWithContext can set externalId correctly") { + // Given + // block SharedPreference before calling init + val trigger = LatchAwaiter("Test") + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val os = OneSignalImp() + val externalId = "testUser" + + val accessorThread = + Thread { + os.initWithContext(blockingPrefContext, "appId") + os.login(externalId) + } + + accessorThread.start() + accessorThread.join(500) + + os.isInitialized shouldBe true + accessorThread.isAlive shouldBe true + + // release the lock on SharedPreferences + trigger.release() + + accessorThread.join(500) + accessorThread.isAlive shouldBe false + os.user.externalId shouldBe externalId + } + + test("a push subscription should be created right after initWithContext") { + // Given + val context = getApplicationContext() + val os = OneSignalImp() + os.initWithContext(context, "appId") + + // When + val pushSub = os.user.pushSubscription + + // Then + pushSub shouldNotBe null + pushSub.token shouldNotBe null + } + + test("externalId retrieved correctly when login right after init") { + // Given + val context = getApplicationContext() + val os = OneSignalImp() + val testExternalId = "testUser" + + // When + os.initWithContext(context, "appId") + val oldExternalId = os.user.externalId + os.login(testExternalId) + val newExternalId = os.user.externalId + + oldExternalId shouldBe "" + newExternalId shouldBe testExternalId + } + + test("accessor instances after multiple initWithContext calls are consistent") { + // Given + val context = getApplicationContext() + val os = OneSignalImp() + + // When + os.initWithContext(context, "appId") + val oldUser = os.user + + // Second init from some internal class + os.initWithContext(context) + val newUser = os.user + + // Then + oldUser shouldBe newUser + } + + test("integration: full user workflow after initialization") { + val context = getApplicationContext() + val os = OneSignalImp() + val testExternalId = "test-user" + val tags = mapOf("test" to "integration", "version" to "1.0") + + os.initWithContext(context, "appId") + + // Test user workflow + // init + val initialExternalId = os.user.externalId + initialExternalId shouldBe "" + + // login + os.login(testExternalId) + os.user.externalId shouldBe testExternalId + + // addTags and getTags + os.user.addTags(tags) + val retrievedTags = os.user.getTags() + retrievedTags shouldContain ("test" to "integration") + retrievedTags shouldContain ("version" to "1.0") + + // logout + os.logout() + os.user.externalId shouldBe "" + } +}) + +/** + * Simulate a context awaiting for a shared preference until the trigger is signaled + */ +class BlockingPrefsContext( + context: Context, + private val unblockTrigger: LatchAwaiter, + private val timeoutInMillis: Long, +) : ContextWrapper(context) { + override fun getSharedPreferences( + name: String, + mode: Int, + ): SharedPreferences { + try { + unblockTrigger.await(timeoutInMillis) + } catch (e: InterruptedException) { + throw e + } catch (e: TimeoutCancellationException) { + throw e + } + + return super.getSharedPreferences(name, mode) + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 4c5d5dc9ea..e78a214414 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -542,14 +542,14 @@ class OperationRepoTests : FunSpec({ test("starting OperationModelStore should be processed, following normal delay rules") { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoExecutionInterval = 100 + mocks.configModelStore.model.opRepoExecutionInterval = 200 every { mocks.operationModelStore.list() } returns listOf(mockOperation()) val executeOperationsCall = mockExecuteOperations(mocks.operationRepo) // When mocks.operationRepo.start() val immediateResult = - withTimeoutOrNull(100) { + withTimeoutOrNull(200) { executeOperationsCall.waitForWake() } val delayedResult = 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 22fb65b536..c1385d6a1f 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/NotificationOpenedActivityHMS.kt @@ -72,13 +72,13 @@ class NotificationOpenedActivityHMS : Activity() { } private fun processOpen(intent: Intent?) { - if (!OneSignal.initWithContext(applicationContext)) { - return - } - - var notificationPayloadProcessorHMS = OneSignal.getService() - val self = this suspendifyBlocking { + if (!OneSignal.initWithContext(applicationContext)) { + return@suspendifyBlocking + } + + val notificationPayloadProcessorHMS = OneSignal.getService() + val self = this notificationPayloadProcessorHMS.handleHMSNotificationOpenIntent(self, intent) } } 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 b083e6107e..2bfe8d13e0 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 @@ -46,25 +46,23 @@ abstract class NotificationOpenedActivityBase : Activity() { } internal open fun processIntent() { - if (!OneSignal.initWithContext(applicationContext)) { - return + suspendifyOnThread { + if (!OneSignal.initWithContext(applicationContext)) { + return@suspendifyOnThread + } + + val openedProcessor = OneSignal.getService() + openedProcessor.processFromContext(this, intent) + // KEEP: Xiaomi Compatibility: + // Must keep this Activity alive while trampolining, that is + // startActivity() must be called BEFORE finish(), otherwise + // the app is never foregrounded. + + // Safely finish the activity on the main thread after processing is complete. + // This gives the system enough time to complete rendering before closing the Trampoline activity. + runOnUiThread { + AndroidUtils.finishSafely(this) + } } - suspendifyOnThread( - block = { - val openedProcessor = OneSignal.getService() - openedProcessor.processFromContext(this, intent) - // KEEP: Xiaomi Compatibility: - // Must keep this Activity alive while trampolining, that is - // startActivity() must be called BEFORE finish(), otherwise - // the app is never foregrounded. - }, - onComplete = { - // Safely finish the activity on the main thread after processing is complete. - // This gives the system enough time to complete rendering before closing the Trampoline activity. - runOnUiThread { - AndroidUtils.finishSafely(this) - } - }, - ) } } 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 7a9a8fef54..2b14638d73 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 @@ -63,42 +63,44 @@ object OneSignalHmsEventBridge { context: Context, message: RemoteMessage, ) { - if (!OneSignal.initWithContext(context)) { - return - } + suspendifyOnThread { + if (!OneSignal.initWithContext(context)) { + return@suspendifyOnThread + } - var time = OneSignal.getService() - val bundleProcessor = OneSignal.getService() + var time = OneSignal.getService() + val bundleProcessor = OneSignal.getService() - var data = message.data - try { - val messageDataJSON = JSONObject(message.data) - if (message.ttl == 0) { - messageDataJSON.put(HMS_TTL_KEY, NotificationConstants.DEFAULT_TTL_IF_NOT_IN_PAYLOAD) - } else { - messageDataJSON.put(HMS_TTL_KEY, message.ttl) - } + var data = message.data + try { + val messageDataJSON = JSONObject(message.data) + if (message.ttl == 0) { + messageDataJSON.put(HMS_TTL_KEY, NotificationConstants.DEFAULT_TTL_IF_NOT_IN_PAYLOAD) + } else { + messageDataJSON.put(HMS_TTL_KEY, message.ttl) + } + + if (message.sentTime == 0L) { + messageDataJSON.put(HMS_SENT_TIME_KEY, time.currentTimeMillis) + } else { + messageDataJSON.put(HMS_SENT_TIME_KEY, message.sentTime) + } - if (message.sentTime == 0L) { - messageDataJSON.put(HMS_SENT_TIME_KEY, time.currentTimeMillis) - } else { - messageDataJSON.put(HMS_SENT_TIME_KEY, message.sentTime) + data = messageDataJSON.toString() + } catch (e: JSONException) { + Logging.error("OneSignalHmsEventBridge error when trying to create RemoteMessage data JSON") } - data = messageDataJSON.toString() - } catch (e: JSONException) { - Logging.error("OneSignalHmsEventBridge error when trying to create RemoteMessage data JSON") - } + // HMS notification with Message Type being Message won't trigger Activity reverse trampolining logic + // for this case OneSignal rely on NotificationOpenedActivityHMS activity + // Last EMUI (12 to the date) is based on Android 10, so no + // Activity trampolining restriction exist for HMS devices + if (data == null) { + return@suspendifyOnThread + } - // HMS notification with Message Type being Message won't trigger Activity reverse trampolining logic - // for this case OneSignal rely on NotificationOpenedActivityHMS activity - // Last EMUI (12 to the date) is based on Android 10, so no - // Activity trampolining restriction exist for HMS devices - if (data == null) { - return + val bundle = JSONUtils.jsonStringToBundle(data) ?: return@suspendifyOnThread + bundleProcessor.processBundleFromReceiver(context, bundle) } - - val bundle = JSONUtils.jsonStringToBundle(data) ?: return - bundleProcessor.processBundleFromReceiver(context, bundle) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt index 741b644fbb..0dc570df7e 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationWorkManager.kt @@ -64,6 +64,7 @@ internal class NotificationGenerationWorkManager : INotificationGenerationWorkMa class NotificationGenerationWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { if (!OneSignal.initWithContext(applicationContext)) { + Logging.warn("NotificationWorker skipped due to failed OneSignal initialization") return Result.success() } @@ -71,11 +72,16 @@ internal class NotificationGenerationWorkManager : INotificationGenerationWorkMa val inputData = inputData val id = inputData.getString(OS_ID_DATA_PARAM) ?: return Result.failure() - try { + return try { Logging.debug("NotificationWorker running doWork with data: $inputData") + val androidNotificationId = inputData.getInt(ANDROID_NOTIF_ID_WORKER_DATA_PARAM, 0) val jsonPayload = JSONObject(inputData.getString(JSON_PAYLOAD_WORKER_DATA_PARAM)) - val timestamp = inputData.getLong(TIMESTAMP_WORKER_DATA_PARAM, System.currentTimeMillis() / 1000L) + val timestamp = + inputData.getLong( + TIMESTAMP_WORKER_DATA_PARAM, + System.currentTimeMillis() / 1000L, + ) val isRestoring = inputData.getBoolean(IS_RESTORING_WORKER_DATA_PARAM, false) notificationProcessor.processNotificationData( @@ -85,13 +91,13 @@ internal class NotificationGenerationWorkManager : INotificationGenerationWorkMa isRestoring, timestamp, ) + Result.success() } catch (e: JSONException) { Logging.error("Error occurred doing work for job with id: $id", e) - return Result.failure() + Result.failure() } finally { removeNotificationIdProcessed(id!!) } - return Result.success() } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt index ff85db76dc..c878fc866e 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt @@ -33,6 +33,8 @@ import com.onesignal.notifications.internal.lifecycle.INotificationLifecycleServ import com.onesignal.notifications.internal.receivereceipt.IReceiveReceiptWorkManager import com.onesignal.session.internal.influence.IInfluenceManager import com.onesignal.user.internal.subscriptions.ISubscriptionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -266,7 +268,9 @@ internal class NotificationLifecycleService( val intent = intentGenerator.getIntentVisible() if (intent != null) { Logging.info("SDK running startActivity with Intent: $intent") - activity.startActivity(intent) + withContext(Dispatchers.Main) { + activity.startActivity(intent) + } } else { Logging.info("SDK not showing an Activity automatically due to it's settings.") } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt index eb421f0ed8..a888205f7f 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptWorkManager.kt @@ -74,12 +74,13 @@ internal class ReceiveReceiptWorkManager( class ReceiveReceiptWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { if (!OneSignal.initWithContext(applicationContext)) { + Logging.warn("ReceiveReceiptWorker skipped due to failed OneSignal initialization") return Result.success() } - val notificationId = inputData.getString(OS_NOTIFICATION_ID)!! - val appId = inputData.getString(OS_APP_ID)!! - val subscriptionId = inputData.getString(OS_SUBSCRIPTION_ID)!! + val notificationId = inputData.getString(OS_NOTIFICATION_ID) ?: return Result.failure() + val appId = inputData.getString(OS_APP_ID) ?: return Result.failure() + val subscriptionId = inputData.getString(OS_SUBSCRIPTION_ID) ?: return Result.failure() val receiveReceiptProcessor = OneSignal.getService() receiveReceiptProcessor.sendReceiveReceipt(appId, subscriptionId, notificationId) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt index bb2b567850..1f91f2b4c7 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt @@ -6,6 +6,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkerParameters import com.onesignal.OneSignal +import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.common.OSWorkManagerHelper import com.onesignal.notifications.internal.restoration.INotificationRestoreProcessor @@ -48,16 +49,18 @@ internal class NotificationRestoreWorkManager : INotificationRestoreWorkManager override suspend fun doWork(): Result { val context = applicationContext - if (!OneSignal.initWithContext(context)) { + val initialized = OneSignal.initWithContext(context) + if (!initialized) { + Logging.warn("NotificationRestoreWorker skipped due to failed OneSignal init") return Result.success() } if (!NotificationHelper.areNotificationsEnabled(context)) { + Logging.debug("NotificationRestoreWorker failed: Notifications disabled") return Result.failure() } val processor = OneSignal.getService() - processor.process() return Result.success() 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 9bb41d3de0..171b14eb39 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,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.onesignal.OneSignal +import com.onesignal.common.threading.suspendifyOnThread +import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager class BootUpReceiver : BroadcastReceiver() { @@ -37,12 +39,18 @@ class BootUpReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - if (!OneSignal.initWithContext(context.applicationContext)) { - return - } - - val restoreWorkManager = OneSignal.getService() + val pendingResult = goAsync() + // in background, init onesignal and begin enqueueing restore work + suspendifyOnThread { + if (!OneSignal.initWithContext(context.applicationContext)) { + Logging.warn("NotificationRestoreReceiver skipped due to failed OneSignal init") + pendingResult.finish() + return@suspendifyOnThread + } - restoreWorkManager.beginEnqueueingWork(context, true) + val restoreWorkManager = OneSignal.getService() + restoreWorkManager.beginEnqueueingWork(context, true) + pendingResult.finish() + } } } 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 bdef09d516..e40d7d607a 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,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.onesignal.OneSignal +import com.onesignal.common.threading.suspendifyOnThread +import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor // This is the entry point when a FCM payload is received from the Google Play services app @@ -23,26 +25,35 @@ class FCMBroadcastReceiver : BroadcastReceiver() { return } - if (!OneSignal.initWithContext(context.applicationContext)) { - return - } + val pendingResult = goAsync() + // process in background + suspendifyOnThread { + if (!OneSignal.initWithContext(context.applicationContext)) { + Logging.warn("FCMBroadcastReceiver skipped due to failed OneSignal init") + pendingResult.finish() + return@suspendifyOnThread + } - val bundleProcessor = OneSignal.getService() + val bundleProcessor = OneSignal.getService() - if (!isFCMMessage(intent)) { - setSuccessfulResultCode() - return - } + if (!isFCMMessage(intent)) { + setSuccessfulResultCode() + pendingResult.finish() + return@suspendifyOnThread + } - val processedResult = bundleProcessor.processBundleFromReceiver(context, bundle) + val processedResult = bundleProcessor.processBundleFromReceiver(context, bundle) - // Prevent other FCM receivers from firing if work manager is processing the notification - if (processedResult!!.isWorkManagerProcessing) { - setAbort() - return - } + // Prevent other FCM receivers from firing if work manager is processing the notification + if (processedResult?.isWorkManagerProcessing == true) { + setAbort() + pendingResult.finish() + return@suspendifyOnThread + } - setSuccessfulResultCode() + setSuccessfulResultCode() + pendingResult.finish() + } } private fun setSuccessfulResultCode() { 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 560d2b417a..93d3d34936 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,22 +28,33 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.onesignal.OneSignal -import com.onesignal.common.threading.suspendifyBlocking +import com.onesignal.common.threading.suspendifyOnThread +import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.open.INotificationOpenedProcessor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class NotificationDismissReceiver : BroadcastReceiver() { override fun onReceive( context: Context, intent: Intent, ) { - if (!OneSignal.initWithContext(context.applicationContext)) { - return - } + val pendingResult = goAsync() + + suspendifyOnThread { + if (!OneSignal.initWithContext(context.applicationContext)) { + Logging.warn("NotificationOpenedReceiver skipped due to failed OneSignal init") + pendingResult.finish() + return@suspendifyOnThread + } - var notificationOpenedProcessor = OneSignal.getService() + val notificationOpenedProcessor = OneSignal.getService() - suspendifyBlocking { - notificationOpenedProcessor.processFromContext(context, intent) + // init OneSignal in background but process in main + withContext(Dispatchers.Main) { + notificationOpenedProcessor.processFromContext(context, intent) + } + pendingResult.finish() } } } 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 7fd768c9ae..f093c5c211 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,8 @@ import android.content.Context import android.content.Intent import android.os.Build import com.onesignal.OneSignal +import com.onesignal.common.threading.suspendifyOnThread +import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager class UpgradeReceiver : BroadcastReceiver() { @@ -38,19 +40,27 @@ class UpgradeReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - // TODO: Now that we arent restoring like we use to, think we can remove this? Ill do some - // testing and look at the issue but maybe someone has a answer or rems what directly - // was causing this issue + // TODO: Now that we aren't restoring like we used to, think we can remove this? + // I'll do some testing and look at the issue, but maybe someone has an answer or + // remembers what directly was causing this issue. // Return early if using Android 7.0 due to upgrade restore crash (#263) if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N) { return } - if (!OneSignal.initWithContext(context.applicationContext)) { - return - } + val pendingResult = goAsync() - val restoreWorkManager = OneSignal.getService() - restoreWorkManager.beginEnqueueingWork(context, true) + // init OneSignal and enqueue restore work in background + suspendifyOnThread { + if (!OneSignal.initWithContext(context.applicationContext)) { + Logging.warn("UpgradeReceiver skipped due to failed OneSignal init") + pendingResult.finish() + return@suspendifyOnThread + } + + val restoreWorkManager = OneSignal.getService() + restoreWorkManager.beginEnqueueingWork(context, true) + pendingResult.finish() + } } } 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 cc8d9c2e2e..c12ddd9764 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 @@ -13,15 +13,17 @@ import com.onesignal.notifications.internal.registration.impl.IPushRegistratorCa class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { override fun onMessage(intent: Intent) { val context = applicationContext - if (!OneSignal.initWithContext(context)) { - return - } - - val bundle = intent.extras + val bundle = intent.extras ?: return - val bundleProcessor = OneSignal.getService() + suspendifyOnThread { + if (!OneSignal.initWithContext(context)) { + Logging.warn("onMessage skipped due to failed OneSignal init") + return@suspendifyOnThread + } - bundleProcessor.processBundleFromReceiver(context, bundle!!) + val bundleProcessor = OneSignal.getService() + bundleProcessor.processBundleFromReceiver(context, bundle) + } } override fun onRegistered(newRegistrationId: String) { 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 c707333743..f1f0143863 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 @@ -14,17 +14,23 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { context: Context?, intent: Intent?, ) { - if (context == null) { - return - } - if (!OneSignal.initWithContext(context.applicationContext)) { + val bundle = intent?.extras + + if (context == null || bundle == null) { return } - val bundleProcessor = OneSignal.getService() - val bundle = intent?.extras + val safeContext = context.applicationContext + + suspendifyOnThread { + if (!OneSignal.initWithContext(safeContext)) { + Logging.warn("onMessage skipped due to failed OneSignal init") + return@suspendifyOnThread + } - bundleProcessor.processBundleFromReceiver(context!!, bundle!!) + val bundleProcessor = OneSignal.getService() + bundleProcessor.processBundleFromReceiver(safeContext, bundle) + } } override fun onRegistered(