diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 2feb49e8f0..a46e9d40e5 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -310,6 +310,7 @@ PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution + ReturnCount:AppIdResolutionV2.kt$internal fun resolveAppIdV2( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model? ReturnCount:ExecutorsIvExtensions.kt$internal fun resolveIvBackendParams( op: Operation, onesignalId: String, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, ): IvBackendParams @@ -327,6 +328,8 @@ ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int? ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long? ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T? + ReturnCount:OneSignalImp.kt$OneSignalImp$override suspend fun initWithContextSuspend( context: Context?, appId: String?, ): Boolean + ReturnCount:OneSignalImpV2.kt$OneSignalImpV2$override suspend fun initWithContextSuspend( context: Context?, appId: String?, ): Boolean ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean ReturnCount:OperationRepo.kt$OperationRepo$private fun shouldSuppressAnonymousOp(op: Operation): Boolean @@ -640,7 +643,5 @@ 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/IOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt index 1fbcd7837e..a7c86f2109 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -22,6 +22,9 @@ interface IOneSignal { /** * The user manager for accessing user-scoped * management. + * + * Before init succeeds this is a stand-in: listener and setting registrations are replayed once + * init succeeds, other calls are logged and ignored. */ val user: IUserManager @@ -79,13 +82,15 @@ interface IOneSignal { * Initialize the OneSignal SDK. This should be called during startup of the application. * * @param context The Android context the SDK should use. - * @param appId The application ID the OneSignal SDK is bound to. + * @param appId The application ID the OneSignal SDK is bound to. If null or invalid, the cached + * app ID is used; if none is cached, initialization is skipped and an error is logged. * - * @return true if the SDK could be successfully initialized, false otherwise. + * @return true once initialization has started (or is already running or done), false for a + * null [context] or an immediate startup failure. Check [isInitialized] for the outcome. */ fun initWithContext( - context: Context, - appId: String, + context: Context?, + appId: String?, ): Boolean /** @@ -165,42 +170,43 @@ interface IOneSignal { * Initialize the OneSignal SDK, suspend until initialization is completed * * @param context The Android context the SDK should use. - * @param appId The application ID the OneSignal SDK is bound to. + * @param appId The application ID the OneSignal SDK is bound to. If null or invalid, the cached + * app ID is used; if none is cached, initialization is skipped and an error is logged. * * @return true if the SDK could be successfully initialized, false otherwise. */ suspend fun initWithContextSuspend( - context: Context, + context: Context?, appId: String? = null, ): Boolean /** * Get the session manager without blocking the calling thread. - * Suspends until the SDK is initialized. + * Suspends while initialization is in progress; if the SDK is not initialized, returns a stand-in (see [user]). */ suspend fun getSession(): ISessionManager /** * Get the notifications manager without blocking the calling thread. - * Suspends until the SDK is initialized. + * Suspends while initialization is in progress; if the SDK is not initialized, returns a stand-in (see [user]). */ suspend fun getNotifications(): INotificationsManager /** * Get the location manager without blocking the calling thread. - * Suspends until the SDK is initialized. + * Suspends while initialization is in progress; if the SDK is not initialized, returns a stand-in (see [user]). */ suspend fun getLocation(): ILocationManager /** * Get the in-app messages manager without blocking the calling thread. - * Suspends until the SDK is initialized. + * Suspends while initialization is in progress; if the SDK is not initialized, returns a stand-in (see [user]). */ suspend fun getInAppMessages(): IInAppMessagesManager /** * Get the user manager without blocking the calling thread. - * Suspends until the SDK is initialized. + * Suspends while initialization is in progress; if the SDK is not initialized, returns a stand-in (see [user]). */ suspend fun getUser(): IUserManager @@ -266,8 +272,8 @@ interface IOneSignal { suspend fun logoutSuspend() /** - * Update the JWT bearer token associated with [externalId] (suspend version). Suspends - * until SDK initialization is complete, then stores the JWT and wakes the operation queue. + * Update the JWT bearer token associated with [externalId] (suspend version). Suspends while + * initialization is in progress, then stores the JWT; if the SDK is not initialized it logs instead. * * @param externalId The external ID the JWT belongs to. * @param token The new JWT bearer token issued by your backend. 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 f834e3cc3f..fb885b8add 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -37,41 +37,44 @@ object OneSignal { get() = oneSignal.sdkVersion /** - * The user manager for accessing user-scoped management. Initialized only after [initWithContext] - * has been called, and initialized with a device-scoped user until (or if) [login] has been - * called. + * The user manager for accessing user-scoped management, with a device-scoped user until (or + * if) [login] is called. Before [initWithContext] succeeds this is a stand-in: listener and setting registrations are + * replayed once init succeeds, other calls are logged and ignored. */ @JvmStatic val User: IUserManager get() = oneSignal.user /** - * The session manager for accessing session-scoped management. Initialized only after [initWithContext] - * has been called. + * The session manager for accessing session-scoped management. + * Before [initWithContext] succeeds this is a stand-in: outcome calls are logged and ignored. */ @JvmStatic val Session: ISessionManager get() = oneSignal.session /** - * The notification manager for accessing device-scoped notification management. Initialized - * only after [initWithContext] has been called. + * The notification manager for accessing device-scoped notification management. + * Before [initWithContext] succeeds this is a stand-in: listener and setting registrations are + * replayed once init succeeds, other calls are logged and ignored. */ @JvmStatic val Notifications: INotificationsManager get() = oneSignal.notifications /** - * The location manager for accessing device-scoped location management. Initialized - * only after [initWithContext] has been called. + * The location manager for accessing device-scoped location management. + * Before [initWithContext] succeeds this is a stand-in: listener and setting registrations are + * replayed once init succeeds, other calls are logged and ignored. */ @JvmStatic val Location: ILocationManager get() = oneSignal.location /** - * The In App Messaging manager for accessing device-scoped IAP management. Initialized - * only after [initWithContext] has been called. + * The In App Messaging manager for accessing device-scoped IAP management. + * Before [initWithContext] succeeds this is a stand-in: listener and setting registrations are + * replayed once init succeeds, other calls are logged and ignored. */ @JvmStatic val InAppMessages: IInAppMessagesManager @@ -125,12 +128,13 @@ object OneSignal { * Initialize the OneSignal SDK. This should be called during startup of the application. * * @param context The Android context the SDK should use. - * @param appId The application ID the OneSignal SDK is bound to. + * @param appId The application ID the OneSignal SDK is bound to. If null or invalid, the cached + * app ID is used; if none is cached, initialization is skipped and an error is logged. */ @JvmStatic fun initWithContext( - context: Context, - appId: String, + context: Context?, + appId: String?, ) { oneSignal.initWithContext(context, appId) } @@ -141,12 +145,13 @@ object OneSignal { * Uses Dispatchers.IO internally to prevent ANRs and optimize for I/O operations. * * @param context Application context is recommended for SDK operations - * @param appId The application ID the OneSignal SDK is bound to. + * @param appId The application ID the OneSignal SDK is bound to. If null or invalid, the cached + * app ID is used; if none is cached, initialization is skipped and an error is logged. * @return Boolean indicating if initialization was successful. */ @JvmStatic suspend fun initWithContextSuspend( - context: Context, + context: Context?, appId: String? = null, ): Boolean { return oneSignal.initWithContextSuspend(context, appId) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt index 250910ca79..c773a9df0b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt @@ -7,8 +7,16 @@ import com.onesignal.debug.internal.logging.Logging internal class StartupService( private val services: ServiceProvider, ) { + private val bootstrapped = mutableSetOf() + + // Retryable after a failure: services that already bootstrapped are skipped, so none subscribes twice. fun bootstrap() { - services.getAllServices().forEach { it.bootstrap() } + services.getAllServices().forEach { + if (it !in bootstrapped) { + it.bootstrap() + bootstrapped += it + } + } } // schedule to start all startable services using OneSignal dispatcher diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitGate.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitGate.kt new file mode 100644 index 0000000000..ae097e9b66 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitGate.kt @@ -0,0 +1,132 @@ +package com.onesignal.internal + +import com.onesignal.debug.internal.logging.Logging +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking + +/** + * Init state for [OneSignalImpV2] and the waits on it. Never throws: callers that need the SDK + * get `false` and a log line when init has not started or has failed. + */ +internal class InitGate { + private val lock = Any() + + // Replaced by every new attempt, so a retry after FAILED never reuses a completed signal. + @Volatile + private var completion = CompletableDeferred() + + @Volatile + var state: InitState = InitState.NOT_STARTED + private set + + // Created by the call that started the attempt, so its stack points at the app's init call. + @Volatile + var failure: Exception? = null + private set + + @Volatile + private var failureTraceLogged = false + + val isSuccess: Boolean get() = state == InitState.SUCCESS + + /** Starts a new attempt and returns true, or returns false if one is running or has succeeded. */ + fun tryStart(): Boolean = + synchronized(lock) { + if (state.isSDKAccessible()) return false + failure = IllegalStateException("OneSignal initWithContext failed.") + failureTraceLogged = false + state = InitState.IN_PROGRESS + completion = CompletableDeferred() + true + } + + fun addFailureCause(cause: Throwable) { + failure?.addSuppressed(cause) + } + + // State and signal change together, so a waiter can't wake up holding the next attempt's signal. + fun complete(terminal: InitState) { + require(terminal == InitState.SUCCESS || terminal == InitState.FAILED) { + "complete requires a terminal state, got $terminal" + } + synchronized(lock) { + state = terminal + completion.complete(Unit) + } + } + + /** Waits out in-progress attempts, including a retry that starts while waiting. True only on SUCCESS. */ + suspend fun await(operation: SdkOperation? = null): Boolean { + while (true) { + val (observed, signal) = synchronized(lock) { state to completion } + when (observed) { + InitState.SUCCESS -> return true + InitState.NOT_STARTED, InitState.FAILED -> return false + InitState.IN_PROGRESS -> awaitAttempt(signal, operation) + } + } + } + + /** Blocking form of [awaitReady]. On the main thread this blocks until init finishes. */ + fun isReady( + operation: SdkOperation, + member: String? = null, + log: Boolean = true, + ): Boolean { + if (isSuccess) return true + // Dispatcher-less: resumes on the caller's own event loop, never on a busy IO worker. + return runBlocking { awaitReady(operation, member, log) } + } + + suspend fun awaitReady( + operation: SdkOperation, + member: String? = null, + log: Boolean = true, + ): Boolean { + if (await(operation)) return true + if (log) logNotReady(target(operation, member)) + return false + } + + fun notReadyMessage(target: String): String = + if (state == InitState.FAILED) { + "'$target' ignored: initWithContext failed." + } else { + "'$target' ignored: call initWithContext with a valid appId first." + } + + fun logQueued( + operation: SdkOperation, + member: String, + ) { + Logging.warn("'${target(operation, member)}' queued until initWithContext succeeds.") + } + + private fun logNotReady(target: String) { + val message = notReadyMessage(target) + val trace = failure.takeIf { state == InitState.FAILED && !failureTraceLogged } + if (trace == null) { + Logging.error(message) + return + } + failureTraceLogged = true + Logging.error(message, trace) + } + + private suspend fun awaitAttempt( + signal: CompletableDeferred, + operation: SdkOperation?, + ) { + Logging.debug("Waiting for init to complete...") + val startTime = System.currentTimeMillis() + signal.await() + val elapsed = System.currentTimeMillis() - startTime + val before = operation?.let { " before '$it'" } ?: "" + Logging.debug("OneSignalImpV2 initialization completed$before (took ${elapsed}ms)") + } +} + +private fun target( + operation: SdkOperation, + member: String?, +) = member?.let { "$operation.$it" } ?: "$operation" 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 index 4de391be86..0481d6c4be 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/InitState.kt @@ -9,13 +9,13 @@ package com.onesignal.internal internal enum class InitState { /** * SDK initialization has not yet started. - * Calling SDK-dependent methods in this state will throw an exception. + * SDK-dependent calls log and return a default; they never throw. */ NOT_STARTED, /** * SDK initialization is currently in progress. - * Calls that require initialization will block (via a latch) until this completes. + * Calls that require initialization wait (block or suspend) until this completes. */ IN_PROGRESS, @@ -26,8 +26,8 @@ internal enum class InitState { 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. + * SDK initialization has failed (e.g., no usable app ID, locked storage). + * SDK-dependent calls log and return a default until a retry of initWithContext succeeds. */ FAILED, 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 7325c27971..6dcd267df1 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 @@ -344,10 +344,11 @@ internal class OneSignalImp : IOneSignal, */ @Suppress("ReturnCount", "TooGenericExceptionCaught") override fun initWithContext( - context: Context, - appId: String, + context: Context?, + appId: String?, ): Boolean { Logging.log(LogLevel.DEBUG, "Calling deprecated initWithContext(context: $context, appId: $appId)") + requireNotNull(context) { "initWithContext: context is required" } // Warm OneSignalDispatchers on a dedicated daemon thread so the first production caller // of suspendifyOnIO / launchOnSerialIO doesn't pay the ThreadPoolExecutor + dispatcher + @@ -753,10 +754,11 @@ internal class OneSignalImp : IOneSignal, } override suspend fun initWithContextSuspend( - context: Context, + context: Context?, appId: String?, ): Boolean { Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") + requireNotNull(context) { "initWithContext: context is required" } // Same warm-up as the synchronous variant. Reaching this entry point on the main thread // (e.g. SyncJobService.onStartJob -> suspendifyOnIO -> initWithContext(context)) pays the 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 index 182bbdff5a..af69ce9f87 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImpV2.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImpV2.kt @@ -48,36 +48,24 @@ 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.isUsableAppId 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.resolveAppIdV2 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 + internal val gate = InitGate() private var observabilityManager: IObservabilityLifecycleManager? = null override val sdkVersion: String = OneSignalUtils.sdkVersion override val isInitialized: Boolean - get() = initState == InitState.SUCCESS + get() = gate.isSuccess override var consentRequired: Boolean get() = @@ -87,9 +75,9 @@ internal class OneSignalImpV2 : IOneSignal, _consentRequired == true } set(value) { - _consentRequired = value - if (isInitialized) { - configModel.consentRequired = value + synchronized(configLock) { + _consentRequired = value + if (configApplied) configModel.consentRequired = value } } @@ -101,13 +89,15 @@ internal class OneSignalImpV2 : IOneSignal, _consentGiven == true } set(value) { - val oldValue = _consentGiven - _consentGiven = value - if (isInitialized) { - configModel.consentGiven = value - if (oldValue != value && value) { - operationRepo.forceExecuteOperations() + val grantedNow = + synchronized(configLock) { + val oldValue = _consentGiven + _consentGiven = value + if (configApplied) configModel.consentGiven = value + configApplied && oldValue != value && value } + if (grantedNow) { + operationRepo.forceExecuteOperations() } } @@ -119,34 +109,65 @@ internal class OneSignalImpV2 : IOneSignal, _disableGMSMissingPrompt == true } set(value) { - _disableGMSMissingPrompt = value - if (isInitialized) { - configModel.disableGMSMissingPrompt = value + synchronized(configLock) { + _disableGMSMissingPrompt = value + if (configApplied) configModel.disableGMSMissingPrompt = value } } // we hardcode the DebugManager implementation so it can be used prior to calling `initWithContext` override val debug: IDebugManager = DebugManager() + private val sessionSource = GatedSource(SdkOperation.SESSION, ISessionManager::class.java) + private val notificationsSource = GatedSource(SdkOperation.NOTIFICATIONS, INotificationsManager::class.java) + private val locationSource = GatedSource(SdkOperation.LOCATION, ILocationManager::class.java) + private val inAppMessagesSource = GatedSource(SdkOperation.IN_APP_MESSAGES, IInAppMessagesManager::class.java) + private val userSource = GatedSource(SdkOperation.USER, IUserManager::class.java) + + private val deferredSession = DeferredSessionManager(sessionSource) + private val deferredNotifications = DeferredNotificationsManager(notificationsSource) + private val deferredLocation = DeferredLocationManager(locationSource) + private val deferredInAppMessages = DeferredInAppMessagesManager(inAppMessagesSource) + private val deferredUser = DeferredUserManager(userSource) + private val deferredJwtListeners = DeferredJwtListeners(GatedSource(SdkOperation.JWT_LISTENERS, JwtTokenStore::class.java)) + override val session: ISessionManager - get() = - waitAndReturn { services.getService() } + get() = sessionSource.peek() ?: deferredSession override val notifications: INotificationsManager - get() = - waitAndReturn { services.getService() } + get() = notificationsSource.peek() ?: deferredNotifications override val location: ILocationManager - get() = - waitAndReturn { services.getService() } + get() = locationSource.peek() ?: deferredLocation override val inAppMessages: IInAppMessagesManager - get() = - waitAndReturn { services.getService() } + get() = inAppMessagesSource.peek() ?: deferredInAppMessages override val user: IUserManager - get() = - waitAndReturn { services.getService() } + get() = userSource.peek() ?: deferredUser + + private inner class GatedSource( + private val operation: SdkOperation, + private val type: Class, + ) : ManagerSource { + override fun get(member: String): T? = if (gate.isReady(operation, member)) services.getService(type) else null + + override fun peek(): T? = if (gate.isReady(operation, log = false)) services.getService(type) else null + + override suspend fun await(member: String): T? = if (gate.awaitReady(operation, member)) services.getService(type) else null + + override fun logQueued(member: String) = gate.logQueued(operation, member) + + suspend fun awaitQuietly(): T? = if (gate.awaitReady(operation, log = false)) services.getService(type) else null + } + + private fun replayDeferredCalls() { + deferredUser.replay(services.getService()) + deferredNotifications.replay(services.getService()) + deferredInAppMessages.replay(services.getService()) + deferredLocation.replay(services.getService()) + deferredJwtListeners.replay(services.getService()) + } // Services required by this class // WARNING: OperationRepo depends on OperationModelStore which in-turn depends @@ -185,14 +206,8 @@ internal class OneSignalImpV2 : IOneSignal, } }.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. + // Read per access: an eager val would build the IO pool on main before prewarm() and keep + // pointing at a dead pool after resetForTest() swaps generations. private val ioDispatcher: CoroutineDispatcher get() = OneSignalDispatchers.IO // get the current config model, if there is one @@ -200,7 +215,14 @@ internal class OneSignalImpV2 : IOneSignal, private var _consentRequired: Boolean? = null private var _consentGiven: Boolean? = null private var _disableGMSMissingPrompt: Boolean? = null - private val initLock: Any = Any() + + // Guards the pre-init fields above and [configApplied], so a setter racing updateConfig is never lost. + private val configLock: Any = Any() + private var configApplied = false + + // Set once services are bootstrapped; a retry after FAILED must not subscribe listeners twice. + private var startupService: StartupService? = null + private val bootstrapper = StartupService(services) private val loginLogoutLock: Any = Any() private val applicationServiceLock: Any = Any() @@ -252,9 +274,11 @@ internal class OneSignalImpV2 : IOneSignal, // 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() } + if (observabilityManager == null) { + observabilityManager = + LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) + .also { it.initializeFromCachedConfig() } + } logStartupDiagnostics(context) PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) @@ -309,85 +333,79 @@ internal class OneSignalImpV2 : IOneSignal, } } + // Copies values set before init into the model; setters write through from here on. 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!! + synchronized(configLock) { + _consentRequired?.let { configModel.consentRequired = it } + _consentGiven?.let { configModel.consentGiven = it } + _disableGMSMissingPrompt?.let { configModel.disableGMSMissingPrompt = it } + configApplied = true } } - private fun bootstrapServices(): StartupService { - val startupService = StartupService(services) - // bootstrap all services - startupService.bootstrap() - return startupService + private fun bootstrapOnce(context: Context): StartupService { + startupService?.let { return it } + + initEssentials(context) + val started = bootstrapper.also { it.bootstrap() } + startupService = started + + // 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") + } + return started } /** - * 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. + * Fire-and-forget: true once init is dispatched, running or done; false only for a null [context] + * or a synchronous start failure. Use [initWithContextSuspend] or [isInitialized] for the outcome. */ @Suppress("ReturnCount", "TooGenericExceptionCaught") override fun initWithContext( - context: Context, - appId: String, + context: Context?, + appId: String?, ): Boolean { Logging.log(LogLevel.DEBUG, "Calling deprecated initWithContext(context: $context, appId: $appId)") + if (context == null) { + Logging.error(MISSING_CONTEXT_MESSAGE) + return false + } + // 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() + if (!gate.tryStart()) { + Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") + return true } - // 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. + // The only step that can throw before dispatch (the Application cast can fail in restricted + // hosts). It must end in FAILED, or IN_PROGRESS is never retired and every accessor blocks + // forever. Failures inside [internalInit] are caught there. try { ensureApplicationServiceStarted(context) } catch (e: Exception) { - initFailureException?.addSuppressed(e) - completeInit(InitState.FAILED) - throw e + gate.addFailureCause(e) + gate.complete(InitState.FAILED) + Logging.error("initWithContext failed to start the application service", e) + return false } // Dispatch init asynchronously so this method never blocks the caller. Callers that - // need to wait (accessors, login, logout) will block via suspendCompletion. + // need to wait (accessors, login, logout) block on the gate. suspendifyOnIO { internalInit(context, appId) } @@ -402,11 +420,6 @@ internal class OneSignalImpV2 : IOneSignal, 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, @@ -418,60 +431,41 @@ internal class OneSignalImpV2 : IOneSignal, // 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) + gate.complete(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 startupService = bootstrapOnce(context) - val result = resolveAppId(appId, configModel, preferencesService) + val result = resolveAppIdV2(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) + val message = "initWithContext skipped: no valid appId was passed and none is cached. Pass your OneSignal app ID to initWithContext()." + gate.addFailureCause(IllegalStateException(message)) + // Null is also how background entry points (e.g. SyncJobService) start init, so it is not an app error. + if (appId == null) Logging.warn(message) else Logging.error(message) + gate.complete(InitState.FAILED) return false } + if (appId != null && !isUsableAppId(appId)) { + Logging.warn("initWithContext: appId is empty or contains a null byte, using the cached app ID instead.") + } configModel.appId = result.appId!! // safe because failed is false val forceCreateUser = result.forceCreateUser updateConfig() userSwitcher.initUser(forceCreateUser) + // Before start, so services never act on a setting the app already changed; before SUCCESS, + // so no direct call can overtake a queued one. Replayed calls must not go through the gate. + replayDeferredCalls() startupService.scheduleStart() - completeInit(InitState.SUCCESS) + gate.complete(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. + // Never rethrow: the sync initWithContext runs this via suspendifyOnIO, with no caller to + // catch it. Reaching FAILED unblocks every waiter; the cause is attached for the next log. Logging.error("OneSignal: internalInit threw unexpectedly; marking init FAILED", e) - initFailureException?.addSuppressed(e) - completeInit(InitState.FAILED) + gate.addFailureCause(e) + if (!gate.isSuccess) gate.complete(InitState.FAILED) return false } } @@ -482,9 +476,7 @@ internal class OneSignalImpV2 : IOneSignal, ) { Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") - waitForInit(operationName = "login") - - if (isMissing(externalId, "login: externalId")) return + if (!gate.isReady(SdkOperation.LOGIN) || isMissing(externalId, SdkOperation.LOGIN, EXTERNAL_ID_PARAM)) return val context = loginHelper.switchUser(externalId, jwtBearerToken).context ?: return @@ -494,7 +486,7 @@ internal class OneSignalImpV2 : IOneSignal, override fun logout() { Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") - waitForInit(operationName = "logout") + if (!gate.isReady(SdkOperation.LOGOUT)) return val context = logoutHelper.switchUser() ?: return @@ -507,24 +499,18 @@ internal class OneSignalImpV2 : IOneSignal, ) { Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: ...${token.takeLast(8)})") - waitForInit(operationName = "updateUserJwt") + if (!gate.isReady(SdkOperation.UPDATE_USER_JWT)) return - if (isMissing(externalId, "updateUserJwt: externalId") || isMissing(token, "updateUserJwt: token")) return + if (isMissing(externalId, SdkOperation.UPDATE_USER_JWT, EXTERNAL_ID_PARAM) || isMissing(token, SdkOperation.UPDATE_USER_JWT, TOKEN_PARAM)) 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 addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) = deferredJwtListeners.add(listener) - override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - waitForInit(operationName = "removeUserJwtInvalidatedListener") - jwtTokenStore.removeUserJwtInvalidatedListener(listener) - } + override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) = deferredJwtListeners.remove(listener) override fun hasService(c: Class): Boolean = services.hasService(c) @@ -534,138 +520,7 @@ internal class OneSignalImpV2 : IOneSignal, 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() - } - + // Callers only reach this after checking [isInitialized], and SUCCESS is terminal. private fun blockingGet(getter: () -> T): T { try { if (AndroidUtils.isRunningOnMainThread()) { @@ -676,136 +531,93 @@ internal class OneSignalImpV2 : IOneSignal, // 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) - } + return getter() } // =============================== // Suspend API Implementation // =============================== - override suspend fun getSession(): ISessionManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } + override suspend fun getSession(): ISessionManager = awaitManager(sessionSource) ?: deferredSession - override suspend fun getNotifications(): INotificationsManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } + override suspend fun getNotifications(): INotificationsManager = awaitManager(notificationsSource) ?: deferredNotifications - override suspend fun getLocation(): ILocationManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } + override suspend fun getLocation(): ILocationManager = awaitManager(locationSource) ?: deferredLocation - override suspend fun getInAppMessages(): IInAppMessagesManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } + override suspend fun getInAppMessages(): IInAppMessagesManager = awaitManager(inAppMessagesSource) ?: deferredInAppMessages - override suspend fun getUser(): IUserManager = - withContext(ioDispatcher) { - suspendAndReturn { services.getService() } - } + override suspend fun getUser(): IUserManager = awaitManager(userSource) ?: deferredUser - override suspend fun getConsentRequired(): Boolean = - withContext(ioDispatcher) { - configModel.consentRequired ?: (_consentRequired == true) - } + // Suspends, never blocks, through an in-progress init; the returned deferred manager logs on use. + private suspend fun awaitManager(source: GatedSource): T? = withContext(ioDispatcher) { source.awaitQuietly() } - override suspend fun setConsentRequired(required: Boolean) = - withContext(ioDispatcher) { - _consentRequired = required - configModel.consentRequired = required - } + // The properties hold the value locally until init applies it, so these are safe before init. + override suspend fun getConsentRequired(): Boolean = afterInit(SdkOperation.GET_CONSENT_REQUIRED) { consentRequired } - override suspend fun getConsentGiven(): Boolean = - withContext(ioDispatcher) { - configModel.consentGiven ?: (_consentGiven == true) - } + override suspend fun setConsentRequired(required: Boolean) = afterInit(SdkOperation.SET_CONSENT_REQUIRED) { consentRequired = required } - 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 getConsentGiven(): Boolean = afterInit(SdkOperation.GET_CONSENT_GIVEN) { consentGiven } - override suspend fun getDisableGMSMissingPrompt(): Boolean = - withContext(ioDispatcher) { - configModel.disableGMSMissingPrompt - } + override suspend fun setConsentGiven(value: Boolean) = afterInit(SdkOperation.SET_CONSENT_GIVEN) { consentGiven = value } - override suspend fun setDisableGMSMissingPrompt(value: Boolean) = + override suspend fun getDisableGMSMissingPrompt(): Boolean = afterInit(SdkOperation.GET_DISABLE_GMS_MISSING_PROMPT) { disableGMSMissingPrompt } + + override suspend fun setDisableGMSMissingPrompt(value: Boolean) = afterInit(SdkOperation.SET_DISABLE_GMS_MISSING_PROMPT) { disableGMSMissingPrompt = value } + + // Waits out an in-progress init so a value set now is not lost between updateConfig and SUCCESS. + private suspend fun afterInit( + operation: SdkOperation, + block: () -> T, + ): T = withContext(ioDispatcher) { - _disableGMSMissingPrompt = value - configModel.disableGMSMissingPrompt = value + gate.await(operation) + block() } override suspend fun initWithContextSuspend( - context: Context, + context: Context?, appId: String?, ): Boolean { Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") + if (context == null) { + Logging.error(MISSING_CONTEXT_MESSAGE) + return false + } + // 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) { + // Observing a terminal SUCCESS must never require an IO worker (see InitGate.isReady). + if (gate.isSuccess) { 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. + if (!gate.tryStart()) { + // Another caller started init. Suspend until it is fully done, not just kicked off, so + // re-entrant callers (e.g. SyncJobService) never use services whose bootstrap has not run. + // Awaited on the caller's own context so the wait never queues behind init's own work. 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 + return gate.await(SdkOperation.INIT_WITH_CONTEXT) } // 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) + var started = false + try { + return withContext(ioDispatcher) { + started = true + internalInit(context, appId) + } + } finally { + // A caller cancelled before dispatch would otherwise leave this attempt IN_PROGRESS forever. + if (!started) { + gate.addFailureCause(IllegalStateException("initWithContext was cancelled before init ran")) + gate.complete(InitState.FAILED) + } } } @@ -815,11 +627,9 @@ internal class OneSignalImpV2 : IOneSignal, ) = 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 (!gate.awaitReady(SdkOperation.LOGIN)) return@withContext - if (isMissing(externalId, "login: externalId")) return@withContext + if (isMissing(externalId, SdkOperation.LOGIN, EXTERNAL_ID_PARAM)) return@withContext val context = loginHelper.switchUser(externalId, jwtBearerToken).context ?: return@withContext loginHelper.enqueueLogin(context) @@ -838,9 +648,11 @@ internal class OneSignalImpV2 : IOneSignal, return@withContext OneSignalResult.failure(ErrorCode.INVALID_ARGUMENT, it) } - awaitInitForLogin()?.let { return@withContext it } + if (!gate.awaitReady(SdkOperation.LOGIN)) { + return@withContext OneSignalResult.failure(ErrorCode.NOT_INITIALIZED, gate.notReadyMessage("login")) + } - if (isMissing(externalId, "login: externalId") || externalId == null) { + if (isMissing(externalId, SdkOperation.LOGIN, EXTERNAL_ID_PARAM) || externalId == null) { return@withContext OneSignalResult.failure( ErrorCode.INVALID_ARGUMENT, "login: externalId is required", @@ -870,20 +682,6 @@ internal class OneSignalImpV2 : IOneSignal, 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." @@ -893,13 +691,9 @@ internal class OneSignalImpV2 : IOneSignal, ) = withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: ...${token.takeLast(8)})") - suspendUntilInit(operationName = "updateUserJwt") + if (!gate.awaitReady(SdkOperation.UPDATE_USER_JWT)) return@withContext - if (!isInitialized) { - throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'") - } - - if (isMissing(externalId, "updateUserJwt: externalId") || isMissing(token, "updateUserJwt: token")) { + if (isMissing(externalId, SdkOperation.UPDATE_USER_JWT, EXTERNAL_ID_PARAM) || isMissing(token, SdkOperation.UPDATE_USER_JWT, TOKEN_PARAM)) { return@withContext } @@ -911,9 +705,15 @@ internal class OneSignalImpV2 : IOneSignal, withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "logoutSuspend()") - suspendUntilInit(operationName = "logout") + if (!gate.awaitReady(SdkOperation.LOGOUT)) return@withContext val context = logoutHelper.switchUser() ?: return@withContext logoutHelper.enqueueLogout(context) } + + private companion object { + const val MISSING_CONTEXT_MESSAGE = "initWithContext skipped: context is required." + const val EXTERNAL_ID_PARAM = "externalId" + const val TOKEN_PARAM = "token" + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolutionV2.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolutionV2.kt new file mode 100644 index 0000000000..3ac339d1aa --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/AppIdResolutionV2.kt @@ -0,0 +1,37 @@ +package com.onesignal.user.internal + +import com.onesignal.common.hasNullByte +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores + +internal fun resolveAppIdV2( + inputAppId: String?, + configModel: ConfigModel, + preferencesService: IPreferencesService, +): AppIdResolution { + val validInputAppId = inputAppId?.takeIf { isUsableAppId(it) } + + // Case 1: AppId provided as input + if (validInputAppId != null) { + val forceCreateUser = !configModel.hasProperty(ConfigModel::appId.name) || configModel.appId != validInputAppId + return AppIdResolution(appId = validInputAppId, forceCreateUser = forceCreateUser, failed = false) + } + + // Case 2: No appId provided, but configModel has one. + // Cached values can be invalid on upgraded installs, so they are validated too. + if (configModel.hasProperty(ConfigModel::appId.name) && isUsableAppId(configModel.appId)) { + return AppIdResolution(appId = configModel.appId, forceCreateUser = false, failed = false) + } + + // Case 3: No appId provided, no configModel appId - try legacy + val legacyAppId = preferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + if (isUsableAppId(legacyAppId)) { + return AppIdResolution(appId = legacyAppId, forceCreateUser = true, failed = false) + } + + return AppIdResolution(appId = null, forceCreateUser = false, failed = true) +} + +internal fun isUsableAppId(appId: String?): Boolean = !appId.isNullOrEmpty() && !hasNullByte(appId) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt index 55d7b6eceb..ae3b424c26 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt @@ -54,56 +54,12 @@ class SDKInitSuspendTests : FunSpec({ } test("initWithContextSuspend with null appId fails when configModel has no appId") { - // Given - val context = getApplicationContext() - - // COMPLETE STATE RESET: Clear ALL SharedPreferences and wait for completion - val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - prefs.edit().clear().commit() - - // Clear any other potential SharedPreferences files - val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) - otherPrefs.edit().clear().commit() - - // Clear any other potential preference stores that might exist - try { - val allPrefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - allPrefs.edit().clear().commit() - } catch (e: Exception) { - // Ignore any errors during cleanup - } - - // Wait longer to ensure all cleanup operations are complete - Thread.sleep(100) - - // Verify cleanup worked - this should be empty - val verifyPrefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - val allKeys = verifyPrefs.all - if (allKeys.isNotEmpty()) { - println("WARNING: SharedPreferences still contains keys after cleanup: $allKeys") - // Force clear again - verifyPrefs.edit().clear().commit() - Thread.sleep(50) - } - - // Create a completely fresh OneSignalImp instance for this test + // Fresh prefs files: other tests' background inits write the shared store, which made this flaky. + val context = IsolatedPrefsContext(getApplicationContext()) val os = OneSignalImp() runBlocking { - // When - val result = os.initWithContextSuspend(context, null) - - // Debug output for CI/CD troubleshooting - println("DEBUG: initWithContextSuspend result = $result") - println("DEBUG: os.isInitialized = ${os.isInitialized}") - - // Additional debug: Check what's in SharedPreferences after the call - val debugPrefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) - val debugKeys = debugPrefs.all - println("DEBUG: SharedPreferences after initWithContextSuspend: $debugKeys") - - // Then - should return false because no appId is provided and configModel doesn't have an appId - result shouldBe false + os.initWithContextSuspend(context, null) shouldBe false os.isInitialized shouldBe false } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendV2Tests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendV2Tests.kt new file mode 100644 index 0000000000..3bd9d64a38 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendV2Tests.kt @@ -0,0 +1,253 @@ +package com.onesignal.core.internal.application + +import android.content.Context +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.ErrorCode +import com.onesignal.OneSignalUserProfile +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.internal.OneSignalImpV2 +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +@RobolectricTest +class SDKInitSuspendV2Tests : FunSpec({ + + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + afterAny { + val context = getApplicationContext() + + // AGGRESSIVE CLEANUP: Clear ALL SharedPreferences to ensure complete isolation + val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + + // Also clear any other potential SharedPreferences files + val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + + // Wait longer to ensure cleanup is complete + Thread.sleep(50) + } + + // ===== INITIALIZATION TESTS ===== + + test("initWithContextSuspend with appId returns true") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + runBlocking { + // When + val result = os.initWithContextSuspend(context, "testAppId") + + // Then + result shouldBe true + os.isInitialized shouldBe true + } + } + + test("initWithContextSuspend with null appId fails when configModel has no appId") { + // Fresh prefs files: other tests' background inits write the shared store, which made this flaky. + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + + runBlocking { + os.initWithContextSuspend(context, null) shouldBe false + os.isInitialized shouldBe false + } + } + + test("initWithContextSuspend is idempotent") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + runBlocking { + // When + val result1 = os.initWithContextSuspend(context, "testAppId") + val result2 = os.initWithContextSuspend(context, "testAppId") + val result3 = os.initWithContextSuspend(context, "testAppId") + + // Then + result1 shouldBe true + result2 shouldBe true + result3 shouldBe true + os.isInitialized shouldBe true + } + } + + // ===== LOGIN TESTS ===== + + test("login suspend method works after initWithContextSuspend") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + val testExternalId = "testUser123" + + runBlocking { + // When + val initResult = os.initWithContextSuspend(context, "testAppId") + initResult shouldBe true + + // Login with timeout - demonstrates suspend method works correctly + try { + withTimeout(2000) { // 2 second timeout + os.login(testExternalId) + } + // If we get here, login completed successfully (unlikely in test env) + os.isInitialized shouldBe true + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // Expected timeout due to operation queue processing in test environment + // This proves the suspend method is working correctly + os.isInitialized shouldBe true + println("Login suspend method works correctly - timed out as expected due to operation queue") + } + } + } + + // Note: Tests for null appId removed since appId is now non-nullable + + test("login suspend method with JWT token") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + val testExternalId = "testUser789" + val jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + + runBlocking { + // When + val initResult = os.initWithContextSuspend(context, "testAppId") + initResult shouldBe true + + try { + withTimeout(2000) { // 2 second timeout + os.login(testExternalId, jwtToken) + } + os.isInitialized shouldBe true + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // Expected timeout due to operation queue processing + os.isInitialized shouldBe true + println("Login with JWT suspend method works correctly - timed out as expected due to operation queue") + } + } + } + + // ===== LOGOUT TESTS ===== + + test("logout suspend method works after initWithContextSuspend") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + runBlocking { + // When + val initResult = os.initWithContextSuspend(context, "testAppId") + initResult shouldBe true + + // Logout with timeout - demonstrates suspend method works correctly + try { + withTimeout(2000) { // 2 second timeout + os.logout() + } + // If we get here, logout completed successfully (unlikely in test env) + os.isInitialized shouldBe true + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // Expected timeout due to operation queue processing in test environment + // This proves the suspend method is working correctly + os.isInitialized shouldBe true + println("Logout suspend method works correctly - timed out as expected due to operation queue") + } + } + } + + // ===== INTEGRATION TESTS ===== + + test("multiple login calls work correctly") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + runBlocking { + // When + val initResult = os.initWithContextSuspend(context, "testAppId") + initResult shouldBe true + + try { + withTimeout(3000) { // 3 second timeout for multiple operations + os.login("user1") + os.login("user2") + os.login("user3") + } + os.isInitialized shouldBe true + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // Expected timeout due to operation queue processing + os.isInitialized shouldBe true + println("Multiple login calls suspend method works correctly - timed out as expected due to operation queue") + } + } + } + + test("login and logout sequence works correctly") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + runBlocking { + // When + val initResult = os.initWithContextSuspend(context, "testAppId") + initResult shouldBe true + + try { + withTimeout(3000) { // 3 second timeout for sequence + os.login("user1") + os.logout() + os.login("user2") + } + os.isInitialized shouldBe true + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // Expected timeout due to operation queue processing + os.isInitialized shouldBe true + println("Login/logout sequence suspend methods work correctly - timed out as expected due to operation queue") + } + } + } + + test("suspend login, logout and updateUserJwt are ignored without throwing when initWithContext is never called") { + val oneSignalImp = OneSignalImpV2() + + runBlocking { + oneSignalImp.loginSuspend("testUser", null) + oneSignalImp.logoutSuspend() + oneSignalImp.updateUserJwtSuspend("testUser", "token") + } + + oneSignalImp.isInitialized shouldBe false + } + + test("composite login returns NOT_INITIALIZED when initWithContext is never called") { + val oneSignalImp = OneSignalImpV2() + + val result = runBlocking { oneSignalImp.login("testUser", OneSignalUserProfile()) } + + result.data shouldBe null + result.error!!.first.code shouldBe ErrorCode.NOT_INITIALIZED + } + + test("suspend consent accessors work before initWithContext") { + val oneSignalImp = OneSignalImpV2() + + runBlocking { + oneSignalImp.setConsentRequired(true) + oneSignalImp.setConsentGiven(true) + oneSignalImp.getConsentRequired() shouldBe true + oneSignalImp.getConsentGiven() shouldBe true + oneSignalImp.getDisableGMSMissingPrompt() shouldBe false + } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitV2Tests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitV2Tests.kt new file mode 100644 index 0000000000..0003062def --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitV2Tests.kt @@ -0,0 +1,1075 @@ +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.IUserJwtInvalidatedListener +import com.onesignal.common.AndroidUtils +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.operations.impl.OperationRepo +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID +import com.onesignal.debug.ILogListener +import com.onesignal.debug.LogLevel +import com.onesignal.debug.OneSignalLogEvent +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.internal.InitGate +import com.onesignal.internal.InitState +import com.onesignal.internal.OneSignalImpV2 +import com.onesignal.user.internal.UserManager +import com.onesignal.user.internal.jwt.JwtTokenStore +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.maps.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@RobolectricTest +class SDKInitV2Tests : FunSpec({ + + /** + * Helper function to wait for OneSignal initialization to complete. + * @param oneSignalImp The OneSignalImpV2 instance to wait for + * @param maxAttempts Maximum number of attempts (default: 100) + * @param sleepMs Sleep duration between attempts in milliseconds (default: 20) + */ + fun waitForInitialization(oneSignalImp: OneSignalImpV2, maxAttempts: Int = 100, sleepMs: Long = 20) { + var attempts = 0 + while (!oneSignalImp.isInitialized && attempts < maxAttempts) { + Thread.sleep(sleepMs) + attempts++ + } + oneSignalImp.isInitialized shouldBe true + } + + fun withSaturatedOneSignalIo(block: () -> Unit) { + val running = CountDownLatch(2) + val release = CountDownLatch(1) + repeat(4) { + OneSignalDispatchers.launchOnIO { + running.countDown() + release.await(30, TimeUnit.SECONDS) + } + } + + try { + running.await(5, TimeUnit.SECONDS) shouldBe true + block() + } finally { + release.countDown() + } + } + + fun runOnThreadAndWait(block: () -> Unit) { + val error = AtomicReference(null) + val thread = + Thread { + try { + block() + } catch (t: Throwable) { + error.set(t) + } + } + // Daemon so a stuck waitForInit() (e.g. if a dispatch is rejected under pool pressure) + // can never keep the test JVM alive and trip the 6h CI timeout. + thread.isDaemon = true + thread.start() + thread.join(2_000) + + thread.isAlive shouldBe false + error.get() shouldBe null + } + + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + afterAny { + val context = getApplicationContext() + val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) + prefs.edit() + .clear() + .commit() + + // Also clear any other potential SharedPreferences files + val otherPrefs = context.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit() + .clear() + .commit() + } + + test("accessor after initWithContext without appID returns a default instead of throwing") { + // Given + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + + // When + os.initWithContext(context) + + // Then + os.user.onesignalId shouldBe "" + os.isInitialized shouldBe false + + // A later initWithContext with an appID still initializes + val result = os.initWithContext(context, "appID") + waitForInitialization(os) + result shouldBe true + os.isInitialized shouldBe true + } + + test("OneSignal accessors return defaults before calling initWithContext") { + val os = OneSignalImpV2() + + os.user.onesignalId shouldBe "" + os.user.externalId shouldBe "" + os.user.getTags() shouldBe emptyMap() + os.user.pushSubscription.id shouldBe "" + os.user.pushSubscription.optedIn shouldBe false + os.user.addTag("key", "value") + os.notifications.permission shouldBe false + runBlocking { os.notifications.requestPermission(true) } shouldBe false + os.inAppMessages.paused shouldBe false + os.inAppMessages.addTrigger("key", "value") + os.session.addOutcome("outcome") + os.location.isShared shouldBe false + os.isInitialized shouldBe false + } + + test("accessor references captured before initWithContext forward once init succeeds") { + val os = OneSignalImpV2() + val user = os.user + + os.initWithContext(IsolatedPrefsContext(getApplicationContext()), "appId") + waitForInitialization(os) + + user.addTag("key", "value") + os.user.getTags() shouldContain ("key" to "value") + user.getTags() shouldContain ("key" to "value") + } + + test("accessor references captured after a failed init forward once a retry succeeds") { + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + os.initWithContext(context, "") + val user = os.user + os.isInitialized shouldBe false + + os.initWithContext(context, "appId") + waitForInitialization(os) + + user.addTag("key", "value") + os.user.getTags() shouldContain ("key" to "value") + user.getTags() shouldContain ("key" to "value") + } + + test("a retry after a failed init does not enqueue duplicate operations") { + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + os.initWithContext(context, "") + os.user + os.isInitialized shouldBe false + + os.initWithContext(context, "appId") + waitForInitialization(os) + os.user.addTag("retryKey", "value") + + val repo = os.getService(IOperationRepo::class.java) as OperationRepo + fun tagOps() = synchronized(repo.queue) { repo.queue.count { it.operation.toString().contains("retryKey") } } + repeat(50) { if (tagOps() == 0) Thread.sleep(20) } + Thread.sleep(200) + tagOps() shouldBe 1 + } + + test("observers and listeners registered before init are applied once init succeeds") { + val os = OneSignalImpV2() + os.user.addObserver(mockk(relaxed = true)) + // Core-only test: the in-app messages module is absent, so replaying this throws and must not break init. + os.inAppMessages.addClickListener(mockk(relaxed = true)) + + os.initWithContext(IsolatedPrefsContext(getApplicationContext()), "appId") + waitForInitialization(os) + + (os.user as UserManager).changeHandlersNotifier.hasSubscribers shouldBe true + os.isInitialized shouldBe true + } + + test("pre-init registrations are replayed before init reports success") { + val os = OneSignalImpV2() + var stateAtReplay: InitState? = null + // The in-app messages module is absent here, so its replayed call logs synchronously on the init thread. + val listener = ILogListener { if (it.entry.contains("Replaying 'addClickListener'")) stateAtReplay = os.gate.state } + Logging.addListener(listener) + try { + os.inAppMessages.addClickListener(mockk(relaxed = true)) + os.initWithContext(IsolatedPrefsContext(getApplicationContext()), "appId") + waitForInitialization(os) + } finally { + Logging.removeListener(listener) + } + + stateAtReplay shouldBe InitState.IN_PROGRESS + } + + test("a JWT invalidated listener added before init is replayed, and a removal before init cancels it") { + val os = OneSignalImpV2() + val fired = CountDownLatch(1) + val removedFired = CountDownLatch(1) + val removed = IUserJwtInvalidatedListener { removedFired.countDown() } + os.addUserJwtInvalidatedListener { fired.countDown() } + os.addUserJwtInvalidatedListener(removed) + os.removeUserJwtInvalidatedListener(removed) + + os.initWithContext(IsolatedPrefsContext(getApplicationContext()), "appId") + waitForInitialization(os) + val store = os.getService(JwtTokenStore::class.java) + store.putJwt("ext", "token") + store.invalidateJwt("ext") + + fired.await(2, TimeUnit.SECONDS) shouldBe true + removedFired.await(200, TimeUnit.MILLISECONDS) shouldBe false + } + + test("an empty appId that falls back to a cached appId logs a warning, not an error") { + val context = IsolatedPrefsContext(getApplicationContext()) + context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE).edit().putString(PREFS_LEGACY_APP_ID, "cachedAppId").commit() + val events = CopyOnWriteArrayList() + val listener = ILogListener { events += it } + Logging.addListener(listener) + val os = OneSignalImpV2() + try { + os.initWithContext(context, "") + waitForInitialization(os) + } finally { + Logging.removeListener(listener) + } + + os.isInitialized shouldBe true + events.filter { it.level == LogLevel.ERROR && it.entry.contains("appId") } shouldBe emptyList() + events.count { it.level == LogLevel.WARN && it.entry.contains("using the cached app ID") } shouldBe 1 + } + + test("a call before init logs one error naming the call, with the failure trace once per attempt") { + val events = CopyOnWriteArrayList() + val listener = ILogListener { events += it } + Logging.addListener(listener) + try { + val os = OneSignalImpV2() + os.user.addTag("key", "value") + events.filter { it.level == LogLevel.ERROR }.map { it.entry.substringAfter("] ") } shouldBe + listOf("'User.addTag' ignored: call initWithContext with a valid appId first.") + + os.initWithContext(IsolatedPrefsContext(getApplicationContext()), "") + os.user + events.clear() + os.user.addTag("key", "value") + os.user.addTag("key", "value") + + val errors = events.filter { it.level == LogLevel.ERROR && it.entry.contains("'User.addTag'") } + errors.size shouldBe 2 + errors.count { it.entry.contains("OneSignal initWithContext failed.") } shouldBe 1 + } finally { + Logging.removeListener(listener) + } + } + + test("a consent change made after init applied the stored values is not lost") { + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + os.consentGiven = false + os.initWithContext(context, "appId") + waitForInitialization(os) + + // Re-enter the window between updateConfig and SUCCESS, where the setter used to write only the local copy. + val state = InitGate::class.java.getDeclaredField("state").apply { isAccessible = true } + val repo = os.getService(IOperationRepo::class.java) as OperationRepo + val forced = OperationRepo::class.java.getDeclaredField("forcedDuringExecute").apply { isAccessible = true } + synchronized(repo.queue) { + repo.queue.clear() + forced.set(repo, false) + } + state.set(os.gate, InitState.IN_PROGRESS) + os.consentGiven = true + state.set(os.gate, InitState.SUCCESS) + + os.consentGiven shouldBe true + forced.get(repo) shouldBe true + } + + test("cancelling initWithContextSuspend before init runs does not leave init stuck in progress") { + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + + runBlocking { + launch { + cancel() + os.initWithContextSuspend(context, "appId") + }.join() + } + + os.gate.state shouldBe InitState.FAILED + os.initWithContext(context, "appId") shouldBe true + waitForInitialization(os) + } + + test("initWithContext with a null context returns false without throwing") { + val os = OneSignalImpV2() + + os.initWithContext(null, "appId") shouldBe false + runBlocking { os.initWithContextSuspend(null, "appId") } shouldBe false + os.isInitialized shouldBe false + } + + test("initWithContext returns gracefully when Android user is locked") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + mockkObject(AndroidUtils) + every { AndroidUtils.isAndroidUserUnlocked(any()) } returns false + + // When + os.initWithContext(context, "appId") + + // Then + // Init runs asynchronously and fails the locked-storage check, transitioning to FAILED. + // Reading an accessor blocks until that terminal state, then returns a default. + os.user.onesignalId shouldBe "" + os.login("user") + os.isInitialized shouldBe false + + unmockkObject(AndroidUtils) + } + + test("initWithContext is successful when Android user is unlocked") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + mockkObject(AndroidUtils) + every { AndroidUtils.isAndroidUserUnlocked(any()) } returns true + + // When + os.initWithContext(context, "appId") + + // Then + waitForInitialization(os) + os.isInitialized shouldBe true + + unmockkObject(AndroidUtils) + } + + test("initWithContext with no appId succeeds when configModel has appId") { + // Given + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + val prefs = context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE) + + // Set up a legacy appId in SharedPreferences to simulate a previous test scenario + // This simulates the case where a previous test has set an appId that can be resolved + prefs.edit() + .putString(PREFS_LEGACY_APP_ID, "testAppId") // Set legacy appId + .commit() + + // When - no appId passed; resolved from the legacy SharedPreferences value. + os.initWithContext(context) + + // Then - init completes successfully because an appId was resolvable. + waitForInitialization(os) + os.isInitialized shouldBe true + } + + test("initWithContext with an empty or null appId and nothing cached skips init without throwing") { + for (appId in listOf("", null)) { + // Fresh stores: buffered prefs writes from earlier tests can land after afterAny clears. + val context = IsolatedPrefsContext(getApplicationContext()) + val os = OneSignalImpV2() + os.initWithContext(context, appId) + + // Blocks until init reaches its terminal state, then returns a default. + os.user.onesignalId shouldBe "" + os.login("user") + os.isInitialized shouldBe false + } + } + + test("initWithContext with an empty appId falls back to the cached appId") { + val context = IsolatedPrefsContext(getApplicationContext()) + context.getSharedPreferences("OneSignal", Context.MODE_PRIVATE).edit() + .putString(PREFS_LEGACY_APP_ID, "cachedAppId") + .commit() + val os = OneSignalImpV2() + + os.initWithContext(context, "") + + waitForInitialization(os) + os.isInitialized shouldBe true + } + + test("initWithContext returns immediately and completes init asynchronously") { + // Background-threaded init is the default: initWithContext dispatches internalInit to a + // background dispatcher and returns immediately, even while internalInit is parked on a + // slow SharedPreferences read. isInitialized only flips to true once init completes. + val trigger = CountDownLatch(1) + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger) + val os = OneSignalImpV2() + + // Returns right away despite the blocked prefs read happening on the background worker. + os.initWithContext(blockingPrefContext, "appId") shouldBe true + os.isInitialized shouldBe false + + trigger.countDown() + waitForInitialization(os) + 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 = CountDownLatch(1) + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger) + val os = OneSignalImpV2() + + val accessorThread = + Thread { + os.initWithContext(blockingPrefContext, "appId") + os.user // This should block until either trigger is released or timed out + } + + // Daemon so a stuck waitForInit() can never keep the test JVM alive. + accessorThread.isDaemon = true + accessorThread.start() + accessorThread.join(500) + + accessorThread.isAlive shouldBe true + + // release the lock on SharedPreferences + trigger.countDown() + + 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 = OneSignalImpV2() + 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 = CountDownLatch(1) + val context = getApplicationContext() + val blockingPrefContext = BlockingPrefsContext(context, trigger) + val os = OneSignalImpV2() + val externalId = "testUser" + + val accessorThread = + Thread { + os.initWithContext(blockingPrefContext, "appId") + os.login(externalId) + + // Wait for background login operation to complete with polling + var attempts = 0 + while (os.user.externalId != externalId && attempts < 50) { + Thread.sleep(20) + attempts++ + } + } + + // Daemon so a stuck waitForInit() can never keep the test JVM alive. + accessorThread.isDaemon = true + accessorThread.start() + accessorThread.join(500) + + // initWithContext should return immediately (non-blocking) + // but isInitialized won't be true until internalInit completes + // which requires SharedPreferences to be unblocked + accessorThread.isAlive shouldBe true + + // release the lock on SharedPreferences so internalInit can complete + trigger.countDown() + + // Wait for initialization to complete (internalInit runs asynchronously) + var initAttempts = 0 + while (!os.isInitialized && initAttempts < 50) { + Thread.sleep(20) + initAttempts++ + } + + os.isInitialized shouldBe true + + 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 = OneSignalImpV2() + os.initWithContext(context, "appId") + + // When + val pushSub = os.user.pushSubscription + + // Then + pushSub shouldNotBe null + pushSub.token shouldNotBe null + } + + test("login changes externalId from initial state after init") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + val testExternalId = "uniqueTestUser_${System.currentTimeMillis()}" // Use unique ID to avoid conflicts + + // When + os.initWithContext(context, "appId") + val initialExternalId = os.user.externalId + os.login(testExternalId) + + // Wait for background login operation to complete with polling + var attempts = 0 + while (os.user.externalId != testExternalId && attempts < 50) { + Thread.sleep(20) + attempts++ + } + + val finalExternalId = os.user.externalId + + // Then - Verify the complete login flow + // 1. Login should set the external ID to our test value + finalExternalId shouldBe testExternalId + + // 2. Login should change the external ID (regardless of initial state) + // This makes the test resilient to state contamination while still testing the flow + finalExternalId shouldNotBe initialExternalId + + // 3. If we're in a clean state, initial should be empty (but don't fail if not) + // This documents the expected behavior without making the test brittle + if (initialExternalId.isEmpty()) { + // Clean state detected - this is the ideal scenario + println("✅ Clean state: initial externalId was empty as expected") + } else { + // State contamination detected - log it but don't fail + println("⚠️ State contamination: initial externalId was '$initialExternalId' (expected empty)") + } + + // Clean up after ourselves to avoid polluting subsequent tests + os.logout() + + // Wait for logout to complete with polling + var logoutAttempts = 0 + while (os.user.externalId.isNotEmpty() && logoutAttempts < 50) { + Thread.sleep(20) + logoutAttempts++ + } + } + + test("accessor instances after multiple initWithContext calls are consistent") { + // Given + val context = getApplicationContext() + val os = OneSignalImpV2() + + // When + os.initWithContext(context, "appId") + + // Wait for initialization to complete before accessing user + waitForInitialization(os) + + // Give additional time for coroutines to settle, especially in CI/CD + Thread.sleep(50) + + val oldUser = os.user + + // Second init from some internal class + os.initWithContext(context) + + // Wait for second initialization to complete + waitForInitialization(os) + + // Give additional time for coroutines to settle after second init + Thread.sleep(50) + + val newUser = os.user + + // Then + oldUser shouldBe newUser + } + + test("integration: full user workflow after initialization") { + val context = getApplicationContext() + val os = OneSignalImpV2() + 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 + + // Handle state contamination gracefully - if externalId is not empty, logout first + if (initialExternalId.isNotEmpty()) { + println("⚠️ State contamination detected: initial externalId was '$initialExternalId' (expected empty)") + os.logout() + + // Wait for logout to complete with polling + var cleanupAttempts = 0 + while (os.user.externalId.isNotEmpty() && cleanupAttempts < 50) { + Thread.sleep(20) + cleanupAttempts++ + } + + val cleanedExternalId = os.user.externalId + cleanedExternalId shouldBe "" + } else { + initialExternalId shouldBe "" + } + + // login + os.login(testExternalId) + + // Wait for background login operation to complete with polling (CI-safe) + run { + var attempts = 0 + val maxAttempts = 200 // 4 seconds total at 20ms intervals + while (os.user.externalId != testExternalId && attempts < maxAttempts) { + Thread.sleep(20) + attempts++ + } + } + + 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() + + // Wait for background logout operation to complete with polling + var attempts = 0 + while (os.user.externalId.isNotEmpty() && attempts < 50) { + Thread.sleep(20) + attempts++ + } + + os.user.externalId shouldBe "" + } + + test("initWithContext recovers when ensureApplicationServiceStarted throws synchronously") { + // Given: a context whose applicationContext is NOT an Application instance. + // ApplicationService.start does `context.applicationContext as Application`, which + // will throw ClassCastException — simulates restricted hosts (Robolectric custom + // application factories, instrumentation context wrappers, multi-process content + // provider init order). Without recovery, initState would be left at IN_PROGRESS + // forever and accessors would deadlock on suspendCompletion.await(). + val baseContext = getApplicationContext() + val brokenContext = + object : ContextWrapper(baseContext) { + override fun getApplicationContext(): Context = ContextWrapper(baseContext) + } + val os = OneSignalImpV2() + + // When: synchronous failure during the bootstrap step. + os.initWithContext(brokenContext, "appId") shouldBe false + + // Then: SDK transitions to FAILED, and the accessor returns a default. + os.user.onesignalId shouldBe "" + os.isInitialized shouldBe false + + // And: a retry with a working context succeeds (FAILED is not "accessible", + // so the retry legitimately re-enters init). + os.initWithContext(baseContext, "appId") shouldBe true + waitForInitialization(os) + } + + test("accessor returns once init is SUCCESS even when the IO dispatcher is saturated (OneSignal-Flutter-SDK#1163)") { + // Regression test for the Flutter main-thread ANR. getServiceWithFeatureGate used to + // ALWAYS route through waitAndReturn -> waitForInit -> runBlocking(OneSignalDispatchers.IO), + // even after init had already reached SUCCESS. On Flutter's main-thread lifecycleInit that + // parks the UI thread on a cold-start-contended IO pool (OneSignal-Flutter-SDK#1163). The + // fix adds a lock-free SUCCESS fast-path, centralized in waitForInit(). + // + // We reproduce the contention faithfully: saturate OneSignalDispatchers.IO so that the + // pre-fix runBlocking(OneSignalDispatchers.IO) would park indefinitely behind the busy + // workers, then assert the accessor still returns. With the fix it answers synchronously; + // without it the accessor thread would still be parked when we check. + val context = getApplicationContext() + val os = OneSignalImpV2() + + os.initWithContext(context, "appId") + waitForInitialization(os) + + withSaturatedOneSignalIo { + // When: a SUCCESS-state accessor is read while the IO pool is fully saturated. + val result = arrayOfNulls(1) + runOnThreadAndWait { result[0] = os.user } + + // Then: the fast-path answered synchronously and the thread finished. The pre-fix + // runBlocking(OneSignalDispatchers.IO) path would still be parked here. + result[0] shouldNotBe null + } + } + + test("login returns once init is SUCCESS even when the IO dispatcher is saturated (OneSignal-Flutter-SDK#1163)") { + // Companion regression test proving the SUCCESS fast-path is centralized in waitForInit(), + // so it covers the operation paths (login / logout / updateUserJwt / JWT listeners), not + // just the service accessors. login() used to route through + // waitForInit -> runBlocking(OneSignalDispatchers.IO) even after init reached SUCCESS, so a + // saturated IO pool would park the caller indefinitely. + val context = getApplicationContext() + val os = OneSignalImpV2() + + os.initWithContext(context, "appId") + waitForInitialization(os) + + withSaturatedOneSignalIo { + // When: login() is invoked while the IO pool is fully saturated. waitForInit must + // short-circuit (the subsequent enqueue is fire-and-forget via suspendifyOnIO and does + // not block the caller). + runOnThreadAndWait { os.login("externalId") } + + // Then: the fast-path answered synchronously and the call returned. The pre-fix + // runBlocking(OneSignalDispatchers.IO) path would still be parked here. + } + } + + test("consent getters return once init is SUCCESS even when the IO dispatcher is saturated") { + val context = getApplicationContext() + val os = OneSignalImpV2() + + os.initWithContext(context, "appId") + waitForInitialization(os) + + withSaturatedOneSignalIo { + val results = arrayOfNulls(3) + runOnThreadAndWait { + results[0] = os.consentRequired + results[1] = os.consentGiven + results[2] = os.disableGMSMissingPrompt + } + + results.toList() shouldBe listOf(false, false, false) + } + } + + test("initWithContextSuspend returns once init is SUCCESS even when the IO dispatcher is saturated") { + // Observing a terminal SUCCESS must not require an IO worker. + val context = getApplicationContext() + val os = OneSignalImpV2() + + os.initWithContext(context, "appId") + waitForInitialization(os) + + withSaturatedOneSignalIo { + val result = arrayOfNulls(1) + runOnThreadAndWait { result[0] = runBlocking { os.initWithContextSuspend(context, "appId") } } + + result[0] shouldBe true + } + } + + test("initWithContextSuspend awaiting an in-flight init does not need an IO worker") { + // Waiting on another caller's init must not queue behind the work that init is doing, + // otherwise a caller arriving while init is IN_PROGRESS stalls until the pool drains. + val context = getApplicationContext() + val os = OneSignalImpV2() + + val started = CountDownLatch(1) + val trigger = CountDownLatch(1) + val blockingCtx = + object : ContextWrapper(context) { + override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + started.countDown() + trigger.await(30, TimeUnit.SECONDS) + return super.getSharedPreferences(name, mode) + } + } + + // Hold init at IN_PROGRESS, then fill the pool so no worker is left for a second caller. + os.initWithContext(blockingCtx, "appId") + started.await(5, TimeUnit.SECONDS) shouldBe true + os.isInitialized shouldBe false + + // The stalled init already holds one worker, so one more blocking task fills the pool + // and the rest back up the queue. + val release = CountDownLatch(1) + val occupied = CountDownLatch(1) + repeat(4) { + OneSignalDispatchers.launchOnIO { + occupied.countDown() + release.await(30, TimeUnit.SECONDS) + } + } + + try { + occupied.await(5, TimeUnit.SECONDS) shouldBe true + + val startedWaiting = CountDownLatch(1) + val waiter = + Thread { + runBlocking { + startedWaiting.countDown() + os.initWithContextSuspend(context, "appId") + } + } + waiter.isDaemon = true + waiter.start() + + startedWaiting.await(5, TimeUnit.SECONDS) shouldBe true + Thread.sleep(200) + // Still blocked, because the init it is waiting on has not been released yet. + waiter.isAlive shouldBe true + + trigger.countDown() + + // Resuming is driven by the latch, not by a free worker, so this completes while + // the queued tasks above still own the pool. + waiter.join(5_000) + waiter.isAlive shouldBe false + } finally { + trigger.countDown() + release.countDown() + } + + os.isInitialized shouldBe true + } + + test("login and logout are ignored without throwing when initWithContext is never called") { + val oneSignalImp = OneSignalImpV2() + + oneSignalImp.login("testUser", null) + oneSignalImp.logout() + oneSignalImp.updateUserJwt("testUser", "token") + + oneSignalImp.isInitialized shouldBe false + } + + // Regression test for the SessionService NPE during in-flight background init. + // + // Background: when init is in flight, internalInit() has not yet called bootstrapServices(), + // which means IBootstrapService implementations like SessionService have null model fields. + // SyncJobService re-enters via initWithContextSuspend(context, null) and then immediately + // calls runBackgroundServices(); if initWithContextSuspend returns true while init is still + // in progress (the old behavior), the loop hits SessionService.endSession() → NPE. + // + // The fix: initWithContextSuspend must suspend until init is *fully* completed, not just + // kicked off. This test verifies that contract by checking that os.isInitialized is true + // at the moment the re-entrant suspend call returns. + test("initWithContextSuspend with in-flight init waits for completion before returning") { + val context = getApplicationContext() + val trigger = CountDownLatch(1) + // started signals when the first caller has entered internalInit() and reached the + // blocking SharedPreferences access -- by which point initState is IN_PROGRESS. + val started = CountDownLatch(1) + val blockingCtx = object : ContextWrapper(context) { + override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + started.countDown() + // Bounded await -- if the test logic ever fails to release `trigger`, the + // IO worker exits cleanly instead of blocking forever and deadlocking the suite. + trigger.await(30, TimeUnit.SECONDS) + return super.getSharedPreferences(name, mode) + } + } + val os = OneSignalImpV2() + + runBlocking { + // First caller: stalls inside internalInit() once it tries to read prefs. + val firstInit = async(Dispatchers.IO) { + os.initWithContextSuspend(blockingCtx, "appId") + } + + // Deterministically wait until firstInit has entered the prefs-blocking region. + // After this point, initState is guaranteed to be IN_PROGRESS. + started.await(5, TimeUnit.SECONDS) shouldBe true + os.isInitialized shouldBe false + firstInit.isCompleted shouldBe false + + // Second caller: under the OLD behavior the synchronized(initLock) block sees + // state == IN_PROGRESS and returns `true` *while* internalInit() (running on the + // first caller) has not yet reached `initState = InitState.SUCCESS`. Under the + // fix, this call must suspend until the first caller fully completes init -- so + // that os.isInitialized is guaranteed `true` at the moment we return. + // + // We capture isInitialized at exactly the moment initWithContextSuspend returns + // for the second caller. Old code: false (bug). New code: true (fix). + val secondInit = async(Dispatchers.IO) { + val result = os.initWithContextSuspend(context, "appId") + val initializedAtReturn = os.isInitialized + Pair(result, initializedAtReturn) + } + + // Sanity: the second caller has not pre-empted the test by returning before + // we unblock the first caller (timing depends on lazy ServiceProvider locks). + Thread.sleep(200) + + // Unblock the first caller so internalInit() can complete (state -> SUCCESS). + trigger.countDown() + + firstInit.await() shouldBe true + val (secondResult, initializedAtReturn) = secondInit.await() + secondResult shouldBe true + // KEY assertion: os must be fully initialized at the moment the re-entrant + // suspend init returns. Old code violated this by returning early while state + // was still IN_PROGRESS, which is what allowed SyncJobService to reach + // runBackgroundServices() before bootstrap() had run. + initializedAtReturn shouldBe true + os.isInitialized shouldBe true + } + } + + // Regression test for review-flagged Defect 1 (stale latch on retry-after-FAILED). + // + // Background: `suspendCompletion` was a single-shot `val CompletableDeferred`. Once + // any init terminates (even FAILED), the deferred stays permanently complete -- so a + // re-entrant suspend caller arriving DURING a subsequent retry would `await()` on the + // already-completed deferred, return instantly, and read transient state (likely + // IN_PROGRESS -> false), silently dropping JobService work. + // + // The fix resets `suspendCompletion` whenever the synchronized(initLock) block flips state + // into IN_PROGRESS, and await sites local-capture the deferred under the lock. + test("initWithContextSuspend resets latch on retry-after-FAILED") { + val context = getApplicationContext() + val os = OneSignalImpV2() + + runBlocking { + // 1. Force a deterministic FAILED first init via the user-locked branch + // (matches the pattern used by other tests in this file). + mockkObject(AndroidUtils) + every { AndroidUtils.isAndroidUserUnlocked(any()) } returns false + val firstInit = os.initWithContextSuspend(context, "appId") + firstInit shouldBe false + os.isInitialized shouldBe false + // At this point the (pre-fix) single-shot `suspendCompletion` is permanently complete. + unmockkObject(AndroidUtils) + + // 2. Stall a fresh retry via BlockingPrefsContext so initState sits at IN_PROGRESS. + val started = CountDownLatch(1) + val trigger = CountDownLatch(1) + val blockingCtx = object : ContextWrapper(context) { + override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + started.countDown() + // Bounded await -- if the test logic ever fails to release `trigger`, the + // IO worker exits cleanly instead of blocking forever and deadlocking the suite. + trigger.await(30, TimeUnit.SECONDS) + return super.getSharedPreferences(name, mode) + } + } + + val secondInit = async(Dispatchers.IO) { os.initWithContextSuspend(blockingCtx, "appId") } + started.await(5, TimeUnit.SECONDS) shouldBe true + os.isInitialized shouldBe false + secondInit.isCompleted shouldBe false + + // 3. Re-entrant caller. With Defect 1, it would wake immediately on the + // FAILED-generation latch (still permanently complete) and return based on + // transient state. With the fix, it waits on the *new* generation's latch. + val thirdInit = async(Dispatchers.IO) { + val r = os.initWithContextSuspend(context, "appId") + val initializedAtReturn = os.isInitialized + Pair(r, initializedAtReturn) + } + + Thread.sleep(300) + thirdInit.isCompleted shouldBe false // would be true with the stale-latch bug + + // 4. Release the second init; both new callers complete with state == SUCCESS. + trigger.countDown() + + secondInit.await() shouldBe true + val (thirdResult, thirdInitializedAtReturn) = thirdInit.await() + thirdResult shouldBe true + thirdInitializedAtReturn shouldBe true + os.isInitialized shouldBe true + } + } + + // Regression test for review-flagged Defect 2 (indefinite hang if internalInit throws). + // + // Background: `internalInit` had no try/catch wrapping its body. An unchecked throw from + // initEssentials/bootstrapServices/etc. would leave initState=IN_PROGRESS forever and + // `suspendCompletion` uncompleted -- causing every re-entrant suspend caller (e.g. + // SyncJobService) to hang on `await()` indefinitely, holding its budget slot until the + // OS killed the worker. + // + // The fix wraps internalInit's body in try/catch, ensuring a terminal state and + // `notifyInitComplete()` on any throw. + test("initWithContextSuspend reaches terminal state when internalInit throws") { + val context = getApplicationContext() + val os = OneSignalImpV2() + + // Make the very first call inside internalInit throw (mirrors the user-locked test + // pattern, but with a throw instead of a `false` return). This is the cheapest way to + // simulate "any unchecked exception during bootstrap" without coupling to specific + // bootstrap internals. + mockkObject(AndroidUtils) + every { AndroidUtils.isAndroidUserUnlocked(any()) } throws RuntimeException("simulated bootstrap failure") + + runBlocking { + // Without the fix: the throw propagates out of internalInit, escapes withContext, + // and either fails the test outright or leaves the SDK in a deadlocked + // IN_PROGRESS state with `suspendCompletion` never completed. + // With the fix: the catch block sets FAILED + notifyInitComplete + returns false. + val firstResult = withTimeoutOrNull(5_000) { + os.initWithContextSuspend(context, "appId") + } + firstResult shouldBe false + os.isInitialized shouldBe false + + unmockkObject(AndroidUtils) + + // State is FAILED so a retry is allowed and (per Defect 1 fix) gets a fresh latch. + // This also doubles as a smoke test that we didn't leak IN_PROGRESS. + val retry = withTimeoutOrNull(5_000) { + os.initWithContextSuspend(context, "appId") + } + retry shouldBe true + os.isInitialized shouldBe true + } + } +}) + +/** Gives each test its own SharedPreferences files, so other tests' writes can't leak in. */ +class IsolatedPrefsContext(context: Context) : ContextWrapper(context) { + private val prefix = java.util.UUID.randomUUID().toString() + + override fun getSharedPreferences( + name: String, + mode: Int, + ): SharedPreferences = super.getSharedPreferences("$prefix-$name", mode) +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt index 7416b2910c..2d9998be66 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt @@ -81,6 +81,20 @@ class StartupServiceTests : FunSpec({ verify(exactly = 0) { mockBootstrapService2.bootstrap() } } + test("a bootstrap retried after a failure skips the services that already bootstrapped") { + val done = mockk(relaxed = true) + val flaky = mockk() + every { flaky.bootstrap() } throws Exception("first attempt") andThen Unit + val startupService = StartupService(setupServiceProvider(listOf(done, flaky), listOf())) + + shouldThrowUnit { startupService.bootstrap() } + startupService.bootstrap() + startupService.bootstrap() + + verify(exactly = 1) { done.bootstrap() } + verify(exactly = 2) { flaky.bootstrap() } + } + test("startup will call all IStartableService dependencies successfully after a short delay") { // Given val mockStartupService1 = mockk(relaxed = true) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/InitGateTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/InitGateTests.kt new file mode 100644 index 0000000000..387635295a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/InitGateTests.kt @@ -0,0 +1,92 @@ +package com.onesignal.internal + +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 java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class InitGateTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + // Runs isReady on its own thread; the returned latch opens when it returns. + fun isReadyOnThread( + gate: InitGate, + result: AtomicReference, + ): CountDownLatch { + val finished = CountDownLatch(1) + Thread { + result.set(gate.isReady(SdkOperation.LOGIN)) + finished.countDown() + }.apply { isDaemon = true }.start() + return finished + } + + test("isReady is true at SUCCESS and false at NOT_STARTED and FAILED") { + val gate = InitGate() + gate.isReady(SdkOperation.LOGIN) shouldBe false + + gate.tryStart() shouldBe true + gate.complete(InitState.FAILED) + gate.isReady(SdkOperation.LOGIN) shouldBe false + + gate.tryStart() shouldBe true + gate.complete(InitState.SUCCESS) + gate.isReady(SdkOperation.LOGIN) shouldBe true + } + + test("tryStart refuses while an attempt is running or after success") { + val gate = InitGate() + gate.tryStart() shouldBe true + gate.tryStart() shouldBe false + gate.complete(InitState.SUCCESS) + gate.tryStart() shouldBe false + } + + test("isReady at IN_PROGRESS waits, then reports the attempt's outcome") { + for (outcome in listOf(InitState.SUCCESS, InitState.FAILED)) { + val gate = InitGate() + gate.tryStart() + val result = AtomicReference(null) + val finished = isReadyOnThread(gate, result) + + finished.await(200, TimeUnit.MILLISECONDS) shouldBe false + gate.complete(outcome) + + finished.await(2, TimeUnit.SECONDS) shouldBe true + result.get() shouldBe (outcome == InitState.SUCCESS) + } + } + + test("a waiter follows a retry that starts before it wakes up") { + val gate = InitGate() + gate.tryStart() + val result = AtomicReference(null) + val finished = isReadyOnThread(gate, result) + finished.await(200, TimeUnit.MILLISECONDS) shouldBe false + + // Holding the gate's lock makes fail-then-retry atomic: the waiter can only see the retry. + val lock = InitGate::class.java.getDeclaredField("lock").apply { isAccessible = true }.get(gate) + synchronized(lock) { + gate.complete(InitState.FAILED) + gate.tryStart() shouldBe true + } + finished.await(200, TimeUnit.MILLISECONDS) shouldBe false + + gate.complete(InitState.SUCCESS) + finished.await(2, TimeUnit.SECONDS) shouldBe true + result.get() shouldBe true + } + + test("notReadyMessage distinguishes a failed init from one that never started") { + val gate = InitGate() + gate.notReadyMessage("login") shouldBe "'login' ignored: call initWithContext with a valid appId first." + gate.tryStart() + gate.complete(InitState.FAILED) + gate.notReadyMessage("login") shouldBe "'login' ignored: initWithContext failed." + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpV2Tests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpV2Tests.kt new file mode 100644 index 0000000000..b52b92bfa2 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpV2Tests.kt @@ -0,0 +1,247 @@ +package com.onesignal.internal + +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class OneSignalImpV2Tests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + fun markInitialized(os: OneSignalImpV2) { + os.gate.tryStart() + os.gate.complete(InitState.SUCCESS) + } + + test("attempting login before initWithContext is ignored without throwing") { + val os = OneSignalImpV2() + // NONE: otherwise Logging.error calls the unmocked android.util.Log. + Logging.logLevel = LogLevel.NONE + + os.login("login-id") + os.login("") + + os.isInitialized shouldBe false + } + + test("login with empty externalId after init does not switch user") { + val os = OneSignalImpV2() + markInitialized(os) + // NONE: otherwise Logging.error calls the unmocked android.util.Log. + Logging.logLevel = LogLevel.NONE + + os.login("") + } + + test("login with a null byte in externalId after init does not switch user") { + val os = OneSignalImpV2() + markInitialized(os) + Logging.logLevel = LogLevel.NONE + + os.login("\u0000: 1") + } + + test("updateUserJwt with empty externalId or token after init does not store the token") { + val os = OneSignalImpV2() + markInitialized(os) + Logging.logLevel = LogLevel.NONE + + os.updateUserJwt("", "token") + os.updateUserJwt("user", "") + } + + test("attempting logout before initWithContext is ignored without throwing") { + val os = OneSignalImpV2() + Logging.logLevel = LogLevel.NONE + + os.logout() + + os.isInitialized shouldBe false + } + + test("blockingGet returns synchronously without dispatching when initState is SUCCESS") { + val os = OneSignalImpV2() + markInitialized(os) + + val blockingGet = OneSignalImpV2::class.java.getDeclaredMethod("blockingGet", Function0::class.java) + blockingGet.isAccessible = true + + val getter = { "value" } + blockingGet.invoke(os, getter) shouldBe "value" + } + + // Comprehensive tests for deprecated properties that should work before and after initialization + context("consentRequired property") { + context("before initWithContext") { + test("get returns false by default") { + // Given + val os = OneSignalImpV2() + + // When & Then + os.consentRequired shouldBe false + } + + test("set and get works correctly") { + // Given + val os = OneSignalImpV2() + + // When + os.consentRequired = true + + // Then + os.consentRequired shouldBe true + + // When + os.consentRequired = false + + // Then + os.consentRequired shouldBe false + } + + test("set should not throw") { + // Given + val os = OneSignalImpV2() + + // When & Then - should not throw + os.consentRequired = false + os.consentRequired = true + } + } + } + + context("consentGiven property") { + context("before initWithContext") { + test("get returns false by default") { + // Given + val os = OneSignalImpV2() + + // When & Then + os.consentGiven shouldBe false + } + + test("set and get works correctly") { + // Given + val os = OneSignalImpV2() + + // When + os.consentGiven = true + + // Then + os.consentGiven shouldBe true + + // When + os.consentGiven = false + + // Then + os.consentGiven shouldBe false + } + + test("set should not throw") { + // Given + val os = OneSignalImpV2() + + // When & Then - should not throw + os.consentGiven = true + os.consentGiven = false + } + } + } + + context("disableGMSMissingPrompt property") { + context("before initWithContext") { + test("get returns false by default") { + // Given + val os = OneSignalImpV2() + + // When & Then + os.disableGMSMissingPrompt shouldBe false + } + + test("set and get works correctly") { + // Given + val os = OneSignalImpV2() + + // When + os.disableGMSMissingPrompt = true + + // Then + os.disableGMSMissingPrompt shouldBe true + + // When + os.disableGMSMissingPrompt = false + + // Then + os.disableGMSMissingPrompt shouldBe false + } + + test("set should not throw") { + // Given + val os = OneSignalImpV2() + + // When & Then - should not throw + os.disableGMSMissingPrompt = true + os.disableGMSMissingPrompt = false + } + } + } + + context("property consistency tests") { + test("all properties maintain state correctly") { + // Given + val os = OneSignalImpV2() + + // When - set all properties to true + os.consentRequired = true + os.consentGiven = true + os.disableGMSMissingPrompt = true + + // Then - all should be true + os.consentRequired shouldBe true + os.consentGiven shouldBe true + os.disableGMSMissingPrompt shouldBe true + + // When - set all properties to false + os.consentRequired = false + os.consentGiven = false + os.disableGMSMissingPrompt = false + + // Then - all should be false + os.consentRequired shouldBe false + os.consentGiven shouldBe false + os.disableGMSMissingPrompt shouldBe false + } + + test("properties are independent of each other") { + // Given + val os = OneSignalImpV2() + + // When - set only consentRequired to true + os.consentRequired = true + + // Then - only consentRequired should be true + os.consentRequired shouldBe true + os.consentGiven shouldBe false + os.disableGMSMissingPrompt shouldBe false + + // When - set only consentGiven to true + os.consentRequired = false + os.consentGiven = true + + // Then - only consentGiven should be true + os.consentRequired shouldBe false + os.consentGiven shouldBe true + os.disableGMSMissingPrompt shouldBe false + + // When - set only disableGMSMissingPrompt to true + os.consentGiven = false + os.disableGMSMissingPrompt = true + + // Then - only disableGMSMissingPrompt should be true + os.consentRequired shouldBe false + os.consentGiven shouldBe false + os.disableGMSMissingPrompt shouldBe true + } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperV2Tests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperV2Tests.kt new file mode 100644 index 0000000000..f54e6148a4 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/AppIdHelperV2Tests.kt @@ -0,0 +1,315 @@ +package com.onesignal.user.internal + +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.IPreferencesService +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.mockk.every +import io.mockk.mockk +import io.mockk.verify + +/** + * Unit tests for the resolveAppId function in AppIdResolution.kt + * + * These tests focus on the pure business logic of App ID resolution, + * complementing the integration tests in SDKInitTests.kt which test + * end-to-end SDK initialization behavior. + */ +class AppIdHelperV2Tests : FunSpec({ + // Test constants - using consistent naming with SDKInitTests + val testAppId = "appId" + val differentAppId = "different-app-id" + val legacyAppId = "legacy-app-id" + + beforeEach { + Logging.logLevel = LogLevel.NONE + } + + test("resolveAppId with new appId and no existing appId forces user creation") { + // Given - fresh config model with no appId property + val configModel = ConfigModel() + // Don't set any appId - simulates fresh install + + val mockPreferencesService = mockk(relaxed = true) + + // When + val result = resolveAppIdV2(testAppId, configModel, mockPreferencesService) + + // Then + result.appId shouldBe testAppId + result.forceCreateUser shouldBe true + result.failed shouldBe false + + // Should not check legacy preferences when appId is provided + verify(exactly = 0) { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } + } + + test("resolveAppId with same appId as existing does not force user creation") { + // Given - config model with existing appId + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + // When + val result = resolveAppIdV2(differentAppId, configModel, mockPreferencesService) + + // Then + result.appId shouldBe differentAppId + result.forceCreateUser shouldBe false + result.failed shouldBe false + } + + test("resolveAppId with different appId than existing forces user creation") { + // Given - config model with different existing appId + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + // When + val result = resolveAppIdV2(testAppId, configModel, mockPreferencesService) + + // Then + result.appId shouldBe testAppId + result.forceCreateUser shouldBe true + result.failed shouldBe false + } + + test("resolveAppId with null appId and existing appId in config returns existing") { + // Given - config model with existing appId + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + // When + val result = resolveAppIdV2(null, configModel, mockPreferencesService) + + // Then + result.appId shouldBe differentAppId // should return the existing appId from configModel + result.forceCreateUser shouldBe false + result.failed shouldBe false + + // Should not check legacy preferences when config already has appId + verify(exactly = 0) { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } + } + + test("resolveAppId with null appId and no existing appId finds legacy appId") { + // Given - fresh config model with no appId property + val configModel = ConfigModel() + + val mockPreferencesService = mockk() + every { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } returns legacyAppId + + // When + val result = resolveAppIdV2(null, configModel, mockPreferencesService) + + // Then + result.appId shouldBe legacyAppId + result.forceCreateUser shouldBe true // Legacy appId found forces user creation + result.failed shouldBe false + + // Should check legacy preferences + verify(exactly = 1) { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } + } + + test("resolveAppId with null appId and no existing appId and no legacy appId fails") { + // Given - fresh config model with no appId property and no legacy appId + val configModel = ConfigModel() + + val mockPreferencesService = mockk() + every { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } returns null + + // When + val result = resolveAppIdV2(null, configModel, mockPreferencesService) + + // Then + result.appId shouldBe null + result.forceCreateUser shouldBe false + result.failed shouldBe true + + // Should check legacy preferences + verify(exactly = 1) { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } + } + + test("AppIdResolution data class has correct properties") { + // Given + val appIdResolution = + AppIdResolution( + appId = "test-app-id", + forceCreateUser = true, + failed = false, + ) + + // Then + appIdResolution.appId shouldBe "test-app-id" + appIdResolution.forceCreateUser shouldBe true + appIdResolution.failed shouldBe false + } + + test("AppIdResolution handles null appId correctly") { + // Given + val appIdResolution = + AppIdResolution( + appId = null, + forceCreateUser = false, + failed = true, + ) + + // Then + appIdResolution.appId shouldBe null + appIdResolution.forceCreateUser shouldBe false + appIdResolution.failed shouldBe true + } + + test("configModel hasProperty check works correctly with appId set") { + // Given - config model with appId explicitly set + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + // When + val result = resolveAppIdV2(testAppId, configModel, mockPreferencesService) + + // Then - should detect property exists and force user creation due to different appId + result.appId shouldBe testAppId + result.forceCreateUser shouldBe true + result.failed shouldBe false + } + + test("empty appId falls back to the legacy appId") { + val configModel = ConfigModel() + + val mockPreferencesService = mockk() + every { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } returns legacyAppId + + val result = resolveAppIdV2("", configModel, mockPreferencesService) + + result.appId shouldBe legacyAppId + result.forceCreateUser shouldBe true + result.failed shouldBe false + } + + test("empty appId falls back to the cached appId without resetting the user") { + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + val result = resolveAppIdV2("", configModel, mockPreferencesService) + + result.appId shouldBe differentAppId + result.forceCreateUser shouldBe false + result.failed shouldBe false + } + + test("appId with a null byte falls back to the cached appId") { + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + val result = resolveAppIdV2("app\u0000id", configModel, mockPreferencesService) + + result.appId shouldBe differentAppId + result.forceCreateUser shouldBe false + result.failed shouldBe false + } + + test("empty appId with nothing cached fails resolution so init is skipped") { + val configModel = ConfigModel() + + val mockPreferencesService = mockk() + every { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } returns null + + val result = resolveAppIdV2("", configModel, mockPreferencesService) + + result.appId shouldBe null + result.failed shouldBe true + } + + test("resolveAppId with existing appId property but same value") { + // Given - config model with the same appId already set + val configModel = ConfigModel() + configModel.appId = differentAppId + + val mockPreferencesService = mockk(relaxed = true) + + // When + val result = resolveAppIdV2(differentAppId, configModel, mockPreferencesService) + + // Then - should not force user creation when appId is unchanged + result.appId shouldBe differentAppId + result.forceCreateUser shouldBe false + result.failed shouldBe false + } + + test("legacy appId fallback when config model exists but has no appId property") { + // Given - config model that exists but doesn't have appId set + val configModel = ConfigModel() + // Don't set appId to simulate hasProperty returning false + + val mockPreferencesService = mockk() + every { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } returns legacyAppId + + // When + val result = resolveAppIdV2(null, configModel, mockPreferencesService) + + // Then + result.appId shouldBe legacyAppId + result.forceCreateUser shouldBe true + result.failed shouldBe false + + verify(exactly = 1) { + mockPreferencesService.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) + } + } + + test("an invalid cached appId is not used") { + for (cached in listOf("", "app\u0000id")) { + val configModel = ConfigModel().apply { appId = cached } + val prefs = mockk() + every { prefs.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) } returns cached + + val result = resolveAppIdV2("", configModel, prefs) + + result.failed shouldBe true + result.appId shouldBe null + } + } + + test("an invalid cached config appId falls through to a valid legacy appId") { + val configModel = ConfigModel().apply { appId = "" } + val prefs = mockk() + every { prefs.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID) } returns legacyAppId + + val result = resolveAppIdV2(null, configModel, prefs) + + result.appId shouldBe legacyAppId + result.failed shouldBe false + } +})