diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml
index 20c78da60..0530b2d0e 100644
--- a/OneSignalSDK/detekt/detekt-baseline-core.xml
+++ b/OneSignalSDK/detekt/detekt-baseline-core.xml
@@ -138,6 +138,7 @@
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _userBackend: IUserBackendService
ConstructorParameterNaming:UserBackendService.kt$UserBackendService$private val _httpClient: IHttpClient
+ ConstructorParameterNaming:UserManager.kt$UserManager$private val _customEventController: ICustomEventController
ConstructorParameterNaming:UserManager.kt$UserManager$private val _identityModelStore: IdentityModelStore
ConstructorParameterNaming:UserManager.kt$UserManager$private val _languageContext: ILanguageContext
ConstructorParameterNaming:UserManager.kt$UserManager$private val _propertiesModelStore: PropertiesModelStore
@@ -191,6 +192,7 @@
LongMethod:TrackGooglePurchase.kt$TrackGooglePurchase$private fun queryBoughtItems()
LongMethod:TrackGooglePurchase.kt$TrackGooglePurchase$private fun sendPurchases( skusToAdd: ArrayList<String>, newPurchaseTokens: ArrayList<String>, )
LongMethod:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse
+ LongParameterList:ICustomEventBackendService.kt$ICustomEventBackendService$( appId: String, onesignalId: String, externalId: String?, timestamp: Long, eventName: String, eventProperties: String?, metadata: CustomEventMetadata, )
LongParameterList:IDatabase.kt$IDatabase$( table: String, columns: Array<String>? = null, whereClause: String? = null, whereArgs: Array<String>? = null, groupBy: String? = null, having: String? = null, orderBy: String? = null, limit: String? = null, action: (ICursor) -> Unit, )
LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, )
LongParameterList:IParamsBackendService.kt$ParamsObject$( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, var notificationChannels: JSONArray? = null, var firebaseAnalytics: Boolean? = null, var restoreTTLFilter: Boolean? = null, var clearGroupOnSummaryClick: Boolean? = null, var receiveReceiptEnabled: Boolean? = null, var disableGMSMissingPrompt: Boolean? = null, var unsubscribeWhenNotificationsDisabled: Boolean? = null, var locationShared: Boolean? = null, var requiresUserPrivacyConsent: Boolean? = null, var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, )
@@ -279,6 +281,7 @@
RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e
ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution
ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean
+ ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean
ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model?
ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse
ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse
@@ -370,6 +373,7 @@
TooManyFunctions:IUserManager.kt$IUserManager
TooManyFunctions:InfluenceManager.kt$InfluenceManager : IInfluenceManagerISessionLifecycleHandler
TooManyFunctions:JSONObjectExtensions.kt$com.onesignal.common.JSONObjectExtensions.kt
+ TooManyFunctions:JSONUtils.kt$JSONUtils$JSONUtils
TooManyFunctions:Logging.kt$Logging$Logging
TooManyFunctions:Model.kt$Model : IEventNotifier
TooManyFunctions:ModelStore.kt$ModelStore<TModel> : IEventNotifierIModelStoreIModelChangedHandler
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 3d6835097..8773a23af 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
@@ -58,4 +58,5 @@ internal class FCMParamsObject(
internal class RemoteLoggingParamsObject(
val logLevel: com.onesignal.debug.LogLevel? = null,
+ val isEnabled: Boolean = logLevel != null,
)
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 b2d68a411..dfaaa027d 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
@@ -61,7 +61,7 @@ internal class ParamsBackendService(
// Process Remote Logging params
var remoteLoggingParams: RemoteLoggingParamsObject? = null
responseJson.expandJSONObject("logging_config") {
- val logLevel = parseLogLevel(it)
+ val logLevel = LogLevel.fromString(it.safeString("log_level"))
remoteLoggingParams =
RemoteLoggingParamsObject(
logLevel = logLevel,
@@ -134,38 +134,4 @@ internal class ParamsBackendService(
isUnattributedEnabled,
)
}
-
- /**
- * Parse LogLevel from JSON. Supports both string (enum name) and int (ordinal) formats.
- */
- @Suppress("ReturnCount", "TooGenericExceptionCaught", "SwallowedException")
- private fun parseLogLevel(json: JSONObject): LogLevel? {
- // Try string format first (e.g., "ERROR", "WARN", "NONE")
- val logLevelString = json.safeString("log_level") ?: json.safeString("logLevel")
- if (logLevelString != null) {
- try {
- return LogLevel.valueOf(logLevelString.uppercase())
- } catch (e: IllegalArgumentException) {
- Logging.warn("Invalid log level string: $logLevelString")
- }
- }
-
- // Try int format (ordinal: 0=NONE, 1=FATAL, 2=ERROR, etc.)
- val logLevelInt = json.safeInt("log_level") ?: json.safeInt("logLevel")
- if (logLevelInt != null) {
- try {
- return LogLevel.fromInt(logLevelInt)
- } catch (e: Exception) {
- Logging.warn("Invalid log level int: $logLevelInt")
- }
- }
-
- // Backward compatibility: support old "enable" boolean field
- val enable = json.safeBool("enable")
- if (enable != null) {
- return if (enable) LogLevel.ERROR else LogLevel.NONE
- }
-
- return null
- }
}
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 86c0417db..bd06e4c3e 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
@@ -1,6 +1,7 @@
package com.onesignal.core.internal.config
import com.onesignal.common.modeling.Model
+import com.onesignal.core.internal.http.OneSignalService.ONESIGNAL_API_BASE_URL
import org.json.JSONArray
import org.json.JSONObject
@@ -36,7 +37,7 @@ class ConfigModel : Model() {
* The API URL String.
*/
var apiUrl: String
- get() = getStringProperty(::apiUrl.name) { "https://api.onesignal.com/" }
+ get() = getStringProperty(::apiUrl.name) { ONESIGNAL_API_BASE_URL }
set(value) {
setStringProperty(::apiUrl.name, value)
}
@@ -454,4 +455,14 @@ class RemoteLoggingConfigModel(
set(value) {
setOptEnumProperty(::logLevel.name, value)
}
+
+ /**
+ * Whether remote logging is enabled.
+ * Set by backend config hydration — true when the server sends a valid log_level, false otherwise.
+ */
+ var isEnabled: Boolean
+ get() = getBooleanProperty(::isEnabled.name) { false }
+ set(value) {
+ setBooleanProperty(::isEnabled.name, value)
+ }
}
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 9f5277f1e..5dcfe83b7 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
@@ -104,6 +104,7 @@ internal class ConfigModelStoreListener(
params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it }
params.remoteLoggingParams.logLevel?.let { config.remoteLoggingParams.logLevel = it }
+ config.remoteLoggingParams.isEnabled = params.remoteLoggingParams.isEnabled
_configModelStore.replace(config, ModelChangeTags.HYDRATE)
success = true
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt
new file mode 100644
index 000000000..b7533961d
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt
@@ -0,0 +1,7 @@
+package com.onesignal.core.internal.http
+
+/** Central API base URL used by all SDK HTTP traffic, including Otel log export. */
+object OneSignalService {
+// const val ONESIGNAL_API_BASE_URL = "https://api.staging.onesignal.com/"
+ const val ONESIGNAL_API_BASE_URL = "https://api.onesignal.com/"
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/LogLevel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/LogLevel.kt
index 9c3f99e87..e88922909 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/LogLevel.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/LogLevel.kt
@@ -49,5 +49,19 @@ enum class LogLevel {
fun fromInt(value: Int): LogLevel {
return values()[value]
}
+
+ /**
+ * Parses a [LogLevel] from its string name (case-insensitive).
+ * Returns `null` if the string is null or not a valid level name.
+ */
+ @JvmStatic
+ fun fromString(value: String?): LogLevel? {
+ if (value == null) return null
+ return try {
+ valueOf(value.uppercase())
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt
index 162a4f7e0..568134287 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt
@@ -1,7 +1,6 @@
package com.onesignal.debug.internal.crash
import android.content.Context
-import android.os.Build
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
import com.onesignal.otel.IOtelCrashHandler
@@ -9,43 +8,31 @@ import com.onesignal.otel.IOtelLogger
import com.onesignal.otel.OtelFactory
/**
- * Factory for creating crash handlers with SDK version checks.
- * For SDK < 26, returns a no-op implementation.
- * For SDK >= 26, returns the Otel-based crash handler.
+ * Factory for creating Otel-based crash handlers.
+ * Callers must verify [OtelSdkSupport.isSupported] before calling [createCrashHandler].
*
* Uses minimal dependencies - only Context and logger.
* Platform provider uses OtelIdResolver internally which reads from SharedPreferences.
*/
internal object OneSignalCrashHandlerFactory {
/**
- * Creates a crash handler appropriate for the current SDK version.
- * This should be called as early as possible, before any other initialization.
+ * Creates an Otel crash handler. Must only be called on supported devices
+ * (SDK >= [OtelSdkSupport.MIN_SDK_VERSION]).
*
* @param context Android context for creating platform provider
* @param logger Logger instance (can be shared with other components)
+ * @throws IllegalArgumentException if called on an unsupported SDK
*/
fun createCrashHandler(
context: Context,
logger: IOtelLogger,
): IOtelCrashHandler {
- // Otel requires SDK 26+, use no-op for older versions
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
- Logging.info("OneSignal: Creating no-op crash handler (SDK ${Build.VERSION.SDK_INT} < 26)")
- return NoOpCrashHandler()
+ require(OtelSdkSupport.isSupported) {
+ "createCrashHandler called on unsupported SDK (< ${OtelSdkSupport.MIN_SDK_VERSION})"
}
- Logging.info("OneSignal: Creating Otel crash handler (SDK ${Build.VERSION.SDK_INT} >= 26)")
- // Create platform provider - uses OtelIdResolver internally
+ Logging.info("OneSignal: Creating Otel crash handler (SDK >= ${OtelSdkSupport.MIN_SDK_VERSION})")
val platformProvider = createAndroidOtelPlatformProvider(context)
return OtelFactory.createCrashHandler(platformProvider, logger)
}
}
-
-/**
- * No-op crash handler for SDK < 26.
- */
-private class NoOpCrashHandler : IOtelCrashHandler {
- override fun initialize() {
- Logging.info("OneSignal: No-op crash handler initialized (SDK < 26, Otel not supported)")
- }
-}
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 e9d620d09..1d197ce79 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
@@ -1,12 +1,12 @@
package com.onesignal.debug.internal.crash
+import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.core.internal.application.IApplicationService
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.
@@ -43,9 +43,18 @@ internal class OneSignalCrashUploaderWrapper(
OtelFactory.createCrashUploader(platformProvider, logger)
}
+ @Suppress("TooGenericExceptionCaught")
override fun start() {
- runBlocking {
- uploader.start()
+ if (!OtelSdkSupport.isSupported) return
+ OneSignalDispatchers.launchOnIO {
+ try {
+ uploader.start()
+ } catch (t: Throwable) {
+ com.onesignal.debug.internal.logging.Logging.warn(
+ "OneSignal: Crash uploader failed to start: ${t.message}",
+ t,
+ )
+ }
}
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt
index bda108d02..d7ad6960a 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt
@@ -23,7 +23,7 @@ import java.util.concurrent.atomic.AtomicLong
* It creates its own crash reporter to save ANR reports.
*/
internal class OtelAnrDetector(
- private val openTelemetryCrash: IOtelOpenTelemetryCrash,
+ openTelemetryCrash: IOtelOpenTelemetryCrash,
private val logger: IOtelLogger,
private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
@@ -58,6 +58,7 @@ internal class OtelAnrDetector(
logger.info("$TAG: ✅ ANR detection started successfully")
}
+ @Suppress("TooGenericExceptionCaught")
private fun setupRunnables() {
// Runnable that runs on the main thread to indicate it's responsive
mainThreadRunnable = Runnable {
@@ -73,17 +74,16 @@ internal class OtelAnrDetector(
// Thread was interrupted, stop monitoring
logger.info("$TAG: Watchdog thread interrupted, stopping ANR detection")
break
- } catch (e: RuntimeException) {
- logger.error("$TAG: Error in ANR watchdog: ${e.message} - ${e.javaClass.simpleName}: ${e.stackTraceToString()}")
- // Continue monitoring even if there's an error
+ } catch (t: Throwable) {
+ logger.error("$TAG: Error in ANR watchdog: ${t.message} - ${t.javaClass.simpleName}")
}
}
}
}
private fun checkForAnr() {
- // Post a message to the main thread
- mainHandler.post(mainThreadRunnable!!)
+ val runnable = mainThreadRunnable ?: return
+ mainHandler.post(runnable)
// Wait for the check interval
Thread.sleep(checkIntervalMs)
@@ -146,6 +146,7 @@ internal class OtelAnrDetector(
logger.info("$TAG: ✅ ANR detection stopped")
}
+ @Suppress("TooGenericExceptionCaught")
private fun reportAnr(unresponsiveDurationMs: Long) {
try {
logger.info("$TAG: Checking if ANR is OneSignal-related (unresponsive for ${unresponsiveDurationMs}ms)")
@@ -176,8 +177,8 @@ internal class OtelAnrDetector(
}
logger.info("$TAG: ✅ ANR report saved successfully")
- } catch (e: RuntimeException) {
- logger.error("$TAG: Failed to report ANR: ${e.message} - ${e.javaClass.simpleName}: ${e.stackTraceToString()}")
+ } catch (t: Throwable) {
+ logger.error("$TAG: Failed to report ANR: ${t.message} - ${t.javaClass.simpleName}")
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt
new file mode 100644
index 000000000..47fc0034d
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt
@@ -0,0 +1,27 @@
+package com.onesignal.debug.internal.crash
+
+import android.os.Build
+
+/**
+ * Centralizes the SDK version requirement for Otel-based features
+ * (crash reporting, ANR detection, remote log shipping).
+ *
+ * [isSupported] is writable internally so that unit tests can override
+ * the device-level gate without Robolectric @Config gymnastics.
+ */
+internal object OtelSdkSupport {
+ /** Otel libraries require Android O (API 26) or above. */
+ const val MIN_SDK_VERSION = Build.VERSION_CODES.O // 26
+
+ /**
+ * Whether the current device meets the minimum SDK requirement.
+ * Production code should treat this as read-only; tests may flip it via [reset]/direct set.
+ */
+ var isSupported: Boolean = Build.VERSION.SDK_INT >= MIN_SDK_VERSION
+ internal set
+
+ /** Restores the runtime-detected value — call in test teardown. */
+ fun reset() {
+ isSupported = Build.VERSION.SDK_INT >= MIN_SDK_VERSION
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt
index d305fe64e..673db1b8d 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt
@@ -228,10 +228,9 @@ object Logging {
exceptionMessage = throwable?.message,
exceptionStacktrace = throwable?.stackTraceToString(),
)
- } catch (e: Exception) {
+ } catch (t: Throwable) {
// Don't log Otel errors to Otel (would cause infinite loop)
- // Just log to logcat silently
- android.util.Log.e(TAG, "Failed to log to Otel: ${e.message}", e)
+ android.util.Log.e(TAG, "Failed to log to Otel: ${t.message}", t)
}
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt
index 9eda39e73..b205fffd9 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt
@@ -190,6 +190,23 @@ internal class OtelIdResolver(
}
}
+ /**
+ * Resolves whether remote logging is enabled from cached ConfigModelStore.
+ * Enabled is derived from the presence of a valid logLevel:
+ * - "logging_config": {} → no logLevel → disabled (not on allowlist)
+ * - "logging_config": {"log_level": "ERROR"} → has logLevel → enabled (on allowlist)
+ * Returns false if not found, empty, or on error (disabled by default on first launch).
+ */
+ @Suppress("TooGenericExceptionCaught", "SwallowedException")
+ fun resolveRemoteLoggingEnabled(): Boolean {
+ return try {
+ val logLevel = resolveRemoteLogLevel()
+ logLevel != null && logLevel != com.onesignal.debug.LogLevel.NONE
+ } catch (e: Exception) {
+ false
+ }
+ }
+
/**
* Resolves remote log level from cached ConfigModelStore.
* Returns null if not found or if there's an error.
@@ -209,19 +226,10 @@ internal class OtelIdResolver(
}
}
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
- private fun extractLogLevelFromParams(remoteLoggingParams: JSONObject): com.onesignal.debug.LogLevel? {
- return if (remoteLoggingParams.has("logLevel")) {
- val logLevelString = remoteLoggingParams.getString("logLevel")
- try {
- com.onesignal.debug.LogLevel.valueOf(logLevelString.uppercase())
- } catch (e: Exception) {
- null
- }
- } else {
- null
- }
- }
+ private fun extractLogLevelFromParams(remoteLoggingParams: JSONObject): com.onesignal.debug.LogLevel? =
+ com.onesignal.debug.LogLevel.fromString(
+ if (remoteLoggingParams.has("logLevel")) remoteLoggingParams.getString("logLevel") else null
+ )
/**
* Resolves install ID from SharedPreferences.
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt
index 84b48ecfc..eebf9469c 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.os.Build
import com.onesignal.common.OneSignalUtils
import com.onesignal.common.OneSignalWrapper
+import com.onesignal.core.internal.http.OneSignalService
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.otel.IOtelPlatformProvider
@@ -100,54 +101,44 @@ internal class OtelPlatformProvider(
}
// https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime
- override val processUptime: Double
- get() = android.os.SystemClock.uptimeMillis() / 1_000.0 // Use SystemClock directly
+ override val processUptime: Long
+ get() = android.os.SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis()
// https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes
override val currentThreadName: String
get() = Thread.currentThread().name
- // Crash-specific configuration
- // Store crashStoragePath privately since we need a custom getter that logs
- private val _crashStoragePath: String = config.crashStoragePath
-
- override val crashStoragePath: String
- get() {
- // Log the path on first access so developers know where to find crash logs
- Logging.info("OneSignal: Crash logs stored at: $_crashStoragePath")
- return _crashStoragePath
- }
+ override val crashStoragePath: String by lazy {
+ val path = config.crashStoragePath
+ Logging.info("OneSignal: Crash logs stored at: $path")
+ path
+ }
override val minFileAgeForReadMillis: Long = 5_000
- // Remote logging configuration
- /**
- * The minimum log level to send remotely as a string.
- * - If remote config log level is populated and valid: use that level
- * - If remote config is null or unavailable: default to "ERROR" (always log errors)
- * - If remote config is explicitly NONE: return "NONE" (no logs including errors)
- */
+ // Cached from SharedPreferences on first access and held for the session.
+ // Mid-session config updates are handled by OtelLifecycleManager reading
+ // from ConfigModel directly, not from these cached values.
+ override val isRemoteLoggingEnabled: Boolean by lazy {
+ idResolver.resolveRemoteLoggingEnabled()
+ }
+
+ // Cached from SharedPreferences on first access and held for the session.
+ // Mid-session config updates are handled by OtelLifecycleManager reading
+ // from ConfigModel directly, not from these cached values.
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override val remoteLogLevel: String? by lazy {
try {
- val configLevel = idResolver.resolveRemoteLogLevel()
- when {
- // Remote config is populated and working well - use whatever is sent from there
- configLevel != null && configLevel != com.onesignal.debug.LogLevel.NONE -> configLevel.name
- // Explicitly NONE means no logging (including errors)
- configLevel == com.onesignal.debug.LogLevel.NONE -> "NONE"
- // Remote config not available - default to ERROR (always log errors)
- else -> "ERROR"
- }
+ idResolver.resolveRemoteLogLevel()?.name
} catch (e: Exception) {
- // If there's an error accessing config, default to ERROR (always log errors)
- // Exception is intentionally swallowed to avoid recursion in logging
- "ERROR"
+ null
}
}
override val appIdForHeaders: String
get() = appId ?: ""
+
+ override val apiBaseUrl: String = OneSignalService.ONESIGNAL_API_BASE_URL
}
/**
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt
deleted file mode 100644
index d69b25fd2..000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-package com.onesignal.internal
-
-import android.content.Context
-import com.onesignal.common.threading.suspendifyOnIO
-import com.onesignal.debug.LogLevel
-import com.onesignal.debug.internal.crash.createAnrDetector
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger
-import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider
-import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
-import com.onesignal.otel.IOtelCrashHandler
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import com.onesignal.otel.OtelFactory
-import com.onesignal.otel.crash.IOtelAnrDetector
-
-/**
- * Helper class for OneSignal initialization tasks.
- * Extracted from OneSignalImp to reduce class size and improve maintainability.
- *
- * Creates and reuses a single OtelPlatformProvider instance for both crash handler and logging.
- */
-internal class OneSignalCrashLogInit(
- private val context: Context,
-) {
- // Platform provider - created once and reused for both crash handler and logging
- private val platformProvider: OtelPlatformProvider by lazy {
- createAndroidOtelPlatformProvider(context)
- }
-
- @Suppress("TooGenericExceptionCaught")
- fun initializeCrashHandler() {
- try {
- Logging.info("OneSignal: Initializing crash handler early...")
- Logging.info("OneSignal: Creating crash handler with minimal dependencies...")
-
- // Create crash handler directly (non-blocking, doesn't require services upfront)
- val logger = AndroidOtelLogger()
- val crashHandler: IOtelCrashHandler = OtelFactory.createCrashHandler(platformProvider, logger)
-
- Logging.info("OneSignal: Crash handler created, initializing...")
- crashHandler.initialize()
-
- // Log crash storage location for debugging
- Logging.info("OneSignal: ✅ Crash handler initialized successfully and ready to capture crashes")
- Logging.info("OneSignal: 📁 Crash logs will be stored at: ${platformProvider.crashStoragePath}")
- Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as ${platformProvider.appPackageId} ls -la ${platformProvider.crashStoragePath}")
-
- // Initialize ANR detector (standalone, monitors main thread for ANRs)
- try {
- Logging.info("OneSignal: Initializing ANR detector...")
- val anrDetector: IOtelAnrDetector = createAnrDetector(
- platformProvider,
- logger,
- anrThresholdMs = com.onesignal.debug.internal.crash.AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
- checkIntervalMs = com.onesignal.debug.internal.crash.AnrConstants.DEFAULT_CHECK_INTERVAL_MS
- )
- anrDetector.start()
- Logging.info("OneSignal: ✅ ANR detector initialized and started")
- } catch (e: Exception) {
- // If ANR detector initialization fails, log it but don't crash
- Logging.error("OneSignal: Failed to initialize ANR detector: ${e.message}", e)
- }
- } catch (e: Exception) {
- // If crash handler initialization fails, log it but don't crash
- Logging.error("OneSignal: Failed to initialize crash handler: ${e.message}", e)
- }
- }
-
- @Suppress("TooGenericExceptionCaught")
- fun initializeOtelLogging() {
- // Initialize Otel logging asynchronously to avoid blocking initialization
- // Remote logging is not critical for crashes, so it's safe to do this in the background
- // Uses OtelIdResolver internally which reads directly from SharedPreferences
- // No service dependencies required - fully decoupled from service architecture
- suspendifyOnIO {
- try {
- // Reuses the same platform provider instance created for crash handler
- // Get the remote log level as string (defaults to "ERROR" if null, "NONE" if explicitly set)
- val remoteLogLevelStr = platformProvider.remoteLogLevel
-
- // Check if remote logging is enabled (not NONE)
- if (remoteLogLevelStr != null && remoteLogLevelStr != "NONE") {
- // Store in local variable for smart cast
- val logLevelStr = remoteLogLevelStr
- Logging.info("OneSignal: Remote logging enabled at level $logLevelStr, initializing Otel logging integration...")
- val remoteTelemetry: IOtelOpenTelemetryRemote = OtelFactory.createRemoteTelemetry(platformProvider)
-
- // Parse the log level string to LogLevel enum for comparison
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
- val remoteLogLevel: LogLevel = try {
- LogLevel.valueOf(logLevelStr)
- } catch (e: Exception) {
- LogLevel.ERROR // Default to ERROR on parse error
- }
-
- // Create a function that checks if a log level should be sent remotely
- // - If remoteLogLevel is null: default to ERROR (send ERROR and above)
- // - If remoteLogLevel is NONE: don't send anything (shouldn't reach here, but handle it)
- // - Otherwise: send logs at that level and above
- val shouldSendLogLevel: (LogLevel) -> Boolean = { level ->
- when {
- remoteLogLevel == LogLevel.NONE -> false // Don't send anything
- else -> level >= remoteLogLevel // Send at configured level and above
- }
- }
-
- // Inject Otel telemetry into Logging class
- Logging.setOtelTelemetry(remoteTelemetry, shouldSendLogLevel)
- Logging.info("OneSignal: ✅ Otel logging integration initialized - logs at level $logLevelStr and above will be sent to remote server")
- } else {
- Logging.debug("OneSignal: Remote logging disabled (level: $remoteLogLevelStr), skipping Otel logging integration")
- }
- } catch (e: Exception) {
- // If Otel logging initialization fails, log it but don't crash
- Logging.warn("OneSignal: Failed to initialize Otel logging: ${e.message}", e)
- }
- }
- }
-}
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 a3a0f1c69..ba263ef53 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
@@ -55,6 +55,8 @@ internal class OneSignalImp(
// Save the exception pointing to the caller that triggered init, not the async worker thread.
private var initFailureException: Exception? = null
+ private var otelManager: OtelLifecycleManager? = null
+
override val sdkVersion: String = OneSignalUtils.sdkVersion
override val isInitialized: Boolean
@@ -207,20 +209,7 @@ internal class OneSignalImp(
}
private fun initEssentials(context: Context) {
- // Create OneSignalCrashLogInit instance once - it manages platform provider lifecycle
- // Platform provider is created lazily and reused for both crash handler and logging
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // Crash handler needs to be one of the first things we setup,
- // otherwise we'll not report some crashes, resulting in a false sense
- // of stability.
- // Initialize crash handler early, before any other services that might crash.
- // This is decoupled from getService to ensure fast initialization.
- crashLogInit.initializeCrashHandler()
-
- // Initialize Otel logging integration - reuses the same platform provider created in initializeCrashHandler
- // No service dependencies required, reads directly from SharedPreferences
- crashLogInit.initializeOtelLogging()
+ otelManager = OtelLifecycleManager(context).also { it.initializeFromCachedConfig() }
PreferenceStoreFix.ensureNoObfuscatedPrefStore(context)
@@ -305,6 +294,11 @@ internal class OneSignalImp(
initEssentials(context)
val startupService = bootstrapServices()
+
+ // Now that the IoC container is ready, subscribe the Otel lifecycle
+ // manager to config store events so it reacts to fresh remote config.
+ otelManager?.subscribeToConfigStore(services.getService())
+
val result = resolveAppId(appId, configModel, preferencesService)
if (result.failed) {
val message = "suspendInitInternal: no appId provided or found in local storage. Please pass a valid appId to initWithContext()."
@@ -453,7 +447,7 @@ internal class OneSignalImp(
private fun blockingGet(getter: () -> T): T {
try {
if (AndroidUtils.isRunningOnMainThread()) {
- Logging.warn("This is called on main thread. This is not recommended.")
+ Logging.debug("This is called on main thread. This is not recommended.")
}
} catch (e: RuntimeException) {
// In test environments, AndroidUtils.isRunningOnMainThread() may fail
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt
new file mode 100644
index 000000000..ea8b862ae
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt
@@ -0,0 +1,68 @@
+package com.onesignal.internal
+
+import com.onesignal.debug.LogLevel
+
+/**
+ * Snapshot of the Otel-relevant fields from remote config.
+ * Used by [OtelConfigEvaluator] to diff old vs new config.
+ */
+internal data class OtelConfig(
+ val isEnabled: Boolean,
+ val logLevel: LogLevel?,
+) {
+ companion object {
+ val DISABLED = OtelConfig(isEnabled = false, logLevel = null)
+ }
+}
+
+/**
+ * Describes what the [OtelLifecycleManager] should do after a config change.
+ */
+internal sealed class OtelConfigAction {
+ /** Nothing changed that affects Otel features. */
+ object NoChange : OtelConfigAction()
+
+ /** Otel features should be started at the given [logLevel]. */
+ data class Enable(val logLevel: LogLevel) : OtelConfigAction()
+
+ /** The remote log level changed while features remain enabled. */
+ data class UpdateLogLevel(val oldLevel: LogLevel, val newLevel: LogLevel) : OtelConfigAction()
+
+ /** Otel features should be stopped/torn down. */
+ object Disable : OtelConfigAction()
+}
+
+/**
+ * Pure, side-effect-free evaluator that compares old and new [OtelConfig]
+ * and returns the [OtelConfigAction] the lifecycle manager should execute.
+ *
+ * Designed to be fully unit-testable without mocks.
+ */
+internal object OtelConfigEvaluator {
+ /**
+ * @param old the previous config snapshot, or null on first evaluation (cold start).
+ * @param new the freshly-arrived config snapshot.
+ */
+ fun evaluate(old: OtelConfig?, new: OtelConfig): OtelConfigAction {
+ val wasEnabled = old?.isEnabled == true
+ val isNowEnabled = new.isEnabled
+
+ return when {
+ // Transition: off -> on
+ !wasEnabled && isNowEnabled -> {
+ val level = new.logLevel ?: LogLevel.ERROR
+ OtelConfigAction.Enable(level)
+ }
+ // Transition: on -> off
+ wasEnabled && !isNowEnabled -> OtelConfigAction.Disable
+ // Stays enabled but log level changed
+ wasEnabled && isNowEnabled && old?.logLevel != new.logLevel -> {
+ val oldLevel = old?.logLevel ?: LogLevel.ERROR
+ val newLevel = new.logLevel ?: LogLevel.ERROR
+ OtelConfigAction.UpdateLogLevel(oldLevel, newLevel)
+ }
+ // Everything else: no meaningful change
+ else -> OtelConfigAction.NoChange
+ }
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt
new file mode 100644
index 000000000..1b8b97b58
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt
@@ -0,0 +1,240 @@
+package com.onesignal.internal
+
+import android.content.Context
+import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.common.modeling.ModelChangedArgs
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.crash.AnrConstants
+import com.onesignal.debug.internal.crash.OneSignalCrashHandlerFactory
+import com.onesignal.debug.internal.crash.OtelSdkSupport
+import com.onesignal.debug.internal.crash.createAnrDetector
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger
+import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider
+import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
+import com.onesignal.otel.IOtelCrashHandler
+import com.onesignal.otel.IOtelLogger
+import com.onesignal.otel.IOtelOpenTelemetryRemote
+import com.onesignal.otel.IOtelPlatformProvider
+import com.onesignal.otel.OtelFactory
+import com.onesignal.otel.crash.IOtelAnrDetector
+
+/**
+ * Owns the lifecycle of all Otel-based observability features and reacts
+ * to remote config changes so features can be enabled, disabled, or
+ * have their log level updated mid-session.
+ *
+ * Subscribes to [ConfigModelStore] via [ISingletonModelStoreChangeHandler]
+ * so that when fresh remote config arrives (HYDRATE), Otel features are
+ * automatically started, stopped, or updated.
+ *
+ * Thread safety: methods are synchronized on [lock] so that concurrent
+ * calls from initEssentials (main) and the config store callback (IO) are safe.
+ *
+ * All factory parameters default to the real implementations, so production
+ * callers can use `OtelLifecycleManager(context)`. Tests can override any
+ * factory to inject mocks or throwing stubs.
+ */
+@Suppress("TooManyFunctions")
+internal class OtelLifecycleManager(
+ private val context: Context,
+ private val crashHandlerFactory: (Context, IOtelLogger) -> IOtelCrashHandler =
+ { ctx, log -> OneSignalCrashHandlerFactory.createCrashHandler(ctx, log) },
+ private val anrDetectorFactory: (IOtelPlatformProvider, IOtelLogger, Long, Long) -> IOtelAnrDetector =
+ { pp, log, threshold, interval -> createAnrDetector(pp, log, threshold, interval) },
+ private val remoteTelemetryFactory: (IOtelPlatformProvider) -> IOtelOpenTelemetryRemote =
+ { pp -> OtelFactory.createRemoteTelemetry(pp) },
+ private val platformProviderFactory: (Context) -> OtelPlatformProvider =
+ { ctx -> createAndroidOtelPlatformProvider(ctx) },
+ private val loggerFactory: () -> IOtelLogger = { AndroidOtelLogger() },
+) : ISingletonModelStoreChangeHandler {
+ private val lock = Any()
+
+ private val platformProvider: OtelPlatformProvider by lazy {
+ platformProviderFactory(context)
+ }
+
+ private val logger: IOtelLogger by lazy { loggerFactory() }
+
+ private var crashHandler: IOtelCrashHandler? = null
+ private var anrDetector: IOtelAnrDetector? = null
+ private var remoteTelemetry: IOtelOpenTelemetryRemote? = null
+ private var currentConfig: OtelConfig? = null
+
+ /**
+ * Called once from [OneSignalImp.initEssentials] at cold start.
+ * Reads the cached config from SharedPreferences and boots
+ * whichever features are already enabled.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ fun initializeFromCachedConfig() {
+ if (!OtelSdkSupport.isSupported) {
+ Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping all Otel features")
+ return
+ }
+
+ try {
+ val cachedConfig = readCurrentCachedConfig()
+ synchronized(lock) {
+ val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = cachedConfig)
+ applyAction(action, cachedConfig)
+ }
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Failed to initialize Otel from cached config: ${t.message}", t)
+ }
+ }
+
+ /**
+ * Subscribes this manager to config store change events.
+ * Call after the IoC container is bootstrapped (i.e. after [bootstrapServices]).
+ */
+ fun subscribeToConfigStore(configModelStore: ConfigModelStore) {
+ configModelStore.subscribe(this)
+ }
+
+ // ------------------------------------------------------------------
+ // ISingletonModelStoreChangeHandler
+ // ------------------------------------------------------------------
+
+ @Suppress("TooGenericExceptionCaught")
+ override fun onModelReplaced(model: ConfigModel, tag: String) {
+ if (tag != ModelChangeTags.HYDRATE) return
+ if (!OtelSdkSupport.isSupported) return
+
+ try {
+ val logLevel = model.remoteLoggingParams.logLevel
+ val isEnabled = model.remoteLoggingParams.isEnabled
+ val newConfig = OtelConfig(isEnabled = isEnabled, logLevel = logLevel)
+ synchronized(lock) {
+ val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig)
+ applyAction(action, newConfig)
+ }
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Failed to refresh Otel from remote config: ${t.message}", t)
+ }
+ }
+
+ override fun onModelUpdated(args: ModelChangedArgs, tag: String) {
+ // We only care about full model replacements (HYDRATE), not individual property changes.
+ }
+
+ // ------------------------------------------------------------------
+ // Internal
+ // ------------------------------------------------------------------
+
+ private fun readCurrentCachedConfig(): OtelConfig {
+ val enabled = platformProvider.isRemoteLoggingEnabled
+ val level = LogLevel.fromString(platformProvider.remoteLogLevel)
+ return OtelConfig(isEnabled = enabled, logLevel = level)
+ }
+
+ /** Must be called while holding [lock]. */
+ @Suppress("TooGenericExceptionCaught")
+ private fun applyAction(action: OtelConfigAction, newConfig: OtelConfig) {
+ when (action) {
+ is OtelConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR)
+ is OtelConfigAction.Disable -> disableFeatures()
+ is OtelConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel)
+ is OtelConfigAction.NoChange -> {
+ Logging.debug("OneSignal: Otel config unchanged, no action needed")
+ }
+ }
+ currentConfig = newConfig
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun enableFeatures(logLevel: LogLevel) {
+ Logging.info("OneSignal: Enabling Otel features at level $logLevel")
+
+ try {
+ startCrashHandler()
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Failed to start crash handler: ${t.message}", t)
+ }
+
+ try {
+ startAnrDetector()
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Failed to start ANR detector: ${t.message}", t)
+ }
+
+ try {
+ startOtelLogging(logLevel)
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Failed to start Otel logging: ${t.message}", t)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun disableFeatures() {
+ Logging.info("OneSignal: Disabling Otel features")
+
+ try {
+ anrDetector?.stop()
+ anrDetector = null
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Error stopping ANR detector: ${t.message}", t)
+ }
+
+ try {
+ crashHandler?.unregister()
+ crashHandler = null
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Error unregistering crash handler: ${t.message}", t)
+ }
+
+ try {
+ Logging.setOtelTelemetry(null, { false })
+ remoteTelemetry?.shutdown()
+ remoteTelemetry = null
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Error disabling Otel logging: ${t.message}", t)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun updateLogLevel(newLevel: LogLevel) {
+ Logging.info("OneSignal: Updating Otel log level to $newLevel")
+ try {
+ startOtelLogging(newLevel)
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Failed to update Otel log level: ${t.message}", t)
+ }
+ }
+
+ private fun startCrashHandler() {
+ if (crashHandler != null) return
+ val handler = crashHandlerFactory(context, logger)
+ handler.initialize()
+ crashHandler = handler
+ Logging.info("OneSignal: Crash handler initialized — logs at: ${platformProvider.crashStoragePath}")
+ }
+
+ private fun startAnrDetector() {
+ if (anrDetector != null) return
+ val detector = anrDetectorFactory(
+ platformProvider,
+ logger,
+ AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
+ AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
+ )
+ detector.start()
+ anrDetector = detector
+ Logging.info("OneSignal: ANR detector started")
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun startOtelLogging(logLevel: LogLevel) {
+ remoteTelemetry?.shutdown()
+ val telemetry = remoteTelemetryFactory(platformProvider)
+ remoteTelemetry = telemetry
+ val shouldSend: (LogLevel) -> Boolean = { level ->
+ logLevel != LogLevel.NONE && level <= logLevel
+ }
+ Logging.setOtelTelemetry(telemetry, shouldSend)
+ Logging.info("OneSignal: Otel logging active at level $logLevel")
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt
index ac099f404..25cac810c 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt
@@ -40,12 +40,13 @@ class OtelAnrDetectorTest : FunSpec({
every { mockPlatformProvider.onesignalId } returns null
every { mockPlatformProvider.pushSubscriptionId } returns null
every { mockPlatformProvider.appState } returns "foreground"
- every { mockPlatformProvider.processUptime } returns 100.0
+ every { mockPlatformProvider.processUptime } returns 100L
every { mockPlatformProvider.currentThreadName } returns "main"
every { mockPlatformProvider.crashStoragePath } returns "/test/path"
every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L
every { mockPlatformProvider.remoteLogLevel } returns "ERROR"
every { mockPlatformProvider.appIdForHeaders } returns "test-app-id"
+ every { mockPlatformProvider.apiBaseUrl } returns "https://api.onesignal.com"
coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id"
}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt
index bcce46424..f7cf09c7d 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt
@@ -111,7 +111,7 @@ class OtelIntegrationTest : FunSpec({
provider.onesignalId shouldBe "test-onesignal-id"
provider.pushSubscriptionId shouldBe "test-subscription-id"
provider.appState shouldBeOneOf listOf("foreground", "background", "unknown")
- (provider.processUptime > 0.0) shouldBe true
+ (provider.processUptime > 0) shouldBe true
provider.currentThreadName shouldBe Thread.currentThread().name
}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt
new file mode 100644
index 000000000..f7660108e
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt
@@ -0,0 +1,38 @@
+package com.onesignal.debug.internal.crash
+
+import android.os.Build
+import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import org.robolectric.annotation.Config
+
+@RobolectricTest
+@Config(sdk = [Build.VERSION_CODES.O])
+class OtelSdkSupportTest : FunSpec({
+
+ afterEach {
+ OtelSdkSupport.reset()
+ }
+
+ test("isSupported is true on SDK >= 26") {
+ OtelSdkSupport.reset()
+ OtelSdkSupport.isSupported shouldBe true
+ }
+
+ test("isSupported can be overridden to false for testing") {
+ OtelSdkSupport.isSupported = false
+ OtelSdkSupport.isSupported shouldBe false
+ }
+
+ test("reset restores runtime-detected value") {
+ OtelSdkSupport.isSupported = false
+ OtelSdkSupport.isSupported shouldBe false
+
+ OtelSdkSupport.reset()
+ OtelSdkSupport.isSupported shouldBe true
+ }
+
+ test("MIN_SDK_VERSION is 26") {
+ OtelSdkSupport.MIN_SDK_VERSION shouldBe 26
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt
index e070c4e92..86be0f189 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt
@@ -569,6 +569,117 @@ class OtelIdResolverTest : FunSpec({
result shouldBe null
}
+ // ===== resolveRemoteLoggingEnabled Tests =====
+ // Enabled is derived from presence of a valid logLevel:
+ // "logging_config": {} → disabled (not on allowlist)
+ // "logging_config": {"log_level": "ERROR"} → enabled (on allowlist)
+
+ test("resolveRemoteLoggingEnabled returns true when logLevel is ERROR") {
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "ERROR")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe true
+ }
+
+ test("resolveRemoteLoggingEnabled returns true when logLevel is WARN") {
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "WARN")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe true
+ }
+
+ test("resolveRemoteLoggingEnabled returns false when logLevel is NONE") {
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "NONE")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("resolveRemoteLoggingEnabled returns false when logLevel field is missing (empty logging_config)") {
+ val remoteLoggingParams = JSONObject()
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("resolveRemoteLoggingEnabled returns false when remoteLoggingParams is missing") {
+ val configModel = JSONObject()
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("resolveRemoteLoggingEnabled returns false when no config exists") {
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("resolveRemoteLoggingEnabled returns false when logLevel is invalid") {
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "INVALID_LEVEL")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
// ===== resolveRemoteLogLevel Tests =====
test("resolveRemoteLogLevel returns LogLevel from ConfigModelStore when available") {
@@ -750,6 +861,113 @@ class OtelIdResolverTest : FunSpec({
result shouldBe null
}
+ // ===== extractLogLevelFromParams Tests (via resolveRemoteLogLevel / resolveRemoteLoggingEnabled) =====
+ // These test the exact JSON shapes received from the backend.
+
+ test("extractLogLevelFromParams: {logLevel:NONE, isEnabled:false} returns NONE and disabled") {
+ val remoteLoggingParams = JSONObject("""{"logLevel":"NONE","isEnabled":false}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe LogLevel.NONE
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("extractLogLevelFromParams: {logLevel:ERROR, isEnabled:true} returns ERROR and enabled") {
+ val remoteLoggingParams = JSONObject("""{"logLevel":"ERROR","isEnabled":true}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe LogLevel.ERROR
+ resolver.resolveRemoteLoggingEnabled() shouldBe true
+ }
+
+ test("extractLogLevelFromParams: {isEnabled:false} with no logLevel returns null and disabled") {
+ val remoteLoggingParams = JSONObject("""{"isEnabled":false}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe null
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("extractLogLevelFromParams: empty object {} returns null and disabled") {
+ val remoteLoggingParams = JSONObject("""{}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe null
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("extractLogLevelFromParams: {logLevel:WARN} without isEnabled returns WARN and enabled") {
+ val remoteLoggingParams = JSONObject("""{"logLevel":"WARN"}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe LogLevel.WARN
+ resolver.resolveRemoteLoggingEnabled() shouldBe true
+ }
+
+ test("extractLogLevelFromParams: {logLevel:error} lowercase returns ERROR (case-insensitive)") {
+ val remoteLoggingParams = JSONObject("""{"logLevel":"error","isEnabled":true}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe LogLevel.ERROR
+ resolver.resolveRemoteLoggingEnabled() shouldBe true
+ }
+
+ test("extractLogLevelFromParams: {logLevel:INVALID} returns null and disabled") {
+ val remoteLoggingParams = JSONObject("""{"logLevel":"INVALID","isEnabled":true}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe null
+ resolver.resolveRemoteLoggingEnabled() shouldBe false
+ }
+
+ test("extractLogLevelFromParams: isEnabled field does not influence logLevel resolution") {
+ val remoteLoggingParams = JSONObject("""{"logLevel":"ERROR","isEnabled":false}""")
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ writeAndVerifyConfigData(configArray)
+
+ val resolver = OtelIdResolver(appContext!!)
+ resolver.resolveRemoteLogLevel() shouldBe LogLevel.ERROR
+ resolver.resolveRemoteLoggingEnabled() shouldBe true
+ }
+
// ===== resolveInstallId Tests =====
test("resolveInstallId returns installId from SharedPreferences when available") {
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt
index 95dd77839..f47e3b65a 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt
@@ -408,7 +408,7 @@ class OtelPlatformProviderTest : FunSpec({
// ===== processUptime Tests =====
- test("processUptime returns uptime in seconds") {
+ test("processUptime returns uptime in milliseconds") {
// Given
val provider = createAndroidOtelPlatformProvider(appContext!!)
@@ -416,7 +416,7 @@ class OtelPlatformProviderTest : FunSpec({
val result = provider.processUptime
// Then
- (result > 0.0) shouldBe true
+ (result >= 0) shouldBe true
(result < 1000000.0) shouldBe true // Reasonable upper bound
}
@@ -496,9 +496,82 @@ class OtelPlatformProviderTest : FunSpec({
result shouldBe 5_000L
}
+ // ===== isRemoteLoggingEnabled Tests =====
+ // Derived from logLevel presence: empty logging_config → disabled, has log_level → enabled
+
+ test("isRemoteLoggingEnabled returns false when no config exists") {
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+ provider.isRemoteLoggingEnabled shouldBe false
+ }
+
+ test("isRemoteLoggingEnabled returns true when config has logLevel ERROR") {
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "ERROR")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+ provider.isRemoteLoggingEnabled shouldBe true
+ }
+
+ test("isRemoteLoggingEnabled returns false when logging_config is empty (no logLevel)") {
+ val remoteLoggingParams = JSONObject()
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+ provider.isRemoteLoggingEnabled shouldBe false
+ }
+
+ test("isRemoteLoggingEnabled returns false when logLevel is NONE") {
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "NONE")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+ provider.isRemoteLoggingEnabled shouldBe false
+ }
+
+ test("isRemoteLoggingEnabled returns false when exception occurs") {
+ val mockContext = mockk(relaxed = true)
+ every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception")
+ val config = OtelPlatformProviderConfig(
+ crashStoragePath = "/test/path",
+ appPackageId = "com.test",
+ appVersion = "1.0",
+ context = mockContext
+ )
+ val provider = OtelPlatformProvider(config)
+ provider.isRemoteLoggingEnabled shouldBe false
+ }
+
// ===== remoteLogLevel Tests =====
- test("remoteLogLevel returns ERROR when configLevel is null") {
+ test("remoteLogLevel returns null when no config exists (disabled)") {
// Given
val provider = createAndroidOtelPlatformProvider(appContext!!)
@@ -506,7 +579,29 @@ class OtelPlatformProviderTest : FunSpec({
val result = provider.remoteLogLevel
// Then
- result shouldBe "ERROR"
+ result shouldBe null
+ }
+
+ test("remoteLogLevel returns null when logging_config is empty (disabled)") {
+ // Given
+ val remoteLoggingParams = JSONObject()
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+
+ // When
+ val result = provider.remoteLogLevel
+
+ // Then
+ result shouldBe null
}
test("remoteLogLevel returns configLevel name when available") {
@@ -533,6 +628,30 @@ class OtelPlatformProviderTest : FunSpec({
result shouldBe "WARN"
}
+ test("remoteLogLevel returns ERROR when configLevel is ERROR") {
+ // Given
+ val remoteLoggingParams = JSONObject().apply {
+ put("logLevel", "ERROR")
+ }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply {
+ put(configModel)
+ }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+
+ // When
+ val result = provider.remoteLogLevel
+
+ // Then
+ result shouldBe "ERROR"
+ }
+
test("remoteLogLevel returns NONE when configLevel is NONE") {
// Given
val remoteLoggingParams = JSONObject().apply {
@@ -557,7 +676,7 @@ class OtelPlatformProviderTest : FunSpec({
result shouldBe "NONE"
}
- test("remoteLogLevel returns ERROR when exception occurs") {
+ test("remoteLogLevel returns null when exception occurs") {
// Given
val mockContext = mockk(relaxed = true)
every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception")
@@ -573,7 +692,7 @@ class OtelPlatformProviderTest : FunSpec({
val result = provider.remoteLogLevel
// Then
- result shouldBe "ERROR"
+ result shouldBe null
}
// ===== appIdForHeaders Tests =====
@@ -610,6 +729,14 @@ class OtelPlatformProviderTest : FunSpec({
result shouldNotBe null
}
+ // ===== apiBaseUrl Tests =====
+
+ test("apiBaseUrl returns the core module base URL") {
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+
+ provider.apiBaseUrl shouldBe com.onesignal.core.internal.http.OneSignalService.ONESIGNAL_API_BASE_URL
+ }
+
// ===== getInstallId Tests =====
test("getInstallId returns installId from SharedPreferences") {
@@ -650,6 +777,104 @@ class OtelPlatformProviderTest : FunSpec({
provider.osName shouldBe "Android"
}
+ // ===== Fresh install / all-missing scenario =====
+
+ test("fresh install: all lazy properties return safe defaults without crashing") {
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+
+ provider.appId shouldContain "e1100000-0000-4000-a000-"
+ provider.onesignalId shouldBe null
+ provider.pushSubscriptionId shouldBe null
+ provider.isRemoteLoggingEnabled shouldBe false
+ provider.remoteLogLevel shouldBe null
+ provider.appIdForHeaders shouldNotBe null
+ provider.sdkBase shouldBe "android"
+ provider.osName shouldBe "Android"
+ provider.crashStoragePath shouldContain "onesignal"
+ }
+
+ test("lazy properties cache the initial value and ignore later SharedPreferences changes") {
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+
+ provider.isRemoteLoggingEnabled shouldBe false
+ provider.remoteLogLevel shouldBe null
+
+ val remoteLoggingParams = JSONObject().apply { put("logLevel", "ERROR") }
+ val configModel = JSONObject().apply {
+ put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
+ }
+ val configArray = JSONArray().apply { put(configModel) }
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
+ .commit()
+
+ provider.isRemoteLoggingEnabled shouldBe false
+ provider.remoteLogLevel shouldBe null
+ }
+
+ test("getIsInForeground callback throws — appState returns unknown") {
+ val config = OtelPlatformProviderConfig(
+ crashStoragePath = "/test/path",
+ appPackageId = "com.test",
+ appVersion = "1.0",
+ context = appContext,
+ getIsInForeground = { throw RuntimeException("callback boom") }
+ )
+ val provider = OtelPlatformProvider(config)
+ provider.appState shouldBe "unknown"
+ }
+
+ test("getIsInForeground returns null — falls back to ActivityManager") {
+ val config = OtelPlatformProviderConfig(
+ crashStoragePath = "/test/path",
+ appPackageId = "com.test",
+ appVersion = "1.0",
+ context = appContext,
+ getIsInForeground = { null }
+ )
+ val provider = OtelPlatformProvider(config)
+ provider.appState shouldBeOneOf listOf("foreground", "background", "unknown")
+ }
+
+ test("null context and null callback — all provider properties return safe defaults") {
+ val config = OtelPlatformProviderConfig(
+ crashStoragePath = "/test/path",
+ appPackageId = "com.test",
+ appVersion = "1.0",
+ context = null,
+ getIsInForeground = null
+ )
+ val provider = OtelPlatformProvider(config)
+
+ provider.appState shouldBe "unknown"
+ provider.appPackageId shouldBe "com.test"
+ provider.appVersion shouldBe "1.0"
+ provider.crashStoragePath shouldBe "/test/path"
+ provider.isRemoteLoggingEnabled shouldBe false
+ provider.remoteLogLevel shouldBe null
+ }
+
+ test("corrupted SharedPreferences JSON — isRemoteLoggingEnabled returns false") {
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "not valid json {{{")
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+ provider.isRemoteLoggingEnabled shouldBe false
+ provider.remoteLogLevel shouldBe null
+ }
+
+ test("corrupted SharedPreferences JSON — appId returns error UUID") {
+ sharedPreferences!!.edit()
+ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "not valid json {{{")
+ .commit()
+
+ val provider = createAndroidOtelPlatformProvider(appContext!!)
+ provider.appId shouldContain "e1100000-0000-4000-a000-"
+ }
+
+ // ===== Factory Function Tests =====
+
test("createAndroidOtelPlatformProvider handles null appVersion gracefully") {
// Given
val mockContext = mockk(relaxed = true)
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt
deleted file mode 100644
index 12758e0a4..000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt
+++ /dev/null
@@ -1,348 +0,0 @@
-package com.onesignal.internal
-
-import android.content.Context
-import android.os.Build
-import androidx.test.core.app.ApplicationProvider
-import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
-import com.onesignal.core.internal.config.ConfigModel
-import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
-import com.onesignal.core.internal.preferences.PreferenceStores
-import com.onesignal.debug.LogLevel
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.otel.IOtelCrashHandler
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldNotBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
-import org.json.JSONArray
-import org.json.JSONObject
-import org.robolectric.annotation.Config
-import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace
-
-@RobolectricTest
-@Config(sdk = [Build.VERSION_CODES.O])
-class OneSignalCrashLogInitTest : FunSpec({
-
- val context: Context = ApplicationProvider.getApplicationContext()
- val sharedPreferences = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE)
-
- beforeAny {
- Logging.logLevel = LogLevel.NONE
- // Clear SharedPreferences before each test
- sharedPreferences.edit().clear().commit()
- }
-
- afterAny {
- // Clean up after each test
- sharedPreferences.edit().clear().commit()
- // Restore default uncaught exception handler
- Thread.setDefaultUncaughtExceptionHandler(null)
- }
-
- // ===== Platform Provider Reuse Tests =====
-
- test("platform provider should be created once and reused") {
- // Given
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When - initialize crash handler (creates platform provider)
- crashLogInit.initializeCrashHandler()
-
- // Then - initialize logging should reuse the same platform provider
- // We can't directly access the private property, but we can verify behavior
- // by checking that both initializations succeed without errors
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(100) // Give async initialization time to complete
- }
-
- // If we got here without exceptions, the platform provider was reused successfully
- }
-
- test("should create instance with context") {
- // Given & When
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // Then
- crashLogInit shouldNotBe null
- }
-
- // ===== Crash Handler Initialization Tests =====
-
- test("initializeCrashHandler should create and initialize crash handler") {
- // Given
- val crashLogInit = OneSignalCrashLogInit(context)
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
-
- // When
- crashLogInit.initializeCrashHandler()
-
- // Then
- val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
- currentHandler shouldNotBe null
- currentHandler.shouldBeInstanceOf()
-
- // Cleanup
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("initializeCrashHandler should handle exceptions gracefully") {
- // Given
- val mockContext = io.mockk.mockk(relaxed = true)
- io.mockk.every { mockContext.cacheDir } throws RuntimeException("Test exception")
- io.mockk.every { mockContext.packageName } returns "com.test"
- io.mockk.every { mockContext.getSharedPreferences(any(), any()) } returns sharedPreferences
-
- val crashLogInit = OneSignalCrashLogInit(mockContext)
-
- // When & Then - should not throw
- crashLogInit.initializeCrashHandler()
- }
-
- test("initializeCrashHandler should initialize ANR detector") {
- // Given
- val crashLogInit = OneSignalCrashLogInit(context)
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
-
- // When
- crashLogInit.initializeCrashHandler()
-
- // Then - ANR detector should be started (we can't directly verify, but no exception means success)
- // The method logs success, so if it doesn't throw, it worked
-
- // Cleanup
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("initializeCrashHandler can be called multiple times safely") {
- // Given
- val crashLogInit = OneSignalCrashLogInit(context)
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
-
- // When
- crashLogInit.initializeCrashHandler()
- crashLogInit.initializeCrashHandler() // Call again
-
- // Then - should not throw or cause issues
- val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
- currentHandler shouldNotBe null
-
- // Cleanup
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- // ===== Otel Logging Initialization Tests =====
-
- test("initializeOtelLogging should initialize remote telemetry when enabled") {
- // Given
- val remoteLoggingParams = JSONObject().apply {
- put("logLevel", "ERROR")
- }
- val configModel = JSONObject().apply {
- put(ConfigModel::appId.name, "test-app-id")
- put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
- }
- val configArray = JSONArray().apply {
- put(configModel)
- }
- sharedPreferences.edit()
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
- .commit()
-
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(200) // Give async initialization time to complete
- }
-
- // Then - should not throw, telemetry should be set
- // We can't directly verify Logging.setOtelTelemetry was called, but no exception means success
- }
-
- test("initializeOtelLogging should skip initialization when remote logging is disabled") {
- // Given
- val remoteLoggingParams = JSONObject().apply {
- put("logLevel", "NONE")
- }
- val configModel = JSONObject().apply {
- put(ConfigModel::appId.name, "test-app-id")
- put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
- }
- val configArray = JSONArray().apply {
- put(configModel)
- }
- sharedPreferences.edit()
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
- .commit()
-
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(100) // Give async initialization time to complete
- }
-
- // Then - should not throw, should skip initialization
- }
-
- test("initializeOtelLogging should default to ERROR when remote log level is not configured") {
- // Given - no remote logging config in SharedPreferences
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(200) // Give async initialization time to complete
- }
-
- // Then - should default to ERROR level and initialize
- // No exception means it worked
- }
-
- test("initializeOtelLogging should handle exceptions gracefully") {
- // Given
- val mockContext = io.mockk.mockk(relaxed = true)
- io.mockk.every { mockContext.cacheDir } returns context.cacheDir
- io.mockk.every { mockContext.packageName } returns "com.test"
- io.mockk.every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception")
-
- val crashLogInit = OneSignalCrashLogInit(mockContext)
-
- // When & Then - should not throw
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(100)
- }
- }
-
- test("initializeOtelLogging can be called multiple times safely") {
- // Given
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(100)
- crashLogInit.initializeOtelLogging() // Call again
- delay(100)
- }
-
- // Then - should not throw or cause issues
- }
-
- // ===== Integration Tests =====
-
- test("both initializeCrashHandler and initializeOtelLogging should work together") {
- // Given
- val remoteLoggingParams = JSONObject().apply {
- put("logLevel", "WARN")
- }
- val configModel = JSONObject().apply {
- put(ConfigModel::appId.name, "test-app-id")
- put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
- }
- val configArray = JSONArray().apply {
- put(configModel)
- }
- sharedPreferences.edit()
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
- .commit()
-
- val crashLogInit = OneSignalCrashLogInit(context)
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
-
- // When
- crashLogInit.initializeCrashHandler()
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(200)
- }
-
- // Then - both should succeed
- val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
- currentHandler shouldNotBe null
-
- // Cleanup
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("should work with different log levels") {
- // Given
- val logLevels = listOf("ERROR", "WARN", "INFO", "DEBUG", "VERBOSE")
-
- logLevels.forEach { level ->
- val remoteLoggingParams = JSONObject().apply {
- put("logLevel", level)
- }
- val configModel = JSONObject().apply {
- put(ConfigModel::appId.name, "test-app-id")
- put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
- }
- val configArray = JSONArray().apply {
- put(configModel)
- }
- sharedPreferences.edit().clear().commit()
- sharedPreferences.edit()
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
- .commit()
-
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(100)
- }
-
- // Then - should not throw for any log level
- }
- }
-
- test("should handle invalid log level gracefully") {
- // Given
- val remoteLoggingParams = JSONObject().apply {
- put("logLevel", "INVALID_LEVEL")
- }
- val configModel = JSONObject().apply {
- put(ConfigModel::appId.name, "test-app-id")
- put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
- }
- val configArray = JSONArray().apply {
- put(configModel)
- }
- sharedPreferences.edit()
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
- .commit()
-
- val crashLogInit = OneSignalCrashLogInit(context)
-
- // When & Then - should default to ERROR and not throw
- runBlocking {
- crashLogInit.initializeOtelLogging()
- delay(100)
- }
- }
-
- // ===== Context Handling Tests =====
-
- test("should work with different contexts") {
- // Given
- val context1: Context = ApplicationProvider.getApplicationContext()
- val context2: Context = ApplicationProvider.getApplicationContext()
-
- val crashLogInit1 = OneSignalCrashLogInit(context1)
- val crashLogInit2 = OneSignalCrashLogInit(context2)
-
- // When
- crashLogInit1.initializeCrashHandler()
- crashLogInit2.initializeCrashHandler()
-
- // Then - both should work independently
- val handler1 = Thread.getDefaultUncaughtExceptionHandler()
- handler1 shouldNotBe null
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt
new file mode 100644
index 000000000..6fd5478cd
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt
@@ -0,0 +1,102 @@
+package com.onesignal.internal
+
+import com.onesignal.debug.LogLevel
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.shouldBeInstanceOf
+
+class OtelConfigEvaluatorTest : FunSpec({
+
+ // ---- null -> enabled ----
+
+ test("null old config and new enabled returns Enable with the configured level") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = null,
+ new = OtelConfig(isEnabled = true, logLevel = LogLevel.WARN),
+ )
+ result.shouldBeInstanceOf()
+ result.logLevel shouldBe LogLevel.WARN
+ }
+
+ test("null old config and new enabled with null logLevel defaults to ERROR") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = null,
+ new = OtelConfig(isEnabled = true, logLevel = null),
+ )
+ result.shouldBeInstanceOf()
+ result.logLevel shouldBe LogLevel.ERROR
+ }
+
+ // ---- null -> disabled ----
+
+ test("null old config and new disabled returns NoChange") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = null,
+ new = OtelConfig(isEnabled = false, logLevel = null),
+ )
+ result shouldBe OtelConfigAction.NoChange
+ }
+
+ // ---- disabled -> enabled ----
+
+ test("disabled to enabled returns Enable") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = OtelConfig.DISABLED,
+ new = OtelConfig(isEnabled = true, logLevel = LogLevel.INFO),
+ )
+ result.shouldBeInstanceOf()
+ result.logLevel shouldBe LogLevel.INFO
+ }
+
+ // ---- enabled -> disabled ----
+
+ test("enabled to disabled returns Disable") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ new = OtelConfig(isEnabled = false, logLevel = null),
+ )
+ result shouldBe OtelConfigAction.Disable
+ }
+
+ // ---- enabled -> enabled (level changed) ----
+
+ test("enabled to enabled with different log level returns UpdateLogLevel") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ new = OtelConfig(isEnabled = true, logLevel = LogLevel.WARN),
+ )
+ result.shouldBeInstanceOf()
+ result.oldLevel shouldBe LogLevel.ERROR
+ result.newLevel shouldBe LogLevel.WARN
+ }
+
+ test("enabled with null level to enabled with explicit level returns UpdateLogLevel") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = OtelConfig(isEnabled = true, logLevel = null),
+ new = OtelConfig(isEnabled = true, logLevel = LogLevel.WARN),
+ )
+ result.shouldBeInstanceOf()
+ result.oldLevel shouldBe LogLevel.ERROR
+ result.newLevel shouldBe LogLevel.WARN
+ }
+
+ // ---- enabled -> enabled (same level) ----
+
+ test("enabled to enabled with same level returns NoChange") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ new = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ )
+ result shouldBe OtelConfigAction.NoChange
+ }
+
+ // ---- disabled -> disabled ----
+
+ test("disabled to disabled returns NoChange") {
+ val result = OtelConfigEvaluator.evaluate(
+ old = OtelConfig.DISABLED,
+ new = OtelConfig.DISABLED,
+ )
+ result shouldBe OtelConfigAction.NoChange
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt
new file mode 100644
index 000000000..7cd5fb641
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt
@@ -0,0 +1,311 @@
+package com.onesignal.internal
+
+import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.crash.OtelSdkSupport
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider
+import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProviderConfig
+import com.onesignal.otel.IOtelCrashHandler
+import com.onesignal.otel.IOtelLogger
+import com.onesignal.otel.IOtelOpenTelemetryRemote
+import com.onesignal.otel.IOtelPlatformProvider
+import com.onesignal.otel.crash.IOtelAnrDetector
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.robolectric.annotation.Config
+
+/**
+ * Fault injection tests that prove all try/catch(Throwable) wrappers in
+ * [OtelLifecycleManager] actually catch and suppress exceptions, and that
+ * a failure in one feature does not prevent others from starting.
+ */
+@RobolectricTest
+@Config(sdk = [Build.VERSION_CODES.O])
+class OtelLifecycleManagerFaultTest : FunSpec({
+
+ lateinit var context: Context
+ lateinit var mockCrashHandler: IOtelCrashHandler
+ lateinit var mockAnrDetector: IOtelAnrDetector
+ lateinit var mockTelemetry: IOtelOpenTelemetryRemote
+ lateinit var mockLogger: IOtelLogger
+ lateinit var mockPlatformProvider: OtelPlatformProvider
+
+ beforeEach {
+ context = ApplicationProvider.getApplicationContext()
+ OtelSdkSupport.isSupported = true
+
+ mockCrashHandler = mockk(relaxed = true)
+ mockAnrDetector = mockk(relaxed = true)
+ mockTelemetry = mockk(relaxed = true)
+ mockLogger = mockk(relaxed = true)
+ mockPlatformProvider = OtelPlatformProvider(
+ OtelPlatformProviderConfig(
+ crashStoragePath = "/test/path",
+ appPackageId = "com.test",
+ appVersion = "1.0",
+ context = context,
+ )
+ )
+ }
+
+ afterEach {
+ OtelSdkSupport.reset()
+ Logging.setOtelTelemetry(null) { false }
+ }
+
+ fun createManager(
+ crashFactory: (Context, IOtelLogger) -> IOtelCrashHandler = { _, _ -> mockCrashHandler },
+ anrFactory: (IOtelPlatformProvider, IOtelLogger, Long, Long) -> IOtelAnrDetector = { _, _, _, _ -> mockAnrDetector },
+ telemetryFactory: (IOtelPlatformProvider) -> IOtelOpenTelemetryRemote = { mockTelemetry },
+ ppFactory: (Context) -> OtelPlatformProvider = { mockPlatformProvider },
+ ): OtelLifecycleManager =
+ OtelLifecycleManager(
+ context = context,
+ crashHandlerFactory = crashFactory,
+ anrDetectorFactory = anrFactory,
+ remoteTelemetryFactory = telemetryFactory,
+ platformProviderFactory = ppFactory,
+ loggerFactory = { mockLogger },
+ )
+
+ // ------------------------------------------------------------------
+ // Factory-level fault injection: factory itself throws
+ // ------------------------------------------------------------------
+
+ test("crash handler factory throws — ANR and logging still start") {
+ var telemetryCreated = false
+ val manager = createManager(
+ crashFactory = { _, _ -> throw RuntimeException("crash factory boom") },
+ telemetryFactory = { telemetryCreated = true; mockTelemetry },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockAnrDetector.start() }
+ telemetryCreated shouldBe true
+ }
+
+ test("ANR factory throws — crash handler and logging still start") {
+ var telemetryCreated = false
+ val manager = createManager(
+ anrFactory = { _, _, _, _ -> throw RuntimeException("anr factory boom") },
+ telemetryFactory = { telemetryCreated = true; mockTelemetry },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockCrashHandler.initialize() }
+ telemetryCreated shouldBe true
+ }
+
+ test("telemetry factory throws — crash handler and ANR still start") {
+ val manager = createManager(
+ telemetryFactory = { throw RuntimeException("telemetry factory boom") },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockCrashHandler.initialize() }
+ verify(exactly = 1) { mockAnrDetector.start() }
+ }
+
+ test("all three factories throw — no exception propagates") {
+ val manager = createManager(
+ crashFactory = { _, _ -> throw RuntimeException("crash") },
+ anrFactory = { _, _, _, _ -> throw RuntimeException("anr") },
+ telemetryFactory = { throw RuntimeException("telemetry") },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ }
+
+ // ------------------------------------------------------------------
+ // Initialize-level fault injection: object created but init throws
+ // ------------------------------------------------------------------
+
+ test("crash handler initialize() throws — ANR and logging still start") {
+ every { mockCrashHandler.initialize() } throws RuntimeException("init boom")
+ var telemetryCreated = false
+
+ val manager = createManager(
+ telemetryFactory = { telemetryCreated = true; mockTelemetry },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockAnrDetector.start() }
+ telemetryCreated shouldBe true
+ }
+
+ test("ANR detector start() throws — crash handler and logging still start") {
+ every { mockAnrDetector.start() } throws RuntimeException("start boom")
+ var telemetryCreated = false
+
+ val manager = createManager(
+ telemetryFactory = { telemetryCreated = true; mockTelemetry },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockCrashHandler.initialize() }
+ telemetryCreated shouldBe true
+ }
+
+ // ------------------------------------------------------------------
+ // Disable-level fault injection: shutdown/stop/unregister throws
+ // ------------------------------------------------------------------
+
+ test("ANR stop() throws during disable — crash unregister and telemetry shutdown still run") {
+ every { mockAnrDetector.stop() } throws RuntimeException("stop boom")
+
+ val manager = createManager()
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockCrashHandler.unregister() }
+ verify(exactly = 1) { mockTelemetry.shutdown() }
+ }
+
+ test("crash handler unregister() throws during disable — telemetry shutdown still runs") {
+ every { mockCrashHandler.unregister() } throws RuntimeException("unregister boom")
+
+ val manager = createManager()
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockTelemetry.shutdown() }
+ }
+
+ test("telemetry shutdown() throws during disable — no exception propagates") {
+ every { mockTelemetry.shutdown() } throws RuntimeException("shutdown boom")
+
+ val manager = createManager()
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockAnrDetector.stop() }
+ verify(exactly = 1) { mockCrashHandler.unregister() }
+ }
+
+ // ------------------------------------------------------------------
+ // Platform provider fault injection
+ // ------------------------------------------------------------------
+
+ test("platform provider factory throws — initializeFromCachedConfig does not propagate") {
+ val manager = createManager(
+ ppFactory = { throw RuntimeException("provider boom") },
+ )
+ manager.initializeFromCachedConfig()
+ }
+
+ // ------------------------------------------------------------------
+ // UpdateLogLevel fault injection
+ // ------------------------------------------------------------------
+
+ test("telemetry factory throws during log level update — no exception propagates") {
+ var callCount = 0
+ val manager = createManager(
+ telemetryFactory = {
+ callCount++
+ if (callCount > 1) throw RuntimeException("second create boom")
+ mockTelemetry
+ },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
+ }
+
+ // ------------------------------------------------------------------
+ // Idempotency: calling enable twice doesn't double-create
+ // ------------------------------------------------------------------
+
+ test("enable called twice does not create duplicate crash handler or ANR detector") {
+ val manager = createManager()
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 2) { mockCrashHandler.initialize() }
+ verify(exactly = 2) { mockAnrDetector.start() }
+ }
+
+ // ------------------------------------------------------------------
+ // Verify mock interactions in happy path
+ // ------------------------------------------------------------------
+
+ test("enable creates all three features and disable tears all down") {
+ val manager = createManager()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ verify(exactly = 1) { mockCrashHandler.initialize() }
+ verify(exactly = 1) { mockAnrDetector.start() }
+
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ verify(exactly = 1) { mockCrashHandler.unregister() }
+ verify(exactly = 1) { mockAnrDetector.stop() }
+ verify { mockTelemetry.shutdown() }
+ }
+
+ test("update log level shuts down old telemetry and creates new one") {
+ var createCount = 0
+ val telemetry1 = mockk(relaxed = true)
+ val telemetry2 = mockk(relaxed = true)
+ val manager = createManager(
+ telemetryFactory = {
+ createCount++
+ if (createCount == 1) telemetry1 else telemetry2
+ },
+ )
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { telemetry1.shutdown() }
+ createCount shouldBe 2
+ }
+
+ // ------------------------------------------------------------------
+ // Error type coverage: OutOfMemoryError, StackOverflowError
+ // ------------------------------------------------------------------
+
+ test("OutOfMemoryError from factory does not propagate") {
+ val manager = createManager(
+ crashFactory = { _, _ -> throw OutOfMemoryError("oom") },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockAnrDetector.start() }
+ }
+
+ test("StackOverflowError from factory does not propagate") {
+ val manager = createManager(
+ anrFactory = { _, _, _, _ -> throw StackOverflowError("stack overflow") },
+ )
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ verify(exactly = 1) { mockCrashHandler.initialize() }
+ }
+
+ // ------------------------------------------------------------------
+ // initializeFromCachedConfig fault injection
+ // ------------------------------------------------------------------
+
+ test("initializeFromCachedConfig catches factory failure and does not propagate") {
+ val manager = createManager(
+ crashFactory = { _, _ -> throw RuntimeException("crash") },
+ anrFactory = { _, _, _, _ -> throw RuntimeException("anr") },
+ telemetryFactory = { throw RuntimeException("telemetry") },
+ )
+ manager.initializeFromCachedConfig()
+ }
+})
+
+private fun configWith(isEnabled: Boolean, logLevel: LogLevel?): ConfigModel {
+ val config = ConfigModel()
+ config.remoteLoggingParams.isEnabled = isEnabled
+ logLevel?.let { config.remoteLoggingParams.logLevel = it }
+ return config
+}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt
new file mode 100644
index 000000000..c5c238034
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt
@@ -0,0 +1,103 @@
+package com.onesignal.internal
+
+import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.crash.OtelSdkSupport
+import com.onesignal.debug.internal.logging.Logging
+import io.kotest.core.spec.style.FunSpec
+import org.robolectric.annotation.Config
+
+@RobolectricTest
+@Config(sdk = [Build.VERSION_CODES.O])
+class OtelLifecycleManagerTest : FunSpec({
+ lateinit var context: Context
+
+ beforeEach {
+ context = ApplicationProvider.getApplicationContext()
+ OtelSdkSupport.isSupported = true
+ }
+
+ afterEach {
+ OtelSdkSupport.reset()
+ }
+
+ test("initializeFromCachedConfig does not crash when SDK unsupported") {
+ OtelSdkSupport.isSupported = false
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+ }
+
+ test("initializeFromCachedConfig does not throw on supported SDK") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+ }
+
+ test("onModelReplaced does not crash when SDK unsupported") {
+ OtelSdkSupport.isSupported = false
+ val manager = OtelLifecycleManager(context)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ }
+
+ test("onModelReplaced ignores non-HYDRATE tags") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.NORMAL)
+ }
+
+ test("onModelReplaced enable then disable does not throw") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ }
+
+ test("onModelReplaced updates log level without throwing") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
+ }
+
+ test("onModelReplaced with same config is no-op") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ }
+
+ test("disable clears Otel telemetry from Logging") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+
+ Logging.info("test message after otel disabled")
+ }
+
+ test("full lifecycle: init -> enable -> update level -> disable -> re-enable") {
+ val manager = OtelLifecycleManager(context)
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.INFO), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.DEBUG), ModelChangeTags.HYDRATE)
+ }
+})
+
+private fun configWith(isEnabled: Boolean, logLevel: LogLevel?): ConfigModel {
+ val config = ConfigModel()
+ config.remoteLoggingParams.isEnabled = isEnabled
+ logLevel?.let { config.remoteLoggingParams.logLevel = it }
+ return config
+}
diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt
index 5a23298c0..b4994a0ae 100644
--- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt
+++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt
@@ -445,7 +445,7 @@ internal class InAppMessagesManager(
Logging.debug("InAppMessagesManager.attemptToShowInAppMessage: $messageDisplayQueue")
// If there are IAMs in the queue and nothing showing, show first in the queue
if (paused) {
- Logging.warn(
+ Logging.debug(
"InAppMessagesManager.attemptToShowInAppMessage: In app messaging is currently paused, in app messages will not be shown!",
)
} else if (messageDisplayQueue.isEmpty()) {
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt
index bd3860eea..93b31fc75 100644
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt
+++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt
@@ -10,4 +10,10 @@ interface IOtelCrashHandler {
* before any other initialization that might crash.
*/
fun initialize()
+
+ /**
+ * Unregisters this crash handler, restoring the previous default handler.
+ * Safe to call even if [initialize] was never called (no-op in that case).
+ */
+ fun unregister()
}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt
index 6a1843d72..156df29ff 100644
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt
+++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt
@@ -23,6 +23,13 @@ interface IOtelOpenTelemetry {
* @return A CompletableResultCode indicating the flush operation result
*/
suspend fun forceFlush(): CompletableResultCode
+
+ /**
+ * Shuts down the underlying OpenTelemetry SDK, flushing pending data
+ * and releasing resources (exporters, logger providers, etc.).
+ * After this call the instance must not be reused.
+ */
+ fun shutdown()
}
/**
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt
index 83dfdb335..98978ee19 100644
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt
+++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt
@@ -30,7 +30,7 @@ interface IOtelPlatformProvider {
val onesignalId: String?
val pushSubscriptionId: String?
val appState: String // "foreground" or "background"
- val processUptime: Double // in seconds
+ val processUptime: Long // in milliseconds
val currentThreadName: String
// Crash-specific configuration
@@ -39,11 +39,26 @@ interface IOtelPlatformProvider {
// Remote logging configuration
/**
- * The minimum log level to send remotely as a string (e.g., "ERROR", "WARN", "NONE").
- * If null, defaults to ERROR level for client-side logging.
- * If "NONE", no logs (including errors) will be sent remotely.
+ * Whether remote logging (crash reporting, ANR detection, remote log shipping) is enabled.
+ * Derived from the presence of a valid log_level in logging_config:
+ * - "logging_config": {} → false (not on allowlist)
+ * - "logging_config": {"log_level": "ERROR"} → true (on allowlist)
+ * Defaults to false on first launch (before remote config is cached).
+ */
+ val isRemoteLoggingEnabled: Boolean
+
+ /**
+ * The minimum log level to send remotely as a string (e.g., "ERROR", "WARN").
+ * Null when logging_config is empty or not yet cached (disabled).
* Valid values: "NONE", "FATAL", "ERROR", "WARN", "INFO", "DEBUG", "VERBOSE"
*/
val remoteLogLevel: String?
val appIdForHeaders: String
+
+ /**
+ * Base URL for the OneSignal API (e.g. "https://api.onesignal.com").
+ * The Otel exporter appends "sdk/otel/v1/logs" to this.
+ * Sourced from the core module so all SDK traffic hits the same host.
+ */
+ val apiBaseUrl: String
}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt
index 6592e6be5..3e470f942 100644
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt
+++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt
@@ -67,6 +67,18 @@ internal abstract class OneSignalOpenTelemetryBase(
}
}
+ @Suppress("TooGenericExceptionCaught")
+ override fun shutdown() {
+ synchronized(lock) {
+ try {
+ sdkCachedValue?.shutdown()
+ } catch (_: Throwable) {
+ // Best-effort cleanup — never propagate Otel teardown failures
+ }
+ sdkCachedValue = null
+ }
+ }
+
companion object {
private const val FORCE_FLUSH_TIMEOUT_SECONDS = 10L
}
@@ -96,8 +108,10 @@ internal class OneSignalOpenTelemetryRemote(
)
}
+ private val apiBaseUrl: String get() = platformProvider.apiBaseUrl
+
override val logExporter by lazy {
- OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders, appId)
+ OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders, appId, apiBaseUrl)
}
override fun getSdkInstance(attributes: Map): OpenTelemetrySdk =
@@ -107,7 +121,8 @@ internal class OneSignalOpenTelemetryRemote(
OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create(
OtelConfigShared.ResourceConfig.create(attributes),
extraHttpHeaders,
- appId
+ appId,
+ apiBaseUrl,
)
).build()
}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt
index cd92d3cde..b6d877dda 100644
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt
+++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt
@@ -6,6 +6,13 @@ import io.opentelemetry.sdk.logs.export.LogRecordExporter
import java.time.Duration
internal class OtelConfigRemoteOneSignal {
+ companion object {
+ const val OTEL_PATH = "sdk/otel"
+
+ fun buildEndpoint(apiBaseUrl: String, appId: String): String =
+ "$apiBaseUrl$OTEL_PATH/v1/logs?app_id=$appId"
+ }
+
object LogRecordExporterConfig {
private const val EXPORTER_TIMEOUT_SECONDS = 10L
@@ -23,30 +30,28 @@ internal class OtelConfigRemoteOneSignal {
}
object SdkLoggerProviderConfig {
- const val BASE_URL = "https://api.onesignal.com/sdk/otel"
- // const val BASE_URL = "https://api.staging.onesignal.com/sdk/otel"
-
fun create(
resource: io.opentelemetry.sdk.resources.Resource,
extraHttpHeaders: Map,
appId: String,
+ apiBaseUrl: String,
): SdkLoggerProvider =
SdkLoggerProvider
.builder()
.setResource(resource)
.addLogRecordProcessor(
OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor(
- HttpRecordBatchExporter.create(extraHttpHeaders, appId)
+ HttpRecordBatchExporter.create(extraHttpHeaders, appId, apiBaseUrl)
)
).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits)
.build()
}
object HttpRecordBatchExporter {
- fun create(extraHttpHeaders: Map, appId: String) =
+ fun create(extraHttpHeaders: Map, appId: String, apiBaseUrl: String) =
LogRecordExporterConfig.otlpHttpLogRecordExporter(
extraHttpHeaders,
- "${SdkLoggerProviderConfig.BASE_URL}/v1/logs?app_id=$appId"
+ buildEndpoint(apiBaseUrl, appId)
)
}
}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt
index ebe744e04..9581c069e 100644
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt
+++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt
@@ -18,6 +18,8 @@ internal class OtelCrashHandler(
) : Thread.UncaughtExceptionHandler, com.onesignal.otel.IOtelCrashHandler {
private var existingHandler: Thread.UncaughtExceptionHandler? = null
private val seenThrowables: MutableList = mutableListOf()
+
+ @Volatile
private var initialized = false
override fun initialize() {
@@ -32,6 +34,17 @@ internal class OtelCrashHandler(
logger.info("OtelCrashHandler: ✅ Successfully initialized and registered as default uncaught exception handler")
}
+ override fun unregister() {
+ if (!initialized) {
+ logger.debug("OtelCrashHandler: Not initialized, nothing to unregister")
+ return
+ }
+ logger.info("OtelCrashHandler: Unregistering — restoring previous exception handler")
+ Thread.setDefaultUncaughtExceptionHandler(existingHandler)
+ existingHandler = null
+ initialized = false
+ }
+
override fun uncaughtException(thread: Thread, throwable: Throwable) {
// Ensure we never attempt to process the same throwable instance
// more than once. This would only happen if there was another crash
@@ -90,15 +103,8 @@ internal class OtelCrashHandler(
try {
runBlocking { crashReporter.saveCrash(thread, throwable) }
logger.info("OtelCrashHandler: Crash report saved successfully")
- } catch (e: RuntimeException) {
- // If crash reporting fails, at least try to log it
- logger.error("OtelCrashHandler: Failed to save crash report: ${e.message} - ${e.javaClass.simpleName}")
- } catch (e: java.io.IOException) {
- // Handle IO errors specifically
- logger.error("OtelCrashHandler: IO error saving crash report: ${e.message}")
- } catch (e: IllegalStateException) {
- // Handle illegal state errors
- logger.error("OtelCrashHandler: Illegal state error saving crash report: ${e.message}")
+ } catch (t: Throwable) {
+ logger.error("OtelCrashHandler: Failed to save crash report: ${t.message} - ${t.javaClass.simpleName}")
}
logger.info("OtelCrashHandler: Delegating to existing crash handler")
existingHandler?.uncaughtException(thread, throwable)
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt
index 837c04ed3..5bc57abf3 100644
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt
+++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt
@@ -32,11 +32,12 @@ class OneSignalOpenTelemetryTest : FunSpec({
every { mockPlatformProvider.onesignalId } returns "test-onesignal-id"
every { mockPlatformProvider.pushSubscriptionId } returns "test-subscription-id"
every { mockPlatformProvider.appState } returns "foreground"
- every { mockPlatformProvider.processUptime } returns 100.0
+ every { mockPlatformProvider.processUptime } returns 100L
every { mockPlatformProvider.currentThreadName } returns "main"
every { mockPlatformProvider.crashStoragePath } returns "/test/path"
every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L
every { mockPlatformProvider.remoteLogLevel } returns "ERROR"
+ every { mockPlatformProvider.apiBaseUrl } returns "https://api.onesignal.com"
}
beforeEach {
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt
index cf29a0f21..56f2ce5cc 100644
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt
+++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt
@@ -29,12 +29,13 @@ class OtelFactoryTest : FunSpec({
every { mockPlatformProvider.onesignalId } returns null
every { mockPlatformProvider.pushSubscriptionId } returns null
every { mockPlatformProvider.appState } returns "foreground"
- every { mockPlatformProvider.processUptime } returns 100.0
+ every { mockPlatformProvider.processUptime } returns 100L
every { mockPlatformProvider.currentThreadName } returns "main"
every { mockPlatformProvider.crashStoragePath } returns "/test/path"
every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L
every { mockPlatformProvider.remoteLogLevel } returns "ERROR"
every { mockPlatformProvider.appIdForHeaders } returns "test-app-id"
+ every { mockPlatformProvider.apiBaseUrl } returns "https://api.onesignal.com"
coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id"
}
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt
index 8a77c7535..6fec49283 100644
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt
+++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt
@@ -19,7 +19,7 @@ class OtelFieldsPerEventTest : FunSpec({
onesignalId: String? = "test-onesignal-id",
pushSubscriptionId: String? = "test-subscription-id",
appState: String = "foreground",
- processUptime: Double = 100.5,
+ processUptime: Long = 100,
threadName: String = "main-thread"
) {
every { mockPlatformProvider.appId } returns appId
@@ -43,7 +43,7 @@ class OtelFieldsPerEventTest : FunSpec({
attributes["ossdk.onesignal_id"] shouldBe "test-onesignal-id"
attributes["ossdk.push_subscription_id"] shouldBe "test-subscription-id"
attributes["app.state"] shouldBe "foreground"
- attributes["process.uptime"] shouldBe "100.5"
+ attributes["process.uptime"] shouldBe "100"
attributes["thread.name"] shouldBe "main-thread"
}
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt
index a5591905c..f4f8daaf1 100644
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt
+++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt
@@ -57,15 +57,17 @@ class OtelConfigTest : FunSpec({
// ===== OtelConfigRemoteOneSignal Tests =====
- test("BASE_URL should point to production endpoint") {
- OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.BASE_URL shouldBe "https://api.onesignal.com/sdk/otel"
+ test("buildEndpoint should construct correct URL from base and appId") {
+ val endpoint = OtelConfigRemoteOneSignal.buildEndpoint("https://api.onesignal.com", "my-app")
+ endpoint shouldBe "https://api.onesignal.com/sdk/otel/v1/logs?app_id=my-app"
}
test("HttpRecordBatchExporter should create exporter with correct endpoint") {
val headers = mapOf("X-Test-Header" to "test-value")
val appId = "test-app-id"
+ val apiBaseUrl = "https://api.onesignal.com"
- val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId)
+ val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId, apiBaseUrl)
exporter shouldNotBe null
}
@@ -89,7 +91,8 @@ class OtelConfigTest : FunSpec({
val provider = OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create(
resource,
headers,
- "test-app-id"
+ "test-app-id",
+ "https://api.onesignal.com",
)
provider shouldNotBe null
diff --git a/examples/demo/app/build.gradle.kts b/examples/demo/app/build.gradle.kts
index 8aea10141..9dbda6eca 100644
--- a/examples/demo/app/build.gradle.kts
+++ b/examples/demo/app/build.gradle.kts
@@ -1,8 +1,11 @@
plugins {
id("com.android.application")
id("kotlin-android")
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"
}
+val kotlinVersion: String by rootProject.extra
+
// Apply GMS or Huawei plugin based on build variant
// Check at configuration time, not when task graph is ready
val taskRequests = gradle.startParameter.taskRequests.toString().lowercase()
@@ -33,10 +36,6 @@ android {
compose = true
}
- composeOptions {
- kotlinCompilerExtensionVersion = "1.5.14"
- }
-
flavorDimensions += "default"
productFlavors {
@@ -90,7 +89,7 @@ android {
dependencies {
// Kotlin
- implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// AndroidX
diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt
index 2cfe743f3..7df8fec38 100644
--- a/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt
+++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt
@@ -72,8 +72,9 @@ class MainApplication : MultiDexApplication() {
OneSignal.consentGiven = SharedPreferenceUtil.getUserPrivacyConsent(this)
// Initialize OneSignal on main thread (required)
+ // Crash handler + ANR detector are initialized early inside initWithContext
OneSignal.initWithContext(this, appId)
- LogManager.i(TAG, "OneSignal init completed")
+ LogManager.i(TAG, "OneSignal init completed (crash handler, ANR detector, and logging active)")
// Set up all OneSignal listeners
setupOneSignalListeners()
diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt
index 6c3ef7f6c..0655dc843 100644
--- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt
+++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt
@@ -3,8 +3,11 @@ package com.onesignal.sdktest.ui.secondary
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -19,9 +22,14 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.onesignal.sdktest.ui.components.DestructiveButton
import com.onesignal.sdktest.ui.theme.LightBackground
import com.onesignal.sdktest.ui.theme.OneSignalRed
import com.onesignal.sdktest.ui.theme.OneSignalTheme
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
class SecondaryActivity : ComponentActivity() {
@@ -51,19 +59,45 @@ class SecondaryActivity : ComponentActivity() {
},
containerColor = LightBackground
) { paddingValues ->
- Box(
+ Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
- contentAlignment = Alignment.Center
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
) {
Text(
text = "Secondary Activity",
style = MaterialTheme.typography.headlineMedium
)
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ DestructiveButton(
+ text = "CRASH",
+ onClick = { triggerCrash() }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ DestructiveButton(
+ text = "SIMULATE ANR (10s block)",
+ onClick = { triggerAnr() }
+ )
}
}
}
}
}
+
+ private fun triggerCrash() {
+ val timestamp = SimpleDateFormat("MMM dd, yyyy HH:mm:ss", Locale.getDefault())
+ .format(Date())
+ throw RuntimeException("Test crash from OneSignal Demo App - $timestamp")
+ }
+
+ @Suppress("MagicNumber")
+ private fun triggerAnr() {
+ Thread.sleep(10_000)
+ }
}
diff --git a/examples/demo/build.gradle.kts b/examples/demo/build.gradle.kts
index 0244a29bc..b21f952b6 100644
--- a/examples/demo/build.gradle.kts
+++ b/examples/demo/build.gradle.kts
@@ -1,6 +1,9 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
+val kotlinVersion by extra("2.2.0")
+
buildscript {
+ val kotlinVersion: String by extra
repositories {
google()
mavenCentral()
@@ -10,7 +13,7 @@ buildscript {
}
dependencies {
classpath("com.android.tools.build:gradle:8.8.2")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.google.gms:google-services:4.3.10")
classpath("com.huawei.agconnect:agcp:1.9.1.304")
}