diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt index 2f46015721..92e3585386 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt @@ -1,9 +1,15 @@ +@file:Suppress("GlobalCoroutineUsage") + package com.onesignal.common.threading import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlin.concurrent.thread /** * Modernized ThreadUtils that leverages OneSignalDispatchers for better thread management. @@ -24,8 +30,27 @@ import kotlinx.coroutines.withContext * */ fun suspendifyOnMain(block: suspend () -> Unit) { - OneSignalDispatchers.launchOnIO { - withContext(Dispatchers.Main) { block() } + if (ThreadingMode.useBackgroundThreading) { + OneSignalDispatchers.launchOnIO { + try { + withContext(Dispatchers.Main) { block() } + } catch (e: Exception) { + Logging.error("Exception in suspendifyOnMain", e) + } + } + return + } + + thread { + try { + runBlocking { + withContext(Dispatchers.Main) { + block() + } + } + } catch (e: Exception) { + Logging.error("Exception on thread with switch to main", e) + } } } @@ -86,24 +111,36 @@ fun suspendifyWithCompletion( block: suspend () -> Unit, onComplete: (() -> Unit)? = null, ) { - if (useIO) { - OneSignalDispatchers.launchOnIO { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithCompletion", e) + if (ThreadingMode.useBackgroundThreading) { + if (useIO) { + OneSignalDispatchers.launchOnIO { + try { + block() + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception in suspendifyWithCompletion", e) + } } - } - } else { - OneSignalDispatchers.launchOnDefault { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithCompletion", e) + } else { + OneSignalDispatchers.launchOnDefault { + try { + block() + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception in suspendifyWithCompletion", e) + } } } + return + } + + GlobalScope.launch(if (useIO) Dispatchers.IO else Dispatchers.Default) { + try { + block() + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception in suspendifyWithCompletion", e) + } } } @@ -122,26 +159,39 @@ fun suspendifyWithErrorHandling( onError: ((Exception) -> Unit)? = null, onComplete: (() -> Unit)? = null, ) { - if (useIO) { - OneSignalDispatchers.launchOnIO { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithErrorHandling", e) - onError?.invoke(e) + if (ThreadingMode.useBackgroundThreading) { + if (useIO) { + OneSignalDispatchers.launchOnIO { + try { + block() + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception in suspendifyWithErrorHandling", e) + onError?.invoke(e) + } } - } - } else { - OneSignalDispatchers.launchOnDefault { - try { - block() - onComplete?.invoke() - } catch (e: Exception) { - Logging.error("Exception in suspendifyWithErrorHandling", e) - onError?.invoke(e) + } else { + OneSignalDispatchers.launchOnDefault { + try { + block() + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception in suspendifyWithErrorHandling", e) + onError?.invoke(e) + } } } + return + } + + GlobalScope.launch(if (useIO) Dispatchers.IO else Dispatchers.Default) { + try { + block() + onComplete?.invoke() + } catch (e: Exception) { + Logging.error("Exception in suspendifyWithErrorHandling", e) + onError?.invoke(e) + } } } @@ -153,7 +203,23 @@ fun suspendifyWithErrorHandling( * @return Job that can be used to wait for completion with .join() */ fun launchOnIO(block: suspend () -> Unit): Job { - return OneSignalDispatchers.launchOnIO(block) + return if (ThreadingMode.useBackgroundThreading) { + OneSignalDispatchers.launchOnIO { + try { + block() + } catch (e: Exception) { + Logging.error("Exception in launchOnIO", e) + } + } + } else { + GlobalScope.launch(Dispatchers.IO) { + try { + block() + } catch (e: Exception) { + Logging.error("Exception in launchOnIO", e) + } + } + } } /** @@ -164,5 +230,21 @@ fun launchOnIO(block: suspend () -> Unit): Job { * @return Job that can be used to wait for completion with .join() */ fun launchOnDefault(block: suspend () -> Unit): kotlinx.coroutines.Job { - return OneSignalDispatchers.launchOnDefault(block) + return if (ThreadingMode.useBackgroundThreading) { + OneSignalDispatchers.launchOnDefault { + try { + block() + } catch (e: Exception) { + Logging.error("Exception in launchOnDefault", e) + } + } + } else { + GlobalScope.launch(Dispatchers.Default) { + try { + block() + } catch (e: Exception) { + Logging.error("Exception in launchOnDefault", e) + } + } + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadingMode.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadingMode.kt new file mode 100644 index 0000000000..dec0aee8f1 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadingMode.kt @@ -0,0 +1,25 @@ +package com.onesignal.common.threading + +import com.onesignal.debug.internal.logging.Logging + +/** + * Global threading mode switch that can be refreshed from remote config. + */ +internal object ThreadingMode { + @Volatile + var useBackgroundThreading: Boolean = false + + fun updateUseBackgroundThreading( + enabled: Boolean, + source: String, + ) { + val previous = useBackgroundThreading + useBackgroundThreading = enabled + + if (previous != enabled) { + Logging.info("OneSignal: ThreadingMode changed to useBackgroundThreading=$enabled (source=$source)") + } else { + Logging.debug("OneSignal: ThreadingMode unchanged (useBackgroundThreading=$enabled, source=$source)") + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 8897bb13a6..9d34231d63 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -16,6 +16,8 @@ import com.onesignal.core.internal.device.IDeviceService import com.onesignal.core.internal.device.IInstallIdService import com.onesignal.core.internal.device.impl.DeviceService import com.onesignal.core.internal.device.impl.InstallIdService +import com.onesignal.core.internal.features.FeatureManager +import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.http.IHttpClient import com.onesignal.core.internal.http.impl.HttpClient import com.onesignal.core.internal.http.impl.HttpConnectionFactory @@ -57,6 +59,7 @@ internal class CoreModule : IModule { // Params (Config) builder.register().provides() + builder.register().provides() builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt index 8773a23af3..54f3cd85e6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt @@ -35,6 +35,7 @@ internal class ParamsObject( var locationShared: Boolean? = null, var requiresUserPrivacyConsent: Boolean? = null, var opRepoExecutionInterval: Long? = null, + val features: List = emptyList(), var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, val remoteLoggingParams: RemoteLoggingParamsObject, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt index dfaaa027dc..ec0af86055 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -68,6 +68,19 @@ internal class ParamsBackendService( ) } + val features = + responseJson.optJSONArray("features") + ?.let { featuresJson -> + buildList { + for (i in 0 until featuresJson.length()) { + val featureName = featuresJson.optString(i, "") + if (featureName.isNotBlank()) { + add(featureName) + } + } + } + } ?: emptyList() + return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), @@ -84,6 +97,7 @@ internal class ParamsBackendService( requiresUserPrivacyConsent = responseJson.safeBool("requires_user_privacy_consent"), // TODO: New opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), + features = features, influenceParams = influenceParams ?: InfluenceParamsObject(), fcmParams = fcmParams ?: FCMParamsObject(), remoteLoggingParams = remoteLoggingParams ?: RemoteLoggingParamsObject(), diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index bd06e4c3e4..a88739e05e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt @@ -290,6 +290,16 @@ class ConfigModel : Model() { setBooleanProperty(::clearGroupOnSummaryClick.name, value) } + /** + * Remote feature switches controlled by backend. + * Presence of a feature name indicates enabled. + */ + var features: List + get() = getListProperty(::features.name) { emptyList() } + set(value) { + setListProperty(::features.name, value) + } + /** * The outcomes parameters */ @@ -329,6 +339,24 @@ class ConfigModel : Model() { return null } + + override fun createListForProperty( + property: String, + jsonArray: JSONArray, + ): List<*>? { + if (property == ::features.name) { + return buildList { + for (i in 0 until jsonArray.length()) { + val featureName = jsonArray.optString(i, "") + if (featureName.isNotBlank()) { + add(featureName) + } + } + } + } + + return null + } } /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt index 5dcfe83b72..9e5c4fc328 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt @@ -95,6 +95,7 @@ internal class ConfigModelStoreListener( params.locationShared?.let { config.locationShared = it } params.requiresUserPrivacyConsent?.let { config.consentRequired = it } params.opRepoExecutionInterval?.let { config.opRepoExecutionInterval = it } + config.features = params.features params.influenceParams.notificationLimit?.let { config.influenceParams.notificationLimit = it } params.influenceParams.indirectNotificationAttributionWindow?.let { config.influenceParams.indirectNotificationAttributionWindow = it } params.influenceParams.iamLimit?.let { config.influenceParams.iamLimit = it } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt new file mode 100644 index 0000000000..01659dea14 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -0,0 +1,27 @@ +package com.onesignal.core.internal.features + +/** + * Controls when remote config changes for a feature are applied. + */ +internal enum class FeatureActivationMode { + /** + * Apply config changes immediately during the current app run. + */ + IMMEDIATE, + + /** + * Latch value at startup; apply remote changes on next app run. + */ + APP_STARTUP, +} + +/** + * Backend-driven feature switches used by the SDK. + */ +internal enum class FeatureFlag( + val key: String, + val activationMode: FeatureActivationMode, +) { + // Threading mode is selected once per app startup to avoid mixed-mode behavior mid-session. + BACKGROUND_THREADING("BACKGROUND_THREADING", FeatureActivationMode.APP_STARTUP), +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt new file mode 100644 index 0000000000..65cd460e51 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -0,0 +1,129 @@ +package com.onesignal.core.internal.features + +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler +import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.common.modeling.ModelChangedArgs +import com.onesignal.common.threading.ThreadingMode +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.debug.internal.logging.Logging + +internal interface IFeatureManager { + fun isEnabled(feature: FeatureFlag): Boolean +} + +@Suppress("TooGenericExceptionCaught") +internal class FeatureManager( + private val configModelStore: ConfigModelStore, +) : IFeatureManager, ISingletonModelStoreChangeHandler { + @Volatile + private var featureStates: Map = emptyMap() + + init { + Logging.debug("OneSignal: FeatureManager initializing from cached config features") + try { + refreshEnabledFeatures(configModelStore.model, applyNextRunOnlyFeatures = true) + } catch (t: Throwable) { + Logging.error("OneSignal: Failed to initialize feature states from cached config", t) + } + configModelStore.subscribe(this) + } + + override fun isEnabled(feature: FeatureFlag): Boolean = featureStates[feature] ?: false + + @Suppress("TooGenericExceptionCaught") + override fun onModelReplaced( + model: ConfigModel, + tag: String, + ) { + Logging.debug("OneSignal: FeatureManager.onModelReplaced(tag=$tag)") + if (tag == ModelChangeTags.HYDRATE || tag == ModelChangeTags.NORMAL) { + try { + refreshEnabledFeatures(model, applyNextRunOnlyFeatures = false) + } catch (t: Throwable) { + Logging.error("OneSignal: Failed to refresh features on model replace", t) + } + } + } + + @Suppress("TooGenericExceptionCaught") + override fun onModelUpdated( + args: ModelChangedArgs, + tag: String, + ) { + if (args.property == ConfigModel::features.name) { + Logging.debug("OneSignal: FeatureManager.onModelUpdated(property=${args.property}, tag=$tag)") + try { + refreshEnabledFeatures(configModelStore.model, applyNextRunOnlyFeatures = false) + } catch (t: Throwable) { + Logging.error("OneSignal: Failed to refresh features on model update", t) + } + } + } + + @Suppress("NestedBlockDepth") + private fun refreshEnabledFeatures( + model: ConfigModel, + applyNextRunOnlyFeatures: Boolean, + ) { + val enabledFeatureKeys = (model.features + localFeatureOverrides).toSet() + if (localFeatureOverrides.isNotEmpty()) { + Logging.warn( + "OneSignal: Local feature override enabled for testing only: $localFeatureOverrides", + ) + } + val nextStates = featureStates.toMutableMap() + + for (feature in FeatureFlag.entries) { + val desiredState = enabledFeatureKeys.contains(feature.key) + when (feature.activationMode) { + FeatureActivationMode.IMMEDIATE -> { + nextStates[feature] = desiredState + applySideEffects(feature, desiredState) + } + + FeatureActivationMode.APP_STARTUP -> { + val hasBeenInitialized = nextStates.containsKey(feature) + if (applyNextRunOnlyFeatures || !hasBeenInitialized) { + nextStates[feature] = desiredState + applySideEffects(feature, desiredState) + } else { + val currentState = nextStates[feature] ?: false + if (currentState != desiredState) { + Logging.info( + "OneSignal: Feature ${feature.key} changed remotely to $desiredState " + + "but is NEXT_RUN, keeping current run value=$currentState", + ) + } + } + } + } + } + + featureStates = nextStates + } + + private fun applySideEffects( + feature: FeatureFlag, + enabled: Boolean, + ) { + when (feature) { + FeatureFlag.BACKGROUND_THREADING -> + ThreadingMode.updateUseBackgroundThreading( + enabled = enabled, + source = "FeatureManager:${feature.activationMode}" + ) + } + } + + companion object { + /** + * Local-only test hook for forcing features ON without backend config. + * Add feature keys here while testing locally, e.g.: + * setOf(FeatureFlag.BACKGROUND_THREADING.key) + */ + private val localFeatureOverrides: Set = emptySet() +// private val localFeatureOverrides: Set = +// setOf(FeatureFlag.BACKGROUND_THREADING.key) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 5d3d75e30f..d1ea2036c2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -5,7 +5,7 @@ import android.os.Build import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.OneSignalWrapper -import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.launchOnIO import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService import com.onesignal.core.internal.http.HttpResponse @@ -110,7 +110,7 @@ internal class HttpClient( var retVal: HttpResponse? = null val job = - OneSignalDispatchers.launchOnIO { + launchOnIO { var httpResponse = -1 var con: HttpURLConnection? = null diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt index 725f56a7bc..0caa063c52 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/impl/PreferencesService.kt @@ -2,8 +2,8 @@ package com.onesignal.core.internal.preferences.impl import android.content.Context import android.content.SharedPreferences -import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.Waiter +import com.onesignal.common.threading.launchOnIO import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceStores @@ -24,8 +24,13 @@ internal class PreferencesService( ) private val waiter = Waiter() + // Throttles missing appContext warnings so we don't spam logs from the background loop. + @Volatile + private var hasLoggedMissingAppContext = false + override fun start() { // fire up an async job that will run "forever" so we don't hold up the other startable services. + Logging.debug("OneSignal: PreferencesService starting async write loop") doWorkAsync() } @@ -166,11 +171,14 @@ internal class PreferencesService( storeMap[key] = value } + Logging.debug("OneSignal: PreferencesService queued write for store=$store key=$key") waiter.wake() } + @Suppress("LongMethod", "ComplexMethod") private fun doWorkAsync() = - OneSignalDispatchers.launchOnIO { + launchOnIO { + Logging.debug("OneSignal: PreferencesService write loop running") var lastSyncTime = _time.currentTimeMillis while (true) { @@ -183,9 +191,17 @@ internal class PreferencesService( if (prefsToWrite == null) { // the assumption here is there is no context yet, but will be. So ensure // we wake up to try again and persist the preference. + if (!hasLoggedMissingAppContext) { + Logging.warn("OneSignal: PreferencesService app context unavailable, deferring writes") + hasLoggedMissingAppContext = true + } waiter.wake() continue } + if (hasLoggedMissingAppContext) { + Logging.info("OneSignal: PreferencesService app context is now available, resuming writes") + hasLoggedMissingAppContext = false + } val editor = prefsToWrite.edit() @@ -226,7 +242,12 @@ internal class PreferencesService( @Synchronized private fun getSharedPrefsByName(store: String): SharedPreferences? { - return _applicationService.appContext.getSharedPreferences(store, Context.MODE_PRIVATE) + return try { + _applicationService.appContext.getSharedPreferences(store, Context.MODE_PRIVATE) + } catch (t: Throwable) { + // App context may not be ready yet during early startup. + null + } } companion object { 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 9d1c112d64..7d1f41c93c 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 @@ -2,6 +2,9 @@ package com.onesignal.core.internal.startup import com.onesignal.common.services.ServiceProvider import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.debug.internal.logging.Logging internal class StartupService( private val services: ServiceProvider, @@ -11,9 +14,37 @@ internal class StartupService( } // schedule to start all startable services using OneSignal dispatcher + @Suppress("TooGenericExceptionCaught") fun scheduleStart() { - OneSignalDispatchers.launchOnDefault { - services.getAllServices().forEach { it.start() } + val useBackgroundThreading = + try { + val featureManager = services.getService() + featureManager.isEnabled(FeatureFlag.BACKGROUND_THREADING) + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to resolve BACKGROUND_THREADING in StartupService. Falling back to legacy thread.", t) + false + } + + if (useBackgroundThreading) { + OneSignalDispatchers.launchOnDefault { + services.getAllServices().forEach { startableService -> + try { + startableService.start() + } catch (t: Throwable) { + Logging.error("OneSignal: Startable service failed: ${startableService::class.java.simpleName}", t) + } + } + } + } else { + Thread { + services.getAllServices().forEach { startableService -> + try { + startableService.start() + } catch (t: Throwable) { + Logging.error("OneSignal: Startable service failed: ${startableService::class.java.simpleName}", t) + } + } + }.start() } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index 1d197ce797..42ea120a49 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -2,11 +2,14 @@ package com.onesignal.debug.internal.crash import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.OtelFactory import com.onesignal.otel.crash.OtelCrashUploader +import kotlinx.coroutines.runBlocking /** * Android-specific wrapper for OtelCrashUploader that implements IStartableService. @@ -31,6 +34,7 @@ import com.onesignal.otel.crash.OtelCrashUploader */ internal class OneSignalCrashUploaderWrapper( private val applicationService: IApplicationService, + private val featureManager: IFeatureManager, ) : IStartableService { private val uploader: OtelCrashUploader by lazy { // Create Android-specific platform provider (injects Android values) @@ -46,15 +50,29 @@ internal class OneSignalCrashUploaderWrapper( @Suppress("TooGenericExceptionCaught") override fun start() { if (!OtelSdkSupport.isSupported) return - OneSignalDispatchers.launchOnIO { + if (featureManager.isEnabled(FeatureFlag.BACKGROUND_THREADING)) { + OneSignalDispatchers.launchOnIO { + try { + uploader.start() + } catch (t: Throwable) { + com.onesignal.debug.internal.logging.Logging.warn( + "OneSignal: Crash uploader failed to start: ${t.message}", + t, + ) + } + } + return + } + + Thread { try { - uploader.start() + runBlocking { uploader.start() } } catch (t: Throwable) { com.onesignal.debug.internal.logging.Logging.warn( "OneSignal: Crash uploader failed to start: ${t.message}", t, ) } - } + }.start() } } 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 ba263ef53c..065c4d6807 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 @@ -16,6 +16,8 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceStoreFix @@ -40,11 +42,12 @@ import com.onesignal.user.internal.resolveAppId import com.onesignal.user.internal.subscriptions.SubscriptionModelStore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext internal class OneSignalImp( - private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : IOneSignal, IServiceProvider { private val suspendCompletion = CompletableDeferred() @@ -113,23 +116,23 @@ internal class OneSignalImp( override val session: ISessionManager get() = - waitAndReturn { services.getService() } + getServiceWithFeatureGate { services.getService() } override val notifications: INotificationsManager get() = - waitAndReturn { services.getService() } + getServiceWithFeatureGate { services.getService() } override val location: ILocationManager get() = - waitAndReturn { services.getService() } + getServiceWithFeatureGate { services.getService() } override val inAppMessages: IInAppMessagesManager get() = - waitAndReturn { services.getService() } + getServiceWithFeatureGate { services.getService() } override val user: IUserManager get() = - waitAndReturn { services.getService() } + getServiceWithFeatureGate { services.getService() } // Services required by this class // WARNING: OperationRepo depends on OperationModelStore which in-turn depends @@ -165,6 +168,25 @@ internal class OneSignalImp( } }.build() + private val featureManager: IFeatureManager by lazy { services.getService() } + private val runtimeIoDispatcher: CoroutineDispatcher + get() = if (isBackgroundThreadingEnabled) OneSignalDispatchers.IO else ioDispatcher + + @Suppress("TooGenericExceptionCaught") + private val isBackgroundThreadingEnabled: Boolean + get() { + if (!applicationServiceStarted) { + return false + } + + return try { + featureManager.isEnabled(FeatureFlag.BACKGROUND_THREADING) + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to resolve BACKGROUND_THREADING feature, defaulting to legacy mode.", t) + false + } + } + // get the current config model, if there is one private val configModel: ConfigModel by lazy { services.getService().model } private var _consentRequired: Boolean? = null @@ -172,6 +194,10 @@ internal class OneSignalImp( private var _disableGMSMissingPrompt: Boolean? = null private val initLock: Any = Any() private val loginLogoutLock: Any = Any() + private val applicationServiceLock: Any = Any() + + @Volatile + private var applicationServiceStarted: Boolean = false private val userSwitcher by lazy { val appContext = services.getService().appContext UserSwitcher( @@ -213,14 +239,25 @@ internal class OneSignalImp( PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) - // start the application service. This is called explicitly first because we want - // to make sure it has the context provided on input, for all other startable services - // to depend on if needed. - val applicationService = services.getService() - (applicationService as ApplicationService).start(context) + ensureApplicationServiceStarted(context) + } + + private fun ensureApplicationServiceStarted(context: Context) { + if (applicationServiceStarted) { + return + } - // Give the logging singleton access to the application service to support visual logging. - Logging.applicationService = applicationService + synchronized(applicationServiceLock) { + if (applicationServiceStarted) { + return + } + + // Start application service before any model store or prefs-backed service access. + val applicationService = services.getService() + (applicationService as ApplicationService).start(context) + Logging.applicationService = applicationService + applicationServiceStarted = true + } } private fun updateConfig() { @@ -246,6 +283,7 @@ internal class OneSignalImp( return startupService } + @Suppress("ReturnCount") override fun initWithContext( context: Context, appId: String, @@ -262,11 +300,22 @@ internal class OneSignalImp( initState = InitState.IN_PROGRESS } - // init in background and return immediately to ensure non-blocking - suspendifyOnIO { + // FeatureManager depends on ConfigModelStore/PreferencesService which requires appContext. + // Ensure app context is available before evaluating feature gates. + ensureApplicationServiceStarted(context) + + if (isBackgroundThreadingEnabled) { + // init in background and return immediately to ensure non-blocking + suspendifyOnIO { + internalInit(context, appId) + } + return true + } + + // Legacy FF-OFF behavior intentionally blocks caller thread until initialization completes. + return runBlocking(runtimeIoDispatcher) { internalInit(context, appId) } - return true } /** @@ -327,17 +376,37 @@ internal class OneSignalImp( ) { Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - waitForInit(operationName = "login") - - suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } + if (isBackgroundThreadingEnabled) { + waitForInit(operationName = "login") + suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } + } else { + if (!isInitialized) { + throw IllegalStateException("Must call 'initWithContext' before 'login'") + } + Thread { + runBlocking(runtimeIoDispatcher) { + loginHelper.login(externalId, jwtBearerToken) + } + }.start() + } } override fun logout() { Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") - waitForInit(operationName = "logout") - - suspendifyOnIO { logoutHelper.logout() } + if (isBackgroundThreadingEnabled) { + waitForInit(operationName = "logout") + suspendifyOnIO { logoutHelper.logout() } + } else { + if (!isInitialized) { + throw IllegalStateException("Must call 'initWithContext' before 'logout'") + } + Thread { + runBlocking(runtimeIoDispatcher) { + logoutHelper.logout() + } + }.start() + } } override fun hasService(c: Class): Boolean = services.hasService(c) @@ -356,7 +425,7 @@ internal class OneSignalImp( * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") */ private fun waitForInit(operationName: String? = null) { - runBlocking(ioDispatcher) { + runBlocking(runtimeIoDispatcher) { waitUntilInitInternal(operationName) } } @@ -444,6 +513,18 @@ internal class OneSignalImp( return getter() } + private fun getServiceWithFeatureGate(getter: () -> T): T { + return if (isBackgroundThreadingEnabled) { + waitAndReturn(getter) + } else { + if (isInitialized) { + getter() + } else { + throw IllegalStateException("Must call 'initWithContext' before use") + } + } + } + private fun blockingGet(getter: () -> T): T { try { if (AndroidUtils.isRunningOnMainThread()) { @@ -455,7 +536,7 @@ internal class OneSignalImp( Logging.debug("Could not check main thread status (likely in test environment): ${e.message}") } // Call suspendAndReturn directly to avoid nested runBlocking (waitAndReturn -> waitForInit -> runBlocking) - return runBlocking(ioDispatcher) { + return runBlocking(runtimeIoDispatcher) { suspendAndReturn(getter) } } @@ -465,48 +546,48 @@ internal class OneSignalImp( // =============================== override suspend fun getSession(): ISessionManager = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { suspendAndReturn { services.getService() } } override suspend fun getNotifications(): INotificationsManager = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { suspendAndReturn { services.getService() } } override suspend fun getLocation(): ILocationManager = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { suspendAndReturn { services.getService() } } override suspend fun getInAppMessages(): IInAppMessagesManager = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { suspendAndReturn { services.getService() } } override suspend fun getUser(): IUserManager = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { suspendAndReturn { services.getService() } } override suspend fun getConsentRequired(): Boolean = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { configModel.consentRequired ?: (_consentRequired == true) } override suspend fun setConsentRequired(required: Boolean) = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { _consentRequired = required configModel.consentRequired = required } override suspend fun getConsentGiven(): Boolean = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { configModel.consentGiven ?: (_consentGiven == true) } override suspend fun setConsentGiven(value: Boolean) = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { val oldValue = _consentGiven _consentGiven = value configModel.consentGiven = value @@ -516,12 +597,12 @@ internal class OneSignalImp( } override suspend fun getDisableGMSMissingPrompt(): Boolean = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { configModel.disableGMSMissingPrompt } override suspend fun setDisableGMSMissingPrompt(value: Boolean) = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { _disableGMSMissingPrompt = value configModel.disableGMSMissingPrompt = value } @@ -536,7 +617,7 @@ internal class OneSignalImp( initFailureException = IllegalStateException("OneSignal initWithContext failed.") // Use IO dispatcher for initialization to prevent ANRs and optimize for I/O operations - return withContext(ioDispatcher) { + return withContext(runtimeIoDispatcher) { // do not do this again if already initialized or init is in progress synchronized(initLock) { if (initState.isSDKAccessible()) { @@ -556,7 +637,7 @@ internal class OneSignalImp( override suspend fun loginSuspend( externalId: String, jwtBearerToken: String?, - ) = withContext(ioDispatcher) { + ) = withContext(runtimeIoDispatcher) { Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") suspendUntilInit(operationName = "login") @@ -569,7 +650,7 @@ internal class OneSignalImp( } override suspend fun logoutSuspend() = - withContext(ioDispatcher) { + withContext(runtimeIoDispatcher) { Logging.log(LogLevel.DEBUG, "logoutSuspend()") suspendUntilInit(operationName = "logout") diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsFeatureFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsFeatureFlagTests.kt new file mode 100644 index 0000000000..3e529f1c3c --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/ThreadUtilsFeatureFlagTests.kt @@ -0,0 +1,85 @@ +package com.onesignal.common.threading + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking + +class ThreadUtilsFeatureFlagTests : FunSpec({ + beforeEach { + ThreadingMode.useBackgroundThreading = false + } + + afterEach { + unmockkObject(OneSignalDispatchers) + ThreadingMode.useBackgroundThreading = false + } + + test("launchOnIO uses OneSignalDispatchers when BACKGROUND_THREADING is on") { + // Given + ThreadingMode.useBackgroundThreading = true + mockkObject(OneSignalDispatchers) + val expectedJob = mockk(relaxed = true) + every { OneSignalDispatchers.launchOnIO(any Unit>()) } returns expectedJob + + // When + val actualJob = launchOnIO {} + + // Then + actualJob shouldBe expectedJob + verify(exactly = 1) { OneSignalDispatchers.launchOnIO(any Unit>()) } + } + + test("launchOnIO avoids OneSignalDispatchers when BACKGROUND_THREADING is off") { + // Given + ThreadingMode.useBackgroundThreading = false + mockkObject(OneSignalDispatchers) + every { OneSignalDispatchers.launchOnIO(any Unit>()) } returns mockk(relaxed = true) + val completed = CompletableDeferred() + + // When + val job = launchOnIO { completed.complete(Unit) } + + // Then + runBlocking { job.join() } + completed.isCompleted shouldBe true + verify(exactly = 0) { OneSignalDispatchers.launchOnIO(any Unit>()) } + } + + test("launchOnDefault uses OneSignalDispatchers when BACKGROUND_THREADING is on") { + // Given + ThreadingMode.useBackgroundThreading = true + mockkObject(OneSignalDispatchers) + val expectedJob = mockk(relaxed = true) + every { OneSignalDispatchers.launchOnDefault(any Unit>()) } returns expectedJob + + // When + val actualJob = launchOnDefault {} + + // Then + actualJob shouldBe expectedJob + verify(exactly = 1) { OneSignalDispatchers.launchOnDefault(any Unit>()) } + } + + test("launchOnDefault avoids OneSignalDispatchers when BACKGROUND_THREADING is off") { + // Given + ThreadingMode.useBackgroundThreading = false + mockkObject(OneSignalDispatchers) + every { OneSignalDispatchers.launchOnDefault(any Unit>()) } returns mockk(relaxed = true) + val completed = CompletableDeferred() + + // When + val job = launchOnDefault { completed.complete(Unit) } + + // Then + runBlocking { job.join() } + completed.isCompleted shouldBe true + verify(exactly = 0) { OneSignalDispatchers.launchOnDefault(any Unit>()) } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt new file mode 100644 index 0000000000..5ebfd46815 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt @@ -0,0 +1,102 @@ +package com.onesignal.core.internal.features + +import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.common.threading.ThreadingMode +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs + +class FeatureManagerTests : FunSpec({ + beforeEach { + ThreadingMode.useBackgroundThreading = false + } + + test("initial state should enable BACKGROUND_THREADING when feature is present") { + // Given + val initialModel = mockk() + every { initialModel.features } returns listOf(FeatureFlag.BACKGROUND_THREADING.key) + val configModelStore = mockk() + every { configModelStore.model } returns initialModel + every { configModelStore.subscribe(any()) } just runs + + // When + val manager = FeatureManager(configModelStore) + + // Then + manager.isEnabled(FeatureFlag.BACKGROUND_THREADING) shouldBe true + ThreadingMode.useBackgroundThreading shouldBe true + } + + test("onModelReplaced should not switch threading mode after startup") { + // Given + val initialModel = mockk() + every { initialModel.features } returns emptyList() + val configModelStore = mockk() + every { configModelStore.model } returns initialModel + every { configModelStore.subscribe(any()) } just runs + val manager = FeatureManager(configModelStore) + + val updatedModel = mockk() + every { updatedModel.features } returns listOf(FeatureFlag.BACKGROUND_THREADING.key) + + // When + manager.onModelReplaced(updatedModel, ModelChangeTags.HYDRATE) + + // Then + manager.isEnabled(FeatureFlag.BACKGROUND_THREADING) shouldBe false + ThreadingMode.useBackgroundThreading shouldBe false + } + + test("onModelUpdated should not switch threading mode after startup") { + // Given + val model = mockk() + every { model.features } returns emptyList() + val configModelStore = mockk() + every { configModelStore.model } returns model + every { configModelStore.subscribe(any()) } just runs + val manager = FeatureManager(configModelStore) + + every { model.features } returns listOf(FeatureFlag.BACKGROUND_THREADING.key) + + // When + manager.onModelUpdated( + args = mockk { + every { property } returns ConfigModel::features.name + }, + tag = ModelChangeTags.NORMAL + ) + + // Then + manager.isEnabled(FeatureFlag.BACKGROUND_THREADING) shouldBe false + ThreadingMode.useBackgroundThreading shouldBe false + } + + test("onModelUpdated should keep startup mode when initial mode is enabled") { + // Given + val model = mockk() + every { model.features } returns listOf(FeatureFlag.BACKGROUND_THREADING.key) + val configModelStore = mockk() + every { configModelStore.model } returns model + every { configModelStore.subscribe(any()) } just runs + val manager = FeatureManager(configModelStore) + + every { model.features } returns emptyList() + + // When + manager.onModelUpdated( + args = mockk { + every { property } returns ConfigModel::features.name + }, + tag = ModelChangeTags.NORMAL + ) + + // Then + manager.isEnabled(FeatureFlag.BACKGROUND_THREADING) shouldBe true + ThreadingMode.useBackgroundThreading shouldBe true + } +}) 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..211a5951ec 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 @@ -2,6 +2,8 @@ package com.onesignal.core.internal.startup import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.IOMockHelper @@ -14,13 +16,20 @@ import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.verify +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit class StartupServiceTests : FunSpec({ fun setupServiceProvider( bootstrapServices: List, startableServices: List, + backgroundThreadingEnabled: Boolean = true, ): ServiceProvider { + val featureManager = mockk() + every { featureManager.isEnabled(FeatureFlag.BACKGROUND_THREADING) } returns backgroundThreadingEnabled + val serviceBuilder = ServiceBuilder() + serviceBuilder.register(featureManager).provides() for (reg in bootstrapServices) serviceBuilder.register(reg).provides() for (reg in startableServices) @@ -97,6 +106,32 @@ class StartupServiceTests : FunSpec({ verify(exactly = 1) { mockStartupService2.start() } } + test("startup will call all IStartableService dependencies when BACKGROUND_THREADING is off") { + // Given + val latch = CountDownLatch(2) + val mockStartupService1 = mockk(relaxed = true) + val mockStartupService2 = mockk(relaxed = true) + every { mockStartupService1.start() } answers { latch.countDown() } + every { mockStartupService2.start() } answers { latch.countDown() } + + val startupService = + StartupService( + setupServiceProvider( + listOf(), + listOf(mockStartupService1, mockStartupService2), + backgroundThreadingEnabled = false + ) + ) + + // When + startupService.scheduleStart() + + // Then + latch.await(1, TimeUnit.SECONDS) shouldBe true + verify(exactly = 1) { mockStartupService1.start() } + verify(exactly = 1) { mockStartupService2.start() } + } + test("scheduleStart does not block main thread") { // Given val mockStartableService1 = mockk(relaxed = true) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt index 942c02af20..8564545983 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt @@ -7,6 +7,8 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.core.internal.startup.IStartableService @@ -37,11 +39,17 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ sharedPreferences.edit().clear().commit() } + fun mockFeatureManager(backgroundThreadingEnabled: Boolean = true): IFeatureManager { + val featureManager = mockk() + every { featureManager.isEnabled(FeatureFlag.BACKGROUND_THREADING) } returns backgroundThreadingEnabled + return featureManager + } + test("should implement IStartableService interface") { val mockApplicationService = mockk(relaxed = true) every { mockApplicationService.appContext } returns appContext - val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager()) wrapper.shouldBeInstanceOf() } @@ -57,7 +65,7 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ val mockApplicationService = mockk(relaxed = true) every { mockApplicationService.appContext } returns appContext - val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager()) // Should return early without error when remote logging is disabled runBlocking { wrapper.start() } @@ -74,7 +82,7 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ val mockApplicationService = mockk(relaxed = true) every { mockApplicationService.appContext } returns appContext - val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager()) // Should complete without error even when no crash reports exist runBlocking { wrapper.start() } @@ -84,7 +92,7 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ val mockApplicationService = mockk(relaxed = true) every { mockApplicationService.appContext } returns appContext - val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager(backgroundThreadingEnabled = false)) // Multiple calls should not throw runBlocking { @@ -97,7 +105,7 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ val mockApplicationService = mockk(relaxed = true) every { mockApplicationService.appContext } returns appContext - val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager()) wrapper shouldNotBe null }