From 351a26f707c4326bbadceae98d3fbf5ae4f33aaa Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 16 Oct 2026 11:56:10 -0500 Subject: [PATCH] feat: [SDK-5538] remote kill switch with a placeholder OneSignalImpV2 NeverCrashKillSwitch reads the feature flags cached by the previous run at process start (ActivityLifecycleInitializer) and picks the IOneSignal implementation once per process: sdk_never_crash_disabled present means OneSignalImp, otherwise OneSignalImpV2. A fresh install, a locked device, an unreadable cache, or first use before evaluation keep OneSignalImpV2. OneSignalImpV2 is a verbatim copy of OneSignalImp for now, so behavior is unchanged either way. The never-crash init lands on it later in this stack. Co-authored-by: Cursor --- OneSignalSDK/detekt/detekt-baseline-core.xml | 4 + .../src/main/java/com/onesignal/OneSignal.kt | 5 +- .../impl/ActivityLifecycleInitializer.kt | 6 +- .../internal/NeverCrashKillSwitch.kt | 76 ++ .../com/onesignal/internal/OneSignalImpV2.kt | 919 ++++++++++++++++++ .../internal/NeverCrashKillSwitchTests.kt | 99 ++ 6 files changed, 1105 insertions(+), 4 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/NeverCrashKillSwitch.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImpV2.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/NeverCrashKillSwitchTests.kt diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 74e9f501d2..2feb49e8f0 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -253,6 +253,7 @@ MagicNumber:OSDatabase.kt$OSDatabase$9 MagicNumber:OneSignalDispatchers.kt$OneSignalDispatchers$1024 MagicNumber:OneSignalImp.kt$OneSignalImp$8 + MagicNumber:OneSignalImpV2.kt$OneSignalImpV2$8 MagicNumber:OperationRepo.kt$OperationRepo$1_000 MagicNumber:OutcomeEventsController.kt$OutcomeEventsController$1000 MagicNumber:PermissionsActivity.kt$PermissionsActivity$23 @@ -418,6 +419,7 @@ TooManyFunctions:OSDatabase.kt$OSDatabase : SQLiteOpenHelperIDatabase TooManyFunctions:OneSignal.kt$OneSignal$OneSignal TooManyFunctions:OneSignalImp.kt$OneSignalImp : IOneSignalIServiceProvider + TooManyFunctions:OneSignalImpV2.kt$OneSignalImpV2 : IOneSignalIServiceProvider TooManyFunctions:OperationRepo.kt$OperationRepo : IOperationRepoIStartableService TooManyFunctions:OutcomeEventsController.kt$OutcomeEventsController : IOutcomeEventsControllerIStartableServiceISessionLifecycleHandler TooManyFunctions:PreferencesService.kt$PreferencesService : IPreferencesServiceIStartableService @@ -638,5 +640,7 @@ UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") + UseCheckOrError:OneSignalImpV2.kt$OneSignalImpV2$throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'") + UseCheckOrError:OneSignalImpV2.kt$OneSignalImpV2$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") 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 928a52c0a2..f834e3cc3f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -4,7 +4,7 @@ import android.content.Context import com.onesignal.common.services.IServiceProvider import com.onesignal.debug.IDebugManager import com.onesignal.inAppMessages.IInAppMessagesManager -import com.onesignal.internal.OneSignalImp +import com.onesignal.internal.NeverCrashKillSwitch import com.onesignal.location.ILocationManager import com.onesignal.notifications.INotificationsManager import com.onesignal.session.ISessionManager @@ -376,8 +376,9 @@ object OneSignal { fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) = oneSignal.removeUserJwtInvalidatedListener(listener) + @Suppress("MemberNameEqualsClassName") private val oneSignal: IOneSignal by lazy { - OneSignalImp() + NeverCrashKillSwitch.create() } /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt index 9fde58fc5b..d194dea20e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.startup.Initializer import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.internal.NeverCrashKillSwitch /** * Registers activity lifecycle observation at process start via androidx.startup, before the host @@ -16,12 +17,13 @@ import com.onesignal.core.internal.application.IApplicationService * full lifecycle regardless of when init later happens. * * This intentionally does NOT initialize the SDK or require an App ID; it only attaches lifecycle - * observation and starts pure dispatcher warmup. App ID / consent gating remains in the runtime - * `initWithContext` path. + * observation, starts pure dispatcher warmup, and reads the cached init kill switch. App ID / consent + * gating remains in the runtime `initWithContext` path. */ class ActivityLifecycleInitializer : Initializer { override fun create(context: Context): IApplicationService { OneSignalDispatchers.prewarm() + NeverCrashKillSwitch.evaluate(context) val applicationService = ApplicationService.getInstance() (context.applicationContext as? Application)?.let { application -> applicationService.attachToApplication(application) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/NeverCrashKillSwitch.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/NeverCrashKillSwitch.kt new file mode 100644 index 0000000000..4478383dd2 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/NeverCrashKillSwitch.kt @@ -0,0 +1,76 @@ +package com.onesignal.internal + +import android.content.Context +import com.onesignal.IOneSignal +import com.onesignal.common.AndroidUtils +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.Logging +import org.json.JSONArray + +/** + * Chooses the [IOneSignal] implementation once per process, from the feature flags cached by the + * previous run, so a remote change applies on the next cold start. Any failure keeps the new behavior. + */ +internal object NeverCrashKillSwitch { + // Present means off, so installs whose init never succeeds (and never fetch flags) keep the safe default. + const val FLAG_KEY = "sdk_never_crash_disabled" + + // Local testing only: true forces the legacy implementation, false the new one, null reads the flag. + private val localOverride: Boolean? = null + + @Volatile + var useLegacy: Boolean = false + private set + + @Volatile + var evaluated: Boolean = false + private set + + // Runs on the main thread before Application.onCreate, so it must never throw. + @Suppress("TooGenericExceptionCaught") + fun evaluate(context: Context) { + useLegacy = localOverride ?: try { + isDisabledByCachedFlags(context) + } catch (t: Throwable) { + Logging.warn("OneSignal: could not read cached feature flags, keeping the default init behavior", t) + false + } + evaluated = true + } + + fun create(): IOneSignal = + when { + !evaluated -> { + Logging.debug("OneSignal: kill switch not evaluated before first use, keeping the default init behavior") + OneSignalImpV2() + } + useLegacy -> { + Logging.warn("OneSignal: $FLAG_KEY is on, using the legacy init behavior") + OneSignalImp() + } + else -> OneSignalImpV2() + } + + internal fun isDisabledByCachedFlags(context: Context): Boolean { + // Credential-encrypted prefs throw on access before the user unlocks the device. + val keys = if (AndroidUtils.isAndroidUserUnlocked(context)) cachedFeatureKeys(context) else null + return keys?.any { it.equals(FLAG_KEY, ignoreCase = true) } == true + } + + private fun cachedFeatureKeys(context: Context): List? { + val stored = + context + .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + .getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, null) + val keys = stored?.let { JSONArray(it).optJSONObject(0)?.optJSONArray(ConfigModel::sdkRemoteFeatureFlags.name) } + return keys?.let { array -> (0 until array.length()).map { array.optString(it) } } + } + + internal fun resetForTest() { + useLegacy = false + evaluated = false + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImpV2.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImpV2.kt new file mode 100644 index 0000000000..182bbdff5a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImpV2.kt @@ -0,0 +1,919 @@ +package com.onesignal.internal + +import android.content.Context +import android.os.Build +import com.onesignal.ErrorCode +import com.onesignal.IOneSignal +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.LoginData +import com.onesignal.OneSignalError +import com.onesignal.OneSignalResult +import com.onesignal.OneSignalUserProfile +import com.onesignal.common.AndroidUtils +import com.onesignal.common.DeviceUtils +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.isMissing +import com.onesignal.common.modules.IModule +import com.onesignal.common.services.IServiceProvider +import com.onesignal.common.services.ServiceBuilder +import com.onesignal.common.services.ServiceProvider +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.core.CoreModule +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.application.impl.ApplicationService +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.config.impl.IdentityVerificationService +import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.operations.OperationWaitResult +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceStoreFix +import com.onesignal.core.internal.startup.StartupService +import com.onesignal.debug.IDebugManager +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.DebugManager +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.debug.internal.logging.logger.android.getCrashStoragePath +import com.onesignal.inAppMessages.IInAppMessagesManager +import com.onesignal.location.ILocationManager +import com.onesignal.logger.IObservabilityEventRecorder +import com.onesignal.notifications.INotificationsManager +import com.onesignal.session.ISessionManager +import com.onesignal.session.SessionModule +import com.onesignal.user.IUserManager +import com.onesignal.user.UserModule +import com.onesignal.user.internal.LoginHelper +import com.onesignal.user.internal.LogoutHelper +import com.onesignal.user.internal.UserSwitcher +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.jwt.JwtTokenStore +import com.onesignal.user.internal.properties.PropertiesModelStore +import com.onesignal.user.internal.resolveAppId +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +internal class OneSignalImpV2 : IOneSignal, + IServiceProvider { + // Reset every time the synchronized(initLock) block flips state to IN_PROGRESS so that + // a retry-after-FAILED gets a fresh latch instead of an already-completed one. Mutated only + // under initLock; reads outside that lock must local-capture before suspending on it. + @Volatile + private var suspendCompletion = CompletableDeferred() + + @Volatile + private var initState: InitState = InitState.NOT_STARTED + + // Save the exception pointing to the caller that triggered init, not the async worker thread. + private var initFailureException: Exception? = null + + private var observabilityManager: IObservabilityLifecycleManager? = null + + override val sdkVersion: String = OneSignalUtils.sdkVersion + + override val isInitialized: Boolean + get() = initState == InitState.SUCCESS + + override var consentRequired: Boolean + get() = + if (isInitialized) { + blockingGet { configModel.consentRequired ?: (_consentRequired == true) } + } else { + _consentRequired == true + } + set(value) { + _consentRequired = value + if (isInitialized) { + configModel.consentRequired = value + } + } + + override var consentGiven: Boolean + get() = + if (isInitialized) { + blockingGet { configModel.consentGiven ?: (_consentGiven == true) } + } else { + _consentGiven == true + } + set(value) { + val oldValue = _consentGiven + _consentGiven = value + if (isInitialized) { + configModel.consentGiven = value + if (oldValue != value && value) { + operationRepo.forceExecuteOperations() + } + } + } + + override var disableGMSMissingPrompt: Boolean + get() = + if (isInitialized) { + blockingGet { configModel.disableGMSMissingPrompt ?: (_disableGMSMissingPrompt == true) } + } else { + _disableGMSMissingPrompt == true + } + set(value) { + _disableGMSMissingPrompt = value + if (isInitialized) { + configModel.disableGMSMissingPrompt = value + } + } + + // we hardcode the DebugManager implementation so it can be used prior to calling `initWithContext` + override val debug: IDebugManager = DebugManager() + + 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 + // on ApplicationService.appContext being non-null. + private val operationRepo: IOperationRepo by lazy { services.getService() } + private val identityModelStore: IdentityModelStore by lazy { services.getService() } + private val propertiesModelStore: PropertiesModelStore by lazy { services.getService() } + private val subscriptionModelStore: SubscriptionModelStore by lazy { services.getService() } + private val preferencesService: IPreferencesService by lazy { services.getService() } + private val jwtTokenStore: JwtTokenStore by lazy { services.getService() } + private val identityVerificationService: IdentityVerificationService by lazy { services.getService() } + private val listOfModules = + listOf( + "com.onesignal.notifications.NotificationsModule", + "com.onesignal.inAppMessages.InAppMessagesModule", + "com.onesignal.location.LocationModule", + ) + private val services: ServiceProvider = + ServiceBuilder() + .apply { + val modules = mutableListOf() + modules.add(CoreModule()) + modules.add(SessionModule()) + modules.add(UserModule()) + for (moduleClassName in listOfModules) { + try { + val moduleClass = Class.forName(moduleClassName) + val moduleInstance = moduleClass.newInstance() as IModule + modules.add(moduleInstance) + } catch (e: ClassNotFoundException) { + e.printStackTrace() + } + } + for (module in modules) { + module.register(this) + } + }.build() + + // Off-main work routes through OneSignal's centralized dispatcher pool rather than ad-hoc + // threads / Dispatchers.IO. + // + // Resolved per-access (get()) rather than captured once: an eager `val` would construct the + // lazy `pools.IO` dispatcher during OneSignalImp construction on the main thread (before + // prewarm() runs), and would pin this instance to one pool generation -- so after + // resetForTest() swaps `pools` and shutdownNow()'s the old executor, a reused instance would + // dispatch onto a dead pool. The getter always reads the current generation's warm dispatcher. + private val ioDispatcher: CoroutineDispatcher get() = OneSignalDispatchers.IO + + // get the current config model, if there is one + private val configModel: ConfigModel by lazy { services.getService().model } + private var _consentRequired: Boolean? = null + private var _consentGiven: Boolean? = null + private var _disableGMSMissingPrompt: Boolean? = null + private val initLock: Any = Any() + private val loginLogoutLock: Any = Any() + private val applicationServiceLock: Any = Any() + + @Volatile + private var applicationServiceStarted: Boolean = false + private val userSwitcher by lazy { + val appContext = services.getService().appContext + UserSwitcher( + identityModelStore = identityModelStore, + propertiesModelStore = propertiesModelStore, + subscriptionModelStore = subscriptionModelStore, + configModel = configModel, + carrierName = DeviceUtils.getCarrierName(appContext), + deviceOS = android.os.Build.VERSION.RELEASE, + appContextProvider = { appContext }, + preferencesService = preferencesService, + operationRepo = operationRepo, + services = services, + ) + } + + private val loginHelper by lazy { + LoginHelper( + identityModelStore = identityModelStore, + userSwitcher = userSwitcher, + operationRepo = operationRepo, + configModel = configModel, + jwtTokenStore = jwtTokenStore, + lock = loginLogoutLock, + subscriptionModelStore = subscriptionModelStore, + ) + } + + private val logoutHelper by lazy { + LogoutHelper( + identityModelStore = identityModelStore, + userSwitcher = userSwitcher, + operationRepo = operationRepo, + configModel = configModel, + subscriptionModelStore = subscriptionModelStore, + identityVerificationService = identityVerificationService, + lock = loginLogoutLock, + ) + } + + private fun initEssentials(context: Context) { + // LoggerLifecycleManager comes up early so crash handling and remote logging can capture + // anything that happens during the rest of init. FeatureManager is wired in via a + // lazy supplier — `enabledFeatureFlags` is read per-event, so resolving the manager + // can be deferred until services have bootstrapped. + val featureManagerProvider = { services.getService() } + observabilityManager = + LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) + .also { it.initializeFromCachedConfig() } + logStartupDiagnostics(context) + + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) + + ensureApplicationServiceStarted(context) + } + + /** + * One concise WARN line at init with the build/runtime facts most useful for + * release triage from a raw log capture: SDK version, the shared KMP module version, + * OS/API, device, host app + version, and the crash storage dir. + * Best-effort — never lets diagnostics interfere with init. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + internal fun logStartupDiagnostics(context: Context) { + try { + val appVersion = + try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + } catch (t: Throwable) { + "unknown" + } + Logging.warn( + "OneSignal init: sdkVersion=${OneSignalUtils.sdkVersion} " + + "observabilityModule=logger " + + "kmpVersion=${com.onesignal.logger.LoggerBuildInfo.KMP_VERSION} " + + "os=Android/${Build.VERSION.RELEASE}(API ${Build.VERSION.SDK_INT}) " + + "device=${Build.MANUFACTURER}/${Build.MODEL} " + + "app=${context.packageName}@$appVersion " + + "crashDir=${getCrashStoragePath(context)}", + ) + } catch (t: Throwable) { + Logging.warn("OneSignal init: startup diagnostics failed: ${t.message}", t) + } + } + + private fun ensureApplicationServiceStarted(context: Context) { + if (applicationServiceStarted) { + return + } + + synchronized(applicationServiceLock) { + if (applicationServiceStarted) { + return + } + + // Start application service before any model store or prefs-backed service access. + val applicationService = services.getService() + (applicationService as ApplicationService).start(context) + Logging.applicationService = applicationService + applicationServiceStarted = true + } + } + + private fun updateConfig() { + // if requires privacy consent was set prior to init, set it in the model now + if (_consentRequired != null) { + configModel.consentRequired = _consentRequired!! + } + + // if privacy consent was set prior to init, set it in the model now + if (_consentGiven != null) { + configModel.consentGiven = _consentGiven!! + } + + if (_disableGMSMissingPrompt != null) { + configModel.disableGMSMissingPrompt = _disableGMSMissingPrompt!! + } + } + + private fun bootstrapServices(): StartupService { + val startupService = StartupService(services) + // bootstrap all services + startupService.bootstrap() + return startupService + } + + /** + * Fire-and-forget init. The actual work runs asynchronously on the IO pool, so this method + * cannot know the real outcome before it returns. + * + * Return value: always `true` once init has been kicked off (or is already in progress/done). + * It does NOT reflect init success — a failure surfaces later, either via a subsequent + * accessor throwing or by querying [isInitialized]. Callers (including wrapper SDKs) must not + * branch on this boolean to detect init failure; use the suspending [initWithContextSuspend], + * which returns an accurate result, when the outcome matters. + */ + @Suppress("ReturnCount", "TooGenericExceptionCaught") + override fun initWithContext( + context: Context, + appId: String, + ): Boolean { + Logging.log(LogLevel.DEBUG, "Calling deprecated initWithContext(context: $context, appId: $appId)") + + // Warm OneSignalDispatchers on a dedicated daemon thread so the first production caller + // of suspendifyOnIO / launchOnSerialIO doesn't pay the ThreadPoolExecutor + dispatcher + + // scope construction cost on the main thread (observed as 5-20s main-thread blocks at the + // first foreground/background lifecycle event). prewarm() is idempotent and fire-and-forget. + OneSignalDispatchers.prewarm() + + synchronized(initLock) { + if (initState.isSDKAccessible()) { + Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") + return true + } + + // Publish state and its associated metadata atomically. + initFailureException = IllegalStateException("OneSignal initWithContext failed.") + initState = InitState.IN_PROGRESS + // Fresh latch for this init attempt -- prevents retry-after-FAILED waiters from + // observing the prior init's already-completed deferred. + suspendCompletion = CompletableDeferred() + } + + // Tight try/catch around the only step that can throw synchronously *before* the + // dispatch (or runBlocking). ApplicationService.start does + // `context.applicationContext as Application`, which can ClassCastException in + // restricted hosts (Robolectric custom application factories, instrumentation context + // wrappers, multi-process content provider init order). If we don't transition to + // FAILED here, IN_PROGRESS would never be retired — every subsequent + // [initWithContext] would short-circuit (IN_PROGRESS counts as "accessible") and every + // accessor would deadlock on suspendCompletion.await(). Failures *inside* + // [internalInit] are owned by its own try/catch. + try { + ensureApplicationServiceStarted(context) + } catch (e: Exception) { + initFailureException?.addSuppressed(e) + completeInit(InitState.FAILED) + throw e + } + + // Dispatch init asynchronously so this method never blocks the caller. Callers that + // need to wait (accessors, login, logout) will block via suspendCompletion. + suspendifyOnIO { + internalInit(context, appId) + } + return true + } + + /** + * 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)") + return initWithContextSuspend(context, null) + } + + // ReturnCount: 4 returns vs detekt's limit of 2. The function intentionally has + // multiple early-return failure paths (user-locked, missing appId, exception). The + // existing detekt baseline entry was for the unannotated signature; once we added + // @Suppress(TooGenericExceptionCaught) the baseline ID no longer matched, so we + // suppress ReturnCount inline rather than churning the baseline. + @Suppress("ReturnCount", "TooGenericExceptionCaught") + private fun internalInit( + context: Context, + appId: String?, + ): Boolean { + try { + // Check whether current Android user is accessible. + // Return early if it is inaccessible, as we are unable to complete initialization without access + // to device storage like SharedPreferences. + if (!AndroidUtils.isAndroidUserUnlocked(context)) { + Logging.warn("initWithContext called when device storage is locked, no user data is accessible!") + completeInit(InitState.FAILED) + return false + } + + initEssentials(context) + + val startupService = bootstrapServices() + + // Now that the IoC container is ready, subscribe the observability lifecycle + // manager to config store events so it reacts to fresh remote config. + observabilityManager?.subscribeToConfigStore(services.getService()) + // The event recorder is a container service, so it can only be handed over now. The resolve + // cannot fail in practice (bootstrap already built the feature manager it needs), and the + // hand-over is fail-open inside the manager. + val eventRecorder = services.getServiceOrNull() + if (eventRecorder != null) { + observabilityManager?.attachEventRecorder(eventRecorder) + } else { + Logging.warn("OneSignal: event recorder unavailable, observability events will not ship") + } + + val result = resolveAppId(appId, configModel, preferencesService) + if (result.failed) { + val message = "suspendInitInternal: no appId provided or found in local storage. Please pass a valid appId to initWithContext()." + val exception = IllegalStateException(message) + // attach the real crash cause to the init failure exception that will be throw shortly after + initFailureException?.addSuppressed(exception) + Logging.warn(message) + completeInit(InitState.FAILED) + return false + } + configModel.appId = result.appId!! // safe because failed is false + val forceCreateUser = result.forceCreateUser + + updateConfig() + userSwitcher.initUser(forceCreateUser) + startupService.scheduleStart() + completeInit(InitState.SUCCESS) + return true + } catch (e: Exception) { + // Any unchecked throw from initEssentials / bootstrapServices / subscribeToConfigStore / + // attachEventRecorder / updateConfig / userSwitcher.initUser / startupService.scheduleStart would otherwise + // leave initState at IN_PROGRESS forever and `suspendCompletion` uncompleted — + // accessors and re-entrant suspend callers (e.g. SyncJobService) would deadlock on + // `await()`. Reach a terminal state via [completeInit] (atomic state+completion) and + // return `false`. The throw is captured on `initFailureException.addSuppressed(...)` + // so a subsequent accessor surfaces it to the caller. We deliberately don't rethrow + // here: the synchronous [initWithContext] dispatches this via `suspendifyOnIO { ... }` + // (which has no caller to rethrow to), so returning `false` on every path — including + // [initWithContextSuspend] — gives one consistent shape. Callers can query + // `initFailureException` (or just the returned `Boolean`) to react. + Logging.error("OneSignal: internalInit threw unexpectedly; marking init FAILED", e) + initFailureException?.addSuppressed(e) + completeInit(InitState.FAILED) + return false + } + } + + override fun login( + externalId: String, + jwtBearerToken: String?, + ) { + Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") + + waitForInit(operationName = "login") + + if (isMissing(externalId, "login: externalId")) return + + val context = loginHelper.switchUser(externalId, jwtBearerToken).context ?: return + + suspendifyOnIO { loginHelper.enqueueLogin(context) } + } + + override fun logout() { + Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") + + waitForInit(operationName = "logout") + + val context = logoutHelper.switchUser() ?: return + + suspendifyOnIO { logoutHelper.enqueueLogout(context) } + } + + override fun updateUserJwt( + externalId: String, + token: String, + ) { + Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: ...${token.takeLast(8)})") + + waitForInit(operationName = "updateUserJwt") + + if (isMissing(externalId, "updateUserJwt: externalId") || isMissing(token, "updateUserJwt: token")) return + + jwtTokenStore.putJwt(externalId, token) + // Wake the queue so any deferred ops can dispatch with the fresh token. + operationRepo.forceExecuteOperations() + } + + override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + waitForInit(operationName = "addUserJwtInvalidatedListener") + jwtTokenStore.addUserJwtInvalidatedListener(listener) + } + + override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + waitForInit(operationName = "removeUserJwtInvalidatedListener") + jwtTokenStore.removeUserJwtInvalidatedListener(listener) + } + + override fun hasService(c: Class): Boolean = services.hasService(c) + + override fun getService(c: Class): T = services.getService(c) + + override fun getServiceOrNull(c: Class): T? = services.getServiceOrNull(c) + + override fun getAllServices(c: Class): List = services.getAllServices(c) + + /** + * Blocking version that waits for initialization to complete. + * Uses runBlocking to bridge to the suspend implementation. + * Waits indefinitely until init completes and logs how long it took. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private fun waitForInit(operationName: String? = null) { + if (initState == InitState.SUCCESS) { + return + } + // Intentionally dispatcher-less: wait on the calling thread's own event loop so resuming + // when init completes never depends on a free worker in a busy pool. + runBlocking { + waitUntilInitInternal(operationName) + } + } + + /** + * Atomically transitions [initState] to a terminal state (SUCCESS or FAILED) + * and completes [suspendCompletion] so any waiters unblock. + * + * Both the state write and the completion run under [initLock]. This closes a + * race where another caller could observe the terminal state in between the two + * writes, call `initWithContext` to flip back to IN_PROGRESS, and replace + * [suspendCompletion] with a fresh deferred — at which point this thread's + * completion would prematurely unblock waiters of the *second* init. + */ + private fun completeInit(terminalState: InitState) { + require(terminalState == InitState.SUCCESS || terminalState == InitState.FAILED) { + "completeInit requires a terminal state, got $terminalState" + } + synchronized(initLock) { + initState = terminalState + suspendCompletion.complete(Unit) + } + } + + /** + * Suspend version that waits for initialization to complete. + * Waits indefinitely until init completes and logs how long it took. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun suspendUntilInit(operationName: String? = null) { + waitUntilInitInternal(operationName) + } + + /** + * Common implementation for waiting until initialization completes. + * Waits indefinitely until init completes (SUCCESS or FAILED) to ensure consistent state. + * Logs how long initialization took when it completes. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun waitUntilInitInternal(operationName: String? = null) { + // Local-capture state + deferred under initLock so we await on the same generation + // we observed (a concurrent retry-after-FAILED can replace `suspendCompletion`). + val observedState: InitState + val completionToAwait: CompletableDeferred + synchronized(initLock) { + observedState = initState + completionToAwait = suspendCompletion + } + + when (observedState) { + InitState.NOT_STARTED -> throw IllegalStateException(notInitializedMessage(operationName)) + + InitState.IN_PROGRESS -> awaitInitCompletion(completionToAwait, operationName) + + InitState.FAILED -> { + throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") + } + + else -> { + // SUCCESS - already initialized, no need to wait + } + } + } + + 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 + // Non-blocking when called from coroutines. But if waitForInit() (which uses runBlocking) + // is called from the main thread, it blocks the main thread until init completes, which can + // ANR. This is intentional: an ANR is recoverable, an uncaught throw is not. To avoid it, + // call SDK methods from background threads or use the suspend API. + 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() + } + + // Blocks until init completes; [waitForInit] throws on NOT_STARTED / FAILED and + // returns once initState == SUCCESS. + private fun waitAndReturn(getter: () -> T): T { + waitForInit() + return getter() + } + + private fun blockingGet(getter: () -> T): T { + try { + if (AndroidUtils.isRunningOnMainThread()) { + Logging.debug("This is called on main thread. This is not recommended.") + } + } catch (e: RuntimeException) { + // In test environments, AndroidUtils.isRunningOnMainThread() may fail + // because Looper.getMainLooper() is not mocked. This is safe to ignore. + Logging.debug("Could not check main thread status (likely in test environment): ${e.message}") + } + if (initState == InitState.SUCCESS) { + return getter() + } + + // Intentionally dispatcher-less (see waitForInit). + return runBlocking { + suspendAndReturn(getter) + } + } + + // =============================== + // Suspend API Implementation + // =============================== + + override suspend fun getSession(): ISessionManager = + withContext(ioDispatcher) { + suspendAndReturn { services.getService() } + } + + override suspend fun getNotifications(): INotificationsManager = + withContext(ioDispatcher) { + suspendAndReturn { services.getService() } + } + + override suspend fun getLocation(): ILocationManager = + withContext(ioDispatcher) { + suspendAndReturn { services.getService() } + } + + override suspend fun getInAppMessages(): IInAppMessagesManager = + withContext(ioDispatcher) { + suspendAndReturn { services.getService() } + } + + override suspend fun getUser(): IUserManager = + withContext(ioDispatcher) { + suspendAndReturn { services.getService() } + } + + override suspend fun getConsentRequired(): Boolean = + withContext(ioDispatcher) { + configModel.consentRequired ?: (_consentRequired == true) + } + + override suspend fun setConsentRequired(required: Boolean) = + withContext(ioDispatcher) { + _consentRequired = required + configModel.consentRequired = required + } + + override suspend fun getConsentGiven(): Boolean = + withContext(ioDispatcher) { + configModel.consentGiven ?: (_consentGiven == true) + } + + override suspend fun setConsentGiven(value: Boolean) = + withContext(ioDispatcher) { + val oldValue = _consentGiven + _consentGiven = value + configModel.consentGiven = value + if (oldValue != value && value) { + operationRepo.forceExecuteOperations() + } + } + + override suspend fun getDisableGMSMissingPrompt(): Boolean = + withContext(ioDispatcher) { + configModel.disableGMSMissingPrompt + } + + override suspend fun setDisableGMSMissingPrompt(value: Boolean) = + withContext(ioDispatcher) { + _disableGMSMissingPrompt = value + configModel.disableGMSMissingPrompt = value + } + + override suspend fun initWithContextSuspend( + context: Context, + appId: String?, + ): Boolean { + Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") + + // Same warm-up as the synchronous variant. Reaching this entry point on the main thread + // (e.g. SyncJobService.onStartJob -> suspendifyOnIO -> initWithContext(context)) pays the + // cold-init cost on the dispatcher used to enter [withContext] below, so warm + // OneSignalDispatchers on a background thread before we touch [ioDispatcher]. + OneSignalDispatchers.prewarm() + + // Observing a terminal SUCCESS must never require an IO worker (see waitForInit). + if (initState == InitState.SUCCESS) { + return true + } + + // Local-capture under the lock so that even if a concurrent retry-after-FAILED + // resets `suspendCompletion`, we await on the same generation we observed. + // Null means this caller is the one that starts init. + val completionToAwait: CompletableDeferred? = + synchronized(initLock) { + if (initState.isSDKAccessible()) { + suspendCompletion + } else { + initState = InitState.IN_PROGRESS + // Fresh latch for this init attempt. + suspendCompletion = CompletableDeferred() + // Only the call that actually starts init owns the failure-attribution exception. + // Re-entrant callers must not overwrite it -- otherwise the failure stack trace + // would point at the SyncJobService coroutine instead of the original initiator. + initFailureException = IllegalStateException("OneSignal initWithContext failed.") + null + } + } + + if (completionToAwait != null) { + // Another caller has already started (or completed) init. Honor this method's + // contract by suspending until initialization is *fully* completed -- not just + // kicked off. This closes a race where re-entrant suspend callers (e.g. the + // SyncJobService entry point) would otherwise + // proceed to use IBackgroundService implementations like SessionService whose + // bootstrap() had not yet run, NPE'ing on still-null model fields. + Logging.log(LogLevel.DEBUG, "initWithContext: init already in progress or completed, awaiting completion") + // Awaited on the caller's own context: waiting for, and resuming from, another + // caller's init must not queue behind the work that init itself is doing. + completionToAwait.await() + return initState == InitState.SUCCESS + } + + // Only the caller that actually runs init needs the IO dispatcher, to keep its disk + // and network work off the calling thread. + return withContext(ioDispatcher) { + internalInit(context, appId) + } + } + + override suspend fun loginSuspend( + externalId: String, + jwtBearerToken: String?, + ) = withContext(ioDispatcher) { + Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") + + // suspendUntilInit throws on NOT_STARTED / FAILED (preserving initFailureException as the + // cause), and only returns once initState == SUCCESS — so no post-check is needed here. + suspendUntilInit(operationName = "login") + + if (isMissing(externalId, "login: externalId")) return@withContext + + val context = loginHelper.switchUser(externalId, jwtBearerToken).context ?: return@withContext + loginHelper.enqueueLogin(context) + } + + override suspend fun login( + externalId: String?, + profile: OneSignalUserProfile, + jwtBearerToken: String?, + ): OneSignalResult = + withContext(ioDispatcher) { + Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, profile: $profile, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") + + profile.validationError()?.let { + Logging.error("login(externalId, profile) rejected: $it") + return@withContext OneSignalResult.failure(ErrorCode.INVALID_ARGUMENT, it) + } + + awaitInitForLogin()?.let { return@withContext it } + + if (isMissing(externalId, "login: externalId") || externalId == null) { + return@withContext OneSignalResult.failure( + ErrorCode.INVALID_ARGUMENT, + "login: externalId is required", + ) + } + + val switched = loginHelper.switchUser(externalId, jwtBearerToken, profile) + if (switched.context != null) { + val completed = loginHelper.enqueueLogin(switched.context, profile) + if (!completed.success) { + val error = + if (completed.httpStatusCode != null) { + OneSignalError.fromBackendResponse( + httpStatus = completed.httpStatusCode, + body = completed.httpResponse, + fallbackMessage = loginFailureMessage(completed), + ) + } else { + OneSignalError.of(ErrorCode.UNKNOWN, loginFailureMessage(completed)) + } + return@withContext OneSignalResult.failure(error) + } + return@withContext OneSignalResult.success( + loginHelper.loginData(externalId, profile, completed, switched.onesignalId), + ) + } + OneSignalResult.success(loginHelper.loginData(externalId, profile, fallbackOnesignalId = switched.onesignalId)) + } + + /** Waits for init; returns a NOT_INITIALIZED failure instead of throwing when it never started or failed. */ + internal suspend fun awaitInitForLogin(): OneSignalResult? = + try { + suspendUntilInit(operationName = "login") + null + } catch (e: CancellationException) { + // A subclass of IllegalStateException, so it must be rethrown before the catch below. + throw e + } catch (e: IllegalStateException) { + // Callers can ignore the result, so the failure has to show up in the log. + Logging.error("login(externalId, profile) skipped: ${e.message}", e) + OneSignalResult.failure(ErrorCode.NOT_INITIALIZED, e.message, cause = e) + } + + private fun loginFailureMessage(wait: OperationWaitResult): String = + wait.httpStatusCode?.let { "Login did not complete (HTTP $it)." } ?: "Login did not complete." + + override suspend fun updateUserJwtSuspend( + externalId: String, + token: String, + ) = withContext(ioDispatcher) { + Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: ...${token.takeLast(8)})") + + suspendUntilInit(operationName = "updateUserJwt") + + if (!isInitialized) { + throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'") + } + + if (isMissing(externalId, "updateUserJwt: externalId") || isMissing(token, "updateUserJwt: token")) { + return@withContext + } + + jwtTokenStore.putJwt(externalId, token) + operationRepo.forceExecuteOperations() + } + + override suspend fun logoutSuspend() = + withContext(ioDispatcher) { + Logging.log(LogLevel.DEBUG, "logoutSuspend()") + + suspendUntilInit(operationName = "logout") + + val context = logoutHelper.switchUser() ?: return@withContext + logoutHelper.enqueueLogout(context) + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/NeverCrashKillSwitchTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/NeverCrashKillSwitchTests.kt new file mode 100644 index 0000000000..a94209cc72 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/NeverCrashKillSwitchTests.kt @@ -0,0 +1,99 @@ +package com.onesignal.internal + +import android.content.Context +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.AndroidUtils +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.json.JSONArray + +@RobolectricTest +class NeverCrashKillSwitchTests : FunSpec({ + val configKey = PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE + + fun prefs() = getApplicationContext().getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + + // Written through the real model serializer, so a change to the stored format fails here. + fun cacheFlags(vararg keys: String) { + val config = ConfigModel().apply { sdkRemoteFeatureFlags = keys.toList() } + prefs().edit().putString(configKey, JSONArray().put(config.toJSON()).toString()).commit() + } + + beforeEach { + Logging.logLevel = LogLevel.NONE + prefs().edit().clear().commit() + NeverCrashKillSwitch.resetForTest() + } + + afterEach { NeverCrashKillSwitch.resetForTest() } + + test("the cached kill switch selects the legacy implementation") { + cacheFlags("other_flag", NeverCrashKillSwitch.FLAG_KEY) + + NeverCrashKillSwitch.evaluate(getApplicationContext()) + + NeverCrashKillSwitch.useLegacy shouldBe true + NeverCrashKillSwitch.create().shouldBeInstanceOf() + } + + test("the cached kill switch matches regardless of wire casing") { + cacheFlags(NeverCrashKillSwitch.FLAG_KEY.uppercase()) + + NeverCrashKillSwitch.evaluate(getApplicationContext()) + + NeverCrashKillSwitch.useLegacy shouldBe true + } + + test("without the kill switch the never-crash implementation is used") { + cacheFlags("other_flag") + + NeverCrashKillSwitch.evaluate(getApplicationContext()) + + NeverCrashKillSwitch.useLegacy shouldBe false + NeverCrashKillSwitch.create().shouldBeInstanceOf() + } + + test("a fresh install with nothing cached uses the never-crash implementation") { + NeverCrashKillSwitch.evaluate(getApplicationContext()) + + NeverCrashKillSwitch.useLegacy shouldBe false + } + + test("an unreadable cached config keeps the never-crash implementation without throwing") { + prefs().edit().putString(configKey, "{not json").commit() + + NeverCrashKillSwitch.evaluate(getApplicationContext()) + + NeverCrashKillSwitch.evaluated shouldBe true + NeverCrashKillSwitch.useLegacy shouldBe false + } + + test("a locked device keeps the never-crash implementation") { + cacheFlags(NeverCrashKillSwitch.FLAG_KEY) + mockkObject(AndroidUtils) + try { + every { AndroidUtils.isAndroidUserUnlocked(any()) } returns false + + NeverCrashKillSwitch.evaluate(getApplicationContext()) + + NeverCrashKillSwitch.useLegacy shouldBe false + } finally { + unmockkObject(AndroidUtils) + } + } + + test("first use before the switch is evaluated uses the never-crash implementation") { + NeverCrashKillSwitch.create().shouldBeInstanceOf() + } +})