From 1211bd89fd04947122dc2367fa9b54071534f693 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 24 Feb 2026 13:32:50 -0500 Subject: [PATCH 1/8] feat: add logging_config-based remote logging control and SDK version gating - Derive isRemoteLoggingEnabled from log_level presence in logging_config (empty object = disabled, has log_level = enabled) - Add OtelSdkSupport utility for testable SDK version checks (API 26+) - Gate all Otel initialization (crash, ANR, remote logging) on both SDK support and backend config - Rename OneSignalCrashLogInit to OneSignalOtelInit to reflect full scope - Simplify OneSignalCrashHandlerFactory with require() instead of no-op - Add crash test button in demo app SecondaryActivity - Fix Compose compiler plugin compatibility with AGP 8.8.2 - Add flow chart documentation for init sequence Co-authored-by: Cursor --- .../internal/backend/IParamsBackendService.kt | 1 + .../core/internal/config/ConfigModel.kt | 10 + .../config/impl/ConfigModelStoreListener.kt | 1 + .../crash/OneSignalCrashHandlerFactory.kt | 29 +-- .../debug/internal/crash/OtelAnrDetector.kt | 2 +- .../debug/internal/crash/OtelSdkSupport.kt | 27 +++ .../logging/otel/android/OtelIdResolver.kt | 17 ++ .../otel/android/OtelPlatformProvider.kt | 25 +-- .../com/onesignal/internal/OneSignalImp.kt | 10 +- ...alCrashLogInit.kt => OneSignalOtelInit.kt} | 36 ++-- .../internal/crash/OtelSdkSupportTest.kt | 38 ++++ .../otel/android/OtelIdResolverTest.kt | 173 ++++++++++++++---- .../otel/android/OtelPlatformProviderTest.kt | 127 ++++++++++++- ...ogInitTest.kt => OneSignalOtelInitTest.kt} | 74 ++++---- .../onesignal/otel/docs/flow_chart.mmd | 52 ++++++ .../onesignal/otel/docs/flow_chart.svg | 67 +++++++ .../onesignal/otel/IOtelPlatformProvider.kt | 14 +- examples/demo/app/build.gradle.kts | 7 +- .../sdktest/application/MainApplication.kt | 3 +- .../sdktest/ui/secondary/SecondaryActivity.kt | 28 ++- examples/demo/build.gradle.kts | 2 +- 21 files changed, 605 insertions(+), 138 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/{OneSignalCrashLogInit.kt => OneSignalOtelInit.kt} (80%) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt rename OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/{OneSignalCrashLogInitTest.kt => OneSignalOtelInitTest.kt} (84%) create mode 100644 OneSignalSDK/onesignal/otel/docs/flow_chart.mmd create mode 100644 OneSignalSDK/onesignal/otel/docs/flow_chart.svg 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..73812206f 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 && logLevel != com.onesignal.debug.LogLevel.NONE, ) 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..e5c17a87d 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 @@ -454,4 +454,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/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/OtelAnrDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt index bda108d02..45010dddc 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, 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/otel/android/OtelIdResolver.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt index 9eda39e73..71f71309a 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. 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..ea8a1b874 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 @@ -34,7 +34,7 @@ internal class OtelPlatformProvider( override val appVersion: String = config.appVersion private val context: Context? = config.context private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground - private val idResolver = OtelIdResolver(context) + private val idResolver = OneSignalIdResolver(context) // Top-level attributes (static, calculated once) override suspend fun getInstallId(): String = idResolver.resolveInstallId() @@ -121,28 +121,23 @@ internal class OtelPlatformProvider( override val minFileAgeForReadMillis: Long = 5_000 // Remote logging configuration + override val isRemoteLoggingEnabled: Boolean by lazy { + idResolver.resolveRemoteLoggingEnabled() + } + /** * 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) + * - "logging_config": {"log_level": "ERROR"} → returns "ERROR" (enabled, on allowlist) + * - "logging_config": {} → returns null (disabled, not on allowlist) + * - No config cached yet → returns null (first launch) */ @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" - } + configLevel?.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 } } 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..89d430ca5 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 @@ -207,20 +207,20 @@ 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) + // Create OneSignalOtelInit instance once - it manages platform provider lifecycle + // Platform provider is created lazily and reused for crash handler, ANR detector, and logging + val otelInit = OneSignalOtelInit(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() + otelInit.initializeCrashHandler() // Initialize Otel logging integration - reuses the same platform provider created in initializeCrashHandler // No service dependencies required, reads directly from SharedPreferences - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt similarity index 80% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt index d69b25fd2..80c0bc316 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt @@ -3,6 +3,8 @@ 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.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 @@ -14,12 +16,12 @@ 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. + * Initializes all Otel-based observability features: crash reporting, ANR detection, + * and remote log shipping. * - * Creates and reuses a single OtelPlatformProvider instance for both crash handler and logging. + * Creates and reuses a single OtelPlatformProvider instance across all features. */ -internal class OneSignalCrashLogInit( +internal class OneSignalOtelInit( private val context: Context, ) { // Platform provider - created once and reused for both crash handler and logging @@ -29,21 +31,27 @@ internal class OneSignalCrashLogInit( @Suppress("TooGenericExceptionCaught") fun initializeCrashHandler() { + if (!OtelSdkSupport.isSupported) { + Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping crash handler and ANR detector") + return + } + if (!platformProvider.isRemoteLoggingEnabled) { + Logging.info("OneSignal: Remote logging disabled (not yet enabled via config), skipping crash handler and ANR detector") + return + } + 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) + // Use factory which handles SDK version checks (no-op for SDK < 26) val logger = AndroidOtelLogger() - val crashHandler: IOtelCrashHandler = OtelFactory.createCrashHandler(platformProvider, logger) + val crashHandler: IOtelCrashHandler = OneSignalCrashHandlerFactory.createCrashHandler(context, 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 { @@ -57,17 +65,23 @@ internal class OneSignalCrashLogInit( 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() { + if (!OtelSdkSupport.isSupported) { + Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping Otel logging") + return + } + if (!platformProvider.isRemoteLoggingEnabled) { + Logging.info("OneSignal: Remote logging disabled, skipping Otel logging integration") + return + } // 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 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..46017dbe4 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 @@ -141,7 +141,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -166,7 +166,7 @@ class OtelIdResolverTest : FunSpec({ // Ensure commit is complete before creating resolver Thread.sleep(10) - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -189,7 +189,7 @@ class OtelIdResolverTest : FunSpec({ // Ensure commit is complete before creating resolver Thread.sleep(10) - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -200,7 +200,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveAppId returns error appId when ConfigModelStore is null") { // Given - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -224,7 +224,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -235,7 +235,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveAppId returns error appId when context is null") { // Given - val resolver = OtelIdResolver(null) + val resolver = OneSignalIdResolver(null) // When val result = resolver.resolveAppId() @@ -250,7 +250,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") .commit() - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -281,7 +281,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -310,7 +310,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -340,7 +340,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -367,7 +367,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -378,7 +378,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveOnesignalId returns null when IdentityModelStore is null") { // Given - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -402,7 +402,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -417,7 +417,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, "invalid-json") .commit() - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -448,7 +448,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -477,7 +477,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -507,7 +507,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -534,7 +534,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -545,7 +545,7 @@ class OtelIdResolverTest : FunSpec({ test("resolvePushSubscriptionId returns null when ConfigModelStore is null") { // Given - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -560,7 +560,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") .commit() - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -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 = OneSignalIdResolver(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 = OneSignalIdResolver(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 = OneSignalIdResolver(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 = OneSignalIdResolver(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 = OneSignalIdResolver(appContext!!) + resolver.resolveRemoteLoggingEnabled() shouldBe false + } + + test("resolveRemoteLoggingEnabled returns false when no config exists") { + val resolver = OneSignalIdResolver(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 = OneSignalIdResolver(appContext!!) + resolver.resolveRemoteLoggingEnabled() shouldBe false + } + // ===== resolveRemoteLogLevel Tests ===== test("resolveRemoteLogLevel returns LogLevel from ConfigModelStore when available") { @@ -594,7 +705,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -626,7 +737,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -656,7 +767,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -683,7 +794,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -715,7 +826,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -726,7 +837,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveRemoteLogLevel returns null when ConfigModelStore is null") { // Given - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -741,7 +852,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") .commit() - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -758,7 +869,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123") .commit() - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveInstallId() @@ -769,7 +880,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveInstallId returns default InstallId-Null when not found") { // Given - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When val result = resolver.resolveInstallId() @@ -784,7 +895,7 @@ class OtelIdResolverTest : FunSpec({ val mockSharedPreferences = mockk(relaxed = true) every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") - val resolver = OtelIdResolver(mockContext) + val resolver = OneSignalIdResolver(mockContext) // When val result = resolver.resolveInstallId() @@ -816,7 +927,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OtelIdResolver(appContext!!) + val resolver = OneSignalIdResolver(appContext!!) // When - resolve multiple IDs val appId1 = resolver.resolveAppId() 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..5f6c3d46a 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 @@ -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 ===== diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.kt similarity index 84% rename from OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt rename to OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.kt index 12758e0a4..583ebb796 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.kt @@ -22,7 +22,7 @@ import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace @RobolectricTest @Config(sdk = [Build.VERSION_CODES.O]) -class OneSignalCrashLogInitTest : FunSpec({ +class OneSignalOtelInitTest : FunSpec({ val context: Context = ApplicationProvider.getApplicationContext() val sharedPreferences = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) @@ -44,16 +44,16 @@ class OneSignalCrashLogInitTest : FunSpec({ test("platform provider should be created once and reused") { // Given - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When - initialize crash handler (creates platform provider) - crashLogInit.initializeCrashHandler() + otelInit.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() + otelInit.initializeOtelLogging() delay(100) // Give async initialization time to complete } @@ -62,21 +62,21 @@ class OneSignalCrashLogInitTest : FunSpec({ test("should create instance with context") { // Given & When - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // Then - crashLogInit shouldNotBe null + otelInit shouldNotBe null } // ===== Crash Handler Initialization Tests ===== test("initializeCrashHandler should create and initialize crash handler") { // Given - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) val originalHandler = Thread.getDefaultUncaughtExceptionHandler() // When - crashLogInit.initializeCrashHandler() + otelInit.initializeCrashHandler() // Then val currentHandler = Thread.getDefaultUncaughtExceptionHandler() @@ -94,19 +94,19 @@ class OneSignalCrashLogInitTest : FunSpec({ io.mockk.every { mockContext.packageName } returns "com.test" io.mockk.every { mockContext.getSharedPreferences(any(), any()) } returns sharedPreferences - val crashLogInit = OneSignalCrashLogInit(mockContext) + val otelInit = OneSignalOtelInit(mockContext) // When & Then - should not throw - crashLogInit.initializeCrashHandler() + otelInit.initializeCrashHandler() } test("initializeCrashHandler should initialize ANR detector") { // Given - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) val originalHandler = Thread.getDefaultUncaughtExceptionHandler() // When - crashLogInit.initializeCrashHandler() + otelInit.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 @@ -117,12 +117,12 @@ class OneSignalCrashLogInitTest : FunSpec({ test("initializeCrashHandler can be called multiple times safely") { // Given - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) val originalHandler = Thread.getDefaultUncaughtExceptionHandler() // When - crashLogInit.initializeCrashHandler() - crashLogInit.initializeCrashHandler() // Call again + otelInit.initializeCrashHandler() + otelInit.initializeCrashHandler() // Call again // Then - should not throw or cause issues val currentHandler = Thread.getDefaultUncaughtExceptionHandler() @@ -150,11 +150,11 @@ class OneSignalCrashLogInitTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(200) // Give async initialization time to complete } @@ -178,11 +178,11 @@ class OneSignalCrashLogInitTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(100) // Give async initialization time to complete } @@ -191,11 +191,11 @@ class OneSignalCrashLogInitTest : FunSpec({ test("initializeOtelLogging should default to ERROR when remote log level is not configured") { // Given - no remote logging config in SharedPreferences - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(200) // Give async initialization time to complete } @@ -210,24 +210,24 @@ class OneSignalCrashLogInitTest : FunSpec({ io.mockk.every { mockContext.packageName } returns "com.test" io.mockk.every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") - val crashLogInit = OneSignalCrashLogInit(mockContext) + val otelInit = OneSignalOtelInit(mockContext) // When & Then - should not throw runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(100) } } test("initializeOtelLogging can be called multiple times safely") { // Given - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(100) - crashLogInit.initializeOtelLogging() // Call again + otelInit.initializeOtelLogging() // Call again delay(100) } @@ -252,13 +252,13 @@ class OneSignalCrashLogInitTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) val originalHandler = Thread.getDefaultUncaughtExceptionHandler() // When - crashLogInit.initializeCrashHandler() + otelInit.initializeCrashHandler() runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(200) } @@ -290,11 +290,11 @@ class OneSignalCrashLogInitTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(100) } @@ -318,11 +318,11 @@ class OneSignalCrashLogInitTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val crashLogInit = OneSignalCrashLogInit(context) + val otelInit = OneSignalOtelInit(context) // When & Then - should default to ERROR and not throw runBlocking { - crashLogInit.initializeOtelLogging() + otelInit.initializeOtelLogging() delay(100) } } @@ -334,12 +334,12 @@ class OneSignalCrashLogInitTest : FunSpec({ val context1: Context = ApplicationProvider.getApplicationContext() val context2: Context = ApplicationProvider.getApplicationContext() - val crashLogInit1 = OneSignalCrashLogInit(context1) - val crashLogInit2 = OneSignalCrashLogInit(context2) + val otelInit1 = OneSignalOtelInit(context1) + val otelInit2 = OneSignalOtelInit(context2) // When - crashLogInit1.initializeCrashHandler() - crashLogInit2.initializeCrashHandler() + otelInit1.initializeCrashHandler() + otelInit2.initializeCrashHandler() // Then - both should work independently val handler1 = Thread.getDefaultUncaughtExceptionHandler() diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd b/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd new file mode 100644 index 000000000..6d5519cea --- /dev/null +++ b/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd @@ -0,0 +1,52 @@ +sequenceDiagram + participant App + participant OneSignalImp + participant OtelInit as OneSignalOtelInit + participant StartupService + participant ConfigListener as ConfigModelStoreListener + participant Backend as android_params.js + participant SharedPrefs as SharedPreferences + + Note over App: Cold Start - Session N + + App->>OneSignalImp: initWithContext(appId) + + rect rgb(230, 245, 255) + Note over OneSignalImp,SharedPrefs: initEssentials - runs FIRST, synchronous + OneSignalImp->>OtelInit: new OneSignalOtelInit(context) + OneSignalImp->>OtelInit: initializeCrashHandler() + OtelInit->>OtelInit: OtelSdkSupport.isSupported? + alt SDK less than 26 + OtelInit-->>OtelInit: skip everything + else SDK 26+ + OtelInit->>SharedPrefs: read cached logging_config from Session N-1 + alt logging_config has log_level + Note over OtelInit: isRemoteLoggingEnabled = true + OtelInit->>OtelInit: init crash handler + ANR detector + else logging_config empty or missing + Note over OtelInit: isRemoteLoggingEnabled = false + OtelInit-->>OtelInit: skip + end + end + OneSignalImp->>OtelInit: initializeOtelLogging() + Note over OtelInit: Same SDK + isEnabled gates + end + + OneSignalImp->>OneSignalImp: bootstrapServices() + OneSignalImp->>OneSignalImp: resolveAppId() + OneSignalImp->>OneSignalImp: configModel.appId = appId + + rect rgb(255, 245, 230) + Note over OneSignalImp,Backend: scheduleStart - runs AFTER, async background thread + OneSignalImp->>StartupService: scheduleStart() + StartupService->>ConfigListener: start() + ConfigListener->>Backend: GET android_params.js with ETag + Backend-->>ConfigListener: 200 logging_config or 304 Not Modified + ConfigListener->>SharedPrefs: persist logging_config for Session N+1 + end + + Note over App: App killed / cold start again + + Note over App: Cold Start - Session N+1 + App->>OneSignalImp: initWithContext(appId) + Note over OtelInit: Reads FRESH config cached from Session N fetch \ No newline at end of file diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chart.svg b/OneSignalSDK/onesignal/otel/docs/flow_chart.svg new file mode 100644 index 000000000..606ea713b --- /dev/null +++ b/OneSignalSDK/onesignal/otel/docs/flow_chart.svg @@ -0,0 +1,67 @@ +SharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceOneSignalOtelInitOneSignalImpAppSharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceOneSignalOtelInitOneSignalImpAppCold Start - Session NinitEssentials - runs FIRST, synchronousisRemoteLoggingEnabled = trueisRemoteLoggingEnabled = falsealt[logging_config has log_level][logging_config empty or missing]alt[SDK less than 26][SDK 26+]Same SDK + isEnabled gatesscheduleStart - runs AFTER, async background threadApp killed / cold start againCold Start - Session N+1Reads FRESH config cached from Session N fetchinitWithContext(appId)new OneSignalOtelInit(context)initializeCrashHandler()OtelSdkSupport.isSupported?skip everythingread cached logging_config from Session N-1init crash handler + ANR detectorskipinitializeOtelLogging()bootstrapServices()resolveAppId()configModel.appId = appIdscheduleStart()start()GET android_params.js with ETag200 logging_config or 304 Not Modifiedpersist logging_config for Session N+1initWithContext(appId) \ No newline at end of file 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..0dfe4f11f 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 @@ -39,9 +39,17 @@ 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? diff --git a/examples/demo/app/build.gradle.kts b/examples/demo/app/build.gradle.kts index 8aea10141..880882936 100644 --- a/examples/demo/app/build.gradle.kts +++ b/examples/demo/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("kotlin-android") + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" } // Apply GMS or Huawei plugin based on build variant @@ -33,10 +34,6 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - flavorDimensions += "default" productFlavors { @@ -90,7 +87,7 @@ android { dependencies { // Kotlin - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.21") 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..d05ba4813 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,33 @@ 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() } + ) } } } } } + + 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") + } } diff --git a/examples/demo/build.gradle.kts b/examples/demo/build.gradle.kts index 0244a29bc..9031f4f74 100644 --- a/examples/demo/build.gradle.kts +++ b/examples/demo/build.gradle.kts @@ -10,7 +10,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:2.0.21") classpath("com.google.gms:google-services:4.3.10") classpath("com.huawei.agconnect:agcp:1.9.1.304") } From 420ce4e137567f28279f975eadcf35c9de2fbc9a Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 24 Feb 2026 17:18:32 -0500 Subject: [PATCH 2/8] reading from config update and try catch --- .../crash/OneSignalCrashUploaderWrapper.kt | 13 +- .../debug/internal/crash/OtelAnrDetector.kt | 9 +- .../debug/internal/logging/Logging.kt | 5 +- .../otel/android/OtelPlatformProvider.kt | 2 +- .../com/onesignal/internal/OneSignalImp.kt | 22 +- .../onesignal/internal/OneSignalOtelInit.kt | 133 ------- .../onesignal/internal/OtelConfigEvaluator.kt | 68 ++++ .../internal/OtelLifecycleManager.kt | 230 ++++++++++++ .../otel/android/OtelIdResolverTest.kt | 76 ++-- .../internal/OneSignalOtelInitTest.kt | 348 ------------------ .../internal/OtelConfigEvaluatorTest.kt | 102 +++++ .../internal/OtelLifecycleManagerTest.kt | 103 ++++++ .../onesignal/otel/docs/flow_chart.mmd | 104 ++++-- .../docs/{flow_chart.svg => flow_chartv1.svg} | 0 .../onesignal/otel/docs/flow_chartv2.svg | 67 ++++ .../com/onesignal/otel/IOtelCrashHandler.kt | 6 + .../com/onesignal/otel/IOtelOpenTelemetry.kt | 7 + .../onesignal/otel/OneSignalOpenTelemetry.kt | 12 + .../onesignal/otel/crash/OtelCrashHandler.kt | 22 +- 19 files changed, 751 insertions(+), 578 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt rename OneSignalSDK/onesignal/otel/docs/{flow_chart.svg => flow_chartv1.svg} (100%) create mode 100644 OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg 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..b9898ae06 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 @@ -43,9 +43,18 @@ internal class OneSignalCrashUploaderWrapper( OtelFactory.createCrashUploader(platformProvider, logger) } + @Suppress("TooGenericExceptionCaught") override fun start() { - runBlocking { - uploader.start() + if (!OtelSdkSupport.isSupported) return + try { + runBlocking { + 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 45010dddc..7daddb37b 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 @@ -73,9 +73,8 @@ 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}") } } } @@ -176,8 +175,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/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/OtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt index ea8a1b874..41db7ac05 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 @@ -34,7 +34,7 @@ internal class OtelPlatformProvider( override val appVersion: String = config.appVersion private val context: Context? = config.context private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground - private val idResolver = OneSignalIdResolver(context) + private val idResolver = OtelIdResolver(context) // Top-level attributes (static, calculated once) override suspend fun getInstallId(): String = idResolver.resolveInstallId() 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 89d430ca5..fe4e76edd 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 OneSignalOtelInit instance once - it manages platform provider lifecycle - // Platform provider is created lazily and reused for crash handler, ANR detector, and logging - val otelInit = OneSignalOtelInit(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. - otelInit.initializeCrashHandler() - - // Initialize Otel logging integration - reuses the same platform provider created in initializeCrashHandler - // No service dependencies required, reads directly from SharedPreferences - otelInit.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()." diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt deleted file mode 100644 index 80c0bc316..000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalOtelInit.kt +++ /dev/null @@ -1,133 +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.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.IOtelOpenTelemetryRemote -import com.onesignal.otel.OtelFactory -import com.onesignal.otel.crash.IOtelAnrDetector - -/** - * Initializes all Otel-based observability features: crash reporting, ANR detection, - * and remote log shipping. - * - * Creates and reuses a single OtelPlatformProvider instance across all features. - */ -internal class OneSignalOtelInit( - 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() { - if (!OtelSdkSupport.isSupported) { - Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping crash handler and ANR detector") - return - } - if (!platformProvider.isRemoteLoggingEnabled) { - Logging.info("OneSignal: Remote logging disabled (not yet enabled via config), skipping crash handler and ANR detector") - return - } - - try { - Logging.info("OneSignal: Initializing crash handler early...") - - // Use factory which handles SDK version checks (no-op for SDK < 26) - val logger = AndroidOtelLogger() - val crashHandler: IOtelCrashHandler = OneSignalCrashHandlerFactory.createCrashHandler(context, logger) - - Logging.info("OneSignal: Crash handler created, initializing...") - crashHandler.initialize() - - Logging.info("OneSignal: ✅ Crash handler initialized successfully and ready to capture crashes") - Logging.info("OneSignal: 📁 Crash logs will be stored at: ${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) { - Logging.error("OneSignal: Failed to initialize ANR detector: ${e.message}", e) - } - } catch (e: Exception) { - Logging.error("OneSignal: Failed to initialize crash handler: ${e.message}", e) - } - } - - @Suppress("TooGenericExceptionCaught") - fun initializeOtelLogging() { - if (!OtelSdkSupport.isSupported) { - Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping Otel logging") - return - } - if (!platformProvider.isRemoteLoggingEnabled) { - Logging.info("OneSignal: Remote logging disabled, skipping Otel logging integration") - return - } - // 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/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..13e2eb8e7 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt @@ -0,0 +1,230 @@ +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.IOtelOpenTelemetryRemote +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. + */ +internal class OtelLifecycleManager( + private val context: Context, +) : ISingletonModelStoreChangeHandler { + private val lock = Any() + + private val platformProvider: OtelPlatformProvider by lazy { + createAndroidOtelPlatformProvider(context) + } + + 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 = readCurrentConfig() + val action = OtelConfigEvaluator.evaluate(old = null, 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) + val action = synchronized(lock) { + 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 readCurrentConfig(): OtelConfig { + val enabled = platformProvider.isRemoteLoggingEnabled + val levelStr = platformProvider.remoteLogLevel + val level = levelStr?.let { + try { + LogLevel.valueOf(it) + } catch (_: Throwable) { + null + } + } + return OtelConfig(isEnabled = enabled, logLevel = level) + } + + @Suppress("TooGenericExceptionCaught") + private fun applyAction(action: OtelConfigAction, newConfig: OtelConfig) { + synchronized(lock) { + 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 logger = AndroidOtelLogger() + val handler = OneSignalCrashHandlerFactory.createCrashHandler(context, logger) + handler.initialize() + crashHandler = handler + Logging.info("OneSignal: Crash handler initialized — logs at: ${platformProvider.crashStoragePath}") + } + + private fun startAnrDetector() { + if (anrDetector != null) return + val logger = AndroidOtelLogger() + val detector = createAnrDetector( + platformProvider, + logger, + anrThresholdMs = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + checkIntervalMs = 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 = OtelFactory.createRemoteTelemetry(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/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt index 46017dbe4..bb1fcb8e4 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 @@ -141,7 +141,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -166,7 +166,7 @@ class OtelIdResolverTest : FunSpec({ // Ensure commit is complete before creating resolver Thread.sleep(10) - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -189,7 +189,7 @@ class OtelIdResolverTest : FunSpec({ // Ensure commit is complete before creating resolver Thread.sleep(10) - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -200,7 +200,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveAppId returns error appId when ConfigModelStore is null") { // Given - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -224,7 +224,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -235,7 +235,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveAppId returns error appId when context is null") { // Given - val resolver = OneSignalIdResolver(null) + val resolver = OtelIdResolver(null) // When val result = resolver.resolveAppId() @@ -250,7 +250,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveAppId() @@ -281,7 +281,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -310,7 +310,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -340,7 +340,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -367,7 +367,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -378,7 +378,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveOnesignalId returns null when IdentityModelStore is null") { // Given - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -402,7 +402,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -417,7 +417,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, "invalid-json") .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveOnesignalId() @@ -448,7 +448,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -477,7 +477,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -507,7 +507,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -534,7 +534,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -545,7 +545,7 @@ class OtelIdResolverTest : FunSpec({ test("resolvePushSubscriptionId returns null when ConfigModelStore is null") { // Given - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -560,7 +560,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolvePushSubscriptionId() @@ -588,7 +588,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe true } @@ -606,7 +606,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe true } @@ -624,7 +624,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe false } @@ -640,7 +640,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe false } @@ -653,12 +653,12 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe false } test("resolveRemoteLoggingEnabled returns false when no config exists") { - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe false } @@ -676,7 +676,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) resolver.resolveRemoteLoggingEnabled() shouldBe false } @@ -705,7 +705,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -737,7 +737,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -767,7 +767,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -794,7 +794,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -826,7 +826,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -837,7 +837,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveRemoteLogLevel returns null when ConfigModelStore is null") { // Given - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -852,7 +852,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveRemoteLogLevel() @@ -869,7 +869,7 @@ class OtelIdResolverTest : FunSpec({ .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123") .commit() - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveInstallId() @@ -880,7 +880,7 @@ class OtelIdResolverTest : FunSpec({ test("resolveInstallId returns default InstallId-Null when not found") { // Given - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When val result = resolver.resolveInstallId() @@ -895,7 +895,7 @@ class OtelIdResolverTest : FunSpec({ val mockSharedPreferences = mockk(relaxed = true) every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") - val resolver = OneSignalIdResolver(mockContext) + val resolver = OtelIdResolver(mockContext) // When val result = resolver.resolveInstallId() @@ -927,7 +927,7 @@ class OtelIdResolverTest : FunSpec({ throw AssertionError("Failed to write SharedPreferences data - test isolation issue") } - val resolver = OneSignalIdResolver(appContext!!) + val resolver = OtelIdResolver(appContext!!) // When - resolve multiple IDs val appId1 = resolver.resolveAppId() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.kt deleted file mode 100644 index 583ebb796..000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalOtelInitTest.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 OneSignalOtelInitTest : 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 otelInit = OneSignalOtelInit(context) - - // When - initialize crash handler (creates platform provider) - otelInit.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 { - otelInit.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 otelInit = OneSignalOtelInit(context) - - // Then - otelInit shouldNotBe null - } - - // ===== Crash Handler Initialization Tests ===== - - test("initializeCrashHandler should create and initialize crash handler") { - // Given - val otelInit = OneSignalOtelInit(context) - val originalHandler = Thread.getDefaultUncaughtExceptionHandler() - - // When - otelInit.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 otelInit = OneSignalOtelInit(mockContext) - - // When & Then - should not throw - otelInit.initializeCrashHandler() - } - - test("initializeCrashHandler should initialize ANR detector") { - // Given - val otelInit = OneSignalOtelInit(context) - val originalHandler = Thread.getDefaultUncaughtExceptionHandler() - - // When - otelInit.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 otelInit = OneSignalOtelInit(context) - val originalHandler = Thread.getDefaultUncaughtExceptionHandler() - - // When - otelInit.initializeCrashHandler() - otelInit.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 otelInit = OneSignalOtelInit(context) - - // When - runBlocking { - otelInit.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 otelInit = OneSignalOtelInit(context) - - // When - runBlocking { - otelInit.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 otelInit = OneSignalOtelInit(context) - - // When - runBlocking { - otelInit.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 otelInit = OneSignalOtelInit(mockContext) - - // When & Then - should not throw - runBlocking { - otelInit.initializeOtelLogging() - delay(100) - } - } - - test("initializeOtelLogging can be called multiple times safely") { - // Given - val otelInit = OneSignalOtelInit(context) - - // When - runBlocking { - otelInit.initializeOtelLogging() - delay(100) - otelInit.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 otelInit = OneSignalOtelInit(context) - val originalHandler = Thread.getDefaultUncaughtExceptionHandler() - - // When - otelInit.initializeCrashHandler() - runBlocking { - otelInit.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 otelInit = OneSignalOtelInit(context) - - // When - runBlocking { - otelInit.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 otelInit = OneSignalOtelInit(context) - - // When & Then - should default to ERROR and not throw - runBlocking { - otelInit.initializeOtelLogging() - delay(100) - } - } - - // ===== Context Handling Tests ===== - - test("should work with different contexts") { - // Given - val context1: Context = ApplicationProvider.getApplicationContext() - val context2: Context = ApplicationProvider.getApplicationContext() - - val otelInit1 = OneSignalOtelInit(context1) - val otelInit2 = OneSignalOtelInit(context2) - - // When - otelInit1.initializeCrashHandler() - otelInit2.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/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/otel/docs/flow_chart.mmd b/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd index 6d5519cea..63374af71 100644 --- a/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd +++ b/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd @@ -1,52 +1,106 @@ sequenceDiagram participant App participant OneSignalImp - participant OtelInit as OneSignalOtelInit + participant OtelMgr as OtelLifecycleManager + participant Evaluator as OtelConfigEvaluator + participant ConfigStore as ConfigModelStore participant StartupService participant ConfigListener as ConfigModelStoreListener participant Backend as android_params.js participant SharedPrefs as SharedPreferences - Note over App: Cold Start - Session N + Note over App: Cold Start — Session N App->>OneSignalImp: initWithContext(appId) rect rgb(230, 245, 255) - Note over OneSignalImp,SharedPrefs: initEssentials - runs FIRST, synchronous - OneSignalImp->>OtelInit: new OneSignalOtelInit(context) - OneSignalImp->>OtelInit: initializeCrashHandler() - OtelInit->>OtelInit: OtelSdkSupport.isSupported? - alt SDK less than 26 - OtelInit-->>OtelInit: skip everything - else SDK 26+ - OtelInit->>SharedPrefs: read cached logging_config from Session N-1 - alt logging_config has log_level - Note over OtelInit: isRemoteLoggingEnabled = true - OtelInit->>OtelInit: init crash handler + ANR detector - else logging_config empty or missing - Note over OtelInit: isRemoteLoggingEnabled = false - OtelInit-->>OtelInit: skip + Note over OneSignalImp,SharedPrefs: initEssentials() — synchronous, on IO thread + + OneSignalImp->>OtelMgr: new OtelLifecycleManager(context) + OneSignalImp->>OtelMgr: initializeFromCachedConfig() + + OtelMgr->>OtelMgr: OtelSdkSupport.isSupported? + alt SDK < 26 + OtelMgr-->>OtelMgr: skip all Otel features, return + else SDK >= 26 + Note over OtelMgr: try/catch(Throwable) wraps entire block + OtelMgr->>SharedPrefs: readCurrentConfig() — cached logging_config + SharedPrefs-->>OtelMgr: OtelConfig(isEnabled, logLevel) + OtelMgr->>Evaluator: evaluate(old=null, new=cachedConfig) + Evaluator-->>OtelMgr: OtelConfigAction + + alt Enable (cached config has valid log_level) + Note over OtelMgr: try/catch(Throwable) per feature + OtelMgr->>OtelMgr: startCrashHandler() + OtelMgr->>OtelMgr: startAnrDetector() + OtelMgr->>OtelMgr: startOtelLogging(logLevel) + else Disable / NoChange + OtelMgr-->>OtelMgr: no-op end end - OneSignalImp->>OtelInit: initializeOtelLogging() - Note over OtelInit: Same SDK + isEnabled gates end - OneSignalImp->>OneSignalImp: bootstrapServices() + OneSignalImp->>OneSignalImp: bootstrapServices() — IoC container ready + + rect rgb(240, 255, 240) + Note over OneSignalImp,ConfigStore: Subscribe to config store (observer pattern) + OneSignalImp->>OtelMgr: subscribeToConfigStore(configModelStore) + OtelMgr->>ConfigStore: subscribe(this) + Note over OtelMgr: Now listens for HYDRATE events + end + OneSignalImp->>OneSignalImp: resolveAppId() OneSignalImp->>OneSignalImp: configModel.appId = appId rect rgb(255, 245, 230) - Note over OneSignalImp,Backend: scheduleStart - runs AFTER, async background thread + Note over OneSignalImp,Backend: scheduleStart() — async, background IO thread + OneSignalImp->>StartupService: scheduleStart() StartupService->>ConfigListener: start() - ConfigListener->>Backend: GET android_params.js with ETag - Backend-->>ConfigListener: 200 logging_config or 304 Not Modified - ConfigListener->>SharedPrefs: persist logging_config for Session N+1 + ConfigListener->>Backend: GET android_params.js (with If-None-Match ETag) + Backend-->>ConfigListener: 200 {logging_config} or 304 Not Modified + + ConfigListener->>ConfigListener: build new ConfigModel from response + ConfigListener->>ConfigStore: replace(config, HYDRATE) + + Note over ConfigStore: Notifies all subscribers + + ConfigStore->>OtelMgr: onModelReplaced(model, HYDRATE) + + rect rgb(255, 240, 240) + Note over OtelMgr,Evaluator: Dynamic config refresh — try/catch(Throwable) + + OtelMgr->>OtelMgr: tag == HYDRATE? SDK supported? + OtelMgr->>OtelMgr: extract remoteLoggingParams from ConfigModel + OtelMgr->>Evaluator: evaluate(old=currentConfig, new=freshConfig) + Evaluator-->>OtelMgr: OtelConfigAction + + alt Enable (was off, now on) + Note over OtelMgr: try/catch(Throwable) per feature + OtelMgr->>OtelMgr: startCrashHandler() + OtelMgr->>OtelMgr: startAnrDetector() + OtelMgr->>OtelMgr: startOtelLogging(logLevel) + else Disable (was on, now off) + Note over OtelMgr: try/catch(Throwable) per feature + OtelMgr->>OtelMgr: anrDetector.stop() + OtelMgr->>OtelMgr: crashHandler.unregister() + OtelMgr->>OtelMgr: Logging.setOtelTelemetry(null) + OtelMgr->>OtelMgr: remoteTelemetry.shutdown() + else UpdateLogLevel (level changed) + Note over OtelMgr: try/catch(Throwable) + OtelMgr->>OtelMgr: shutdown old telemetry + OtelMgr->>OtelMgr: startOtelLogging(newLevel) + else NoChange + OtelMgr-->>OtelMgr: no-op + end + end + + ConfigListener->>SharedPrefs: logging_config persisted (available for Session N+1) end Note over App: App killed / cold start again - Note over App: Cold Start - Session N+1 + Note over App: Cold Start — Session N+1 App->>OneSignalImp: initWithContext(appId) - Note over OtelInit: Reads FRESH config cached from Session N fetch \ No newline at end of file + Note over OtelMgr: Reads FRESH config cached from Session N fetch + Note over OtelMgr: Same flow repeats — initializeFromCachedConfig() diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chart.svg b/OneSignalSDK/onesignal/otel/docs/flow_chartv1.svg similarity index 100% rename from OneSignalSDK/onesignal/otel/docs/flow_chart.svg rename to OneSignalSDK/onesignal/otel/docs/flow_chartv1.svg diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg b/OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg new file mode 100644 index 000000000..7fdb47d52 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg @@ -0,0 +1,67 @@ +SharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceConfigModelStoreOtelConfigEvaluatorOtelLifecycleManagerOneSignalImpAppSharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceConfigModelStoreOtelConfigEvaluatorOtelLifecycleManagerOneSignalImpAppCold Start — Session NinitEssentials() — synchronous, on IO threadtry/catch(Throwable) wraps entire blocktry/catch(Throwable) per featurealt[Enable (cached config has validlog_level)][Disable / NoChange]alt[SDK < 26][SDK >= 26]Subscribe to config store (observer pattern)Now listens for HYDRATE eventsscheduleStart() — async, background IO threadNotifies all subscribersDynamic config refresh — try/catch(Throwable)try/catch(Throwable) per featuretry/catch(Throwable) per featuretry/catch(Throwable)alt[Enable (was off, now on)][Disable (was on, now off)][UpdateLogLevel (level changed)][NoChange]App killed / cold start againCold Start — Session N+1Reads FRESH config cached from Session N fetchSame flow repeats — initializeFromCachedConfig()initWithContext(appId)new OtelLifecycleManager(context)initializeFromCachedConfig()OtelSdkSupport.isSupported?skip all Otel features, returnreadCurrentConfig() — cached logging_configOtelConfig(isEnabled, logLevel)evaluate(old=null, new=cachedConfig)OtelConfigActionstartCrashHandler()startAnrDetector()startOtelLogging(logLevel)no-opbootstrapServices() — IoC container readysubscribeToConfigStore(configModelStore)subscribe(this)resolveAppId()configModel.appId = appIdscheduleStart()start()GET android_params.js (with If-None-Match ETag)200 {logging_config} or 304 Not Modifiedbuild new ConfigModel from responsereplace(config, HYDRATE)onModelReplaced(model, HYDRATE)tag == HYDRATE? SDK supported?extract remoteLoggingParams from ConfigModelevaluate(old=currentConfig, new=freshConfig)OtelConfigActionstartCrashHandler()startAnrDetector()startOtelLogging(logLevel)anrDetector.stop()crashHandler.unregister()Logging.setOtelTelemetry(null)remoteTelemetry.shutdown()shutdown old telemetrystartOtelLogging(newLevel)no-oplogging_config persisted (available for Session N+1)initWithContext(appId) \ No newline at end of file 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/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt index 6592e6be5..1ee759a04 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 } 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..229e841eb 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 @@ -32,6 +32,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 +101,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) From 1d5b6f479e622c66e55f7dc5c45098fa293ea3a1 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 24 Feb 2026 17:22:01 -0500 Subject: [PATCH 3/8] kotlin version --- examples/demo/app/build.gradle.kts | 6 ++++-- examples/demo/build.gradle.kts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/demo/app/build.gradle.kts b/examples/demo/app/build.gradle.kts index 880882936..9dbda6eca 100644 --- a/examples/demo/app/build.gradle.kts +++ b/examples/demo/app/build.gradle.kts @@ -1,9 +1,11 @@ plugins { id("com.android.application") id("kotlin-android") - id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" + 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() @@ -87,7 +89,7 @@ android { dependencies { // Kotlin - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.21") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // AndroidX diff --git a/examples/demo/build.gradle.kts b/examples/demo/build.gradle.kts index 9031f4f74..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:2.0.21") + 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") } From 13cef860bbb1dba6916c5d355ed0260214215fca Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 24 Feb 2026 17:38:12 -0500 Subject: [PATCH 4/8] more cleanup --- .../otel/android/OtelPlatformProvider.kt | 16 ++- .../internal/OtelLifecycleManager.kt | 8 +- .../onesignal/otel/docs/flow_chart.mmd | 106 ------------------ .../onesignal/otel/docs/flow_chartv1.svg | 67 ----------- .../onesignal/otel/docs/flow_chartv2.svg | 67 ----------- 5 files changed, 11 insertions(+), 253 deletions(-) delete mode 100644 OneSignalSDK/onesignal/otel/docs/flow_chart.mmd delete mode 100644 OneSignalSDK/onesignal/otel/docs/flow_chartv1.svg delete mode 100644 OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg 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 41db7ac05..e2b30e50f 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 @@ -120,22 +120,20 @@ internal class OtelPlatformProvider( override val minFileAgeForReadMillis: Long = 5_000 - // Remote logging configuration + // 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() } - /** - * The minimum log level to send remotely as a string. - * - "logging_config": {"log_level": "ERROR"} → returns "ERROR" (enabled, on allowlist) - * - "logging_config": {} → returns null (disabled, not on allowlist) - * - No config cached yet → returns null (first launch) - */ + // 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() - configLevel?.name + idResolver.resolveRemoteLogLevel()?.name } catch (e: Exception) { null } 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 index 13e2eb8e7..3a0012288 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt @@ -41,6 +41,8 @@ internal class OtelLifecycleManager( createAndroidOtelPlatformProvider(context) } + private val logger = AndroidOtelLogger() + private var crashHandler: IOtelCrashHandler? = null private var anrDetector: IOtelAnrDetector? = null private var remoteTelemetry: IOtelOpenTelemetryRemote? = null @@ -59,7 +61,7 @@ internal class OtelLifecycleManager( } try { - val cachedConfig = readCurrentConfig() + val cachedConfig = readCurrentCachedConfig() val action = OtelConfigEvaluator.evaluate(old = null, new = cachedConfig) applyAction(action, cachedConfig) } catch (t: Throwable) { @@ -105,7 +107,7 @@ internal class OtelLifecycleManager( // Internal // ------------------------------------------------------------------ - private fun readCurrentConfig(): OtelConfig { + private fun readCurrentCachedConfig(): OtelConfig { val enabled = platformProvider.isRemoteLoggingEnabled val levelStr = platformProvider.remoteLogLevel val level = levelStr?.let { @@ -195,7 +197,6 @@ internal class OtelLifecycleManager( private fun startCrashHandler() { if (crashHandler != null) return - val logger = AndroidOtelLogger() val handler = OneSignalCrashHandlerFactory.createCrashHandler(context, logger) handler.initialize() crashHandler = handler @@ -204,7 +205,6 @@ internal class OtelLifecycleManager( private fun startAnrDetector() { if (anrDetector != null) return - val logger = AndroidOtelLogger() val detector = createAnrDetector( platformProvider, logger, diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd b/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd deleted file mode 100644 index 63374af71..000000000 --- a/OneSignalSDK/onesignal/otel/docs/flow_chart.mmd +++ /dev/null @@ -1,106 +0,0 @@ -sequenceDiagram - participant App - participant OneSignalImp - participant OtelMgr as OtelLifecycleManager - participant Evaluator as OtelConfigEvaluator - participant ConfigStore as ConfigModelStore - participant StartupService - participant ConfigListener as ConfigModelStoreListener - participant Backend as android_params.js - participant SharedPrefs as SharedPreferences - - Note over App: Cold Start — Session N - - App->>OneSignalImp: initWithContext(appId) - - rect rgb(230, 245, 255) - Note over OneSignalImp,SharedPrefs: initEssentials() — synchronous, on IO thread - - OneSignalImp->>OtelMgr: new OtelLifecycleManager(context) - OneSignalImp->>OtelMgr: initializeFromCachedConfig() - - OtelMgr->>OtelMgr: OtelSdkSupport.isSupported? - alt SDK < 26 - OtelMgr-->>OtelMgr: skip all Otel features, return - else SDK >= 26 - Note over OtelMgr: try/catch(Throwable) wraps entire block - OtelMgr->>SharedPrefs: readCurrentConfig() — cached logging_config - SharedPrefs-->>OtelMgr: OtelConfig(isEnabled, logLevel) - OtelMgr->>Evaluator: evaluate(old=null, new=cachedConfig) - Evaluator-->>OtelMgr: OtelConfigAction - - alt Enable (cached config has valid log_level) - Note over OtelMgr: try/catch(Throwable) per feature - OtelMgr->>OtelMgr: startCrashHandler() - OtelMgr->>OtelMgr: startAnrDetector() - OtelMgr->>OtelMgr: startOtelLogging(logLevel) - else Disable / NoChange - OtelMgr-->>OtelMgr: no-op - end - end - end - - OneSignalImp->>OneSignalImp: bootstrapServices() — IoC container ready - - rect rgb(240, 255, 240) - Note over OneSignalImp,ConfigStore: Subscribe to config store (observer pattern) - OneSignalImp->>OtelMgr: subscribeToConfigStore(configModelStore) - OtelMgr->>ConfigStore: subscribe(this) - Note over OtelMgr: Now listens for HYDRATE events - end - - OneSignalImp->>OneSignalImp: resolveAppId() - OneSignalImp->>OneSignalImp: configModel.appId = appId - - rect rgb(255, 245, 230) - Note over OneSignalImp,Backend: scheduleStart() — async, background IO thread - - OneSignalImp->>StartupService: scheduleStart() - StartupService->>ConfigListener: start() - ConfigListener->>Backend: GET android_params.js (with If-None-Match ETag) - Backend-->>ConfigListener: 200 {logging_config} or 304 Not Modified - - ConfigListener->>ConfigListener: build new ConfigModel from response - ConfigListener->>ConfigStore: replace(config, HYDRATE) - - Note over ConfigStore: Notifies all subscribers - - ConfigStore->>OtelMgr: onModelReplaced(model, HYDRATE) - - rect rgb(255, 240, 240) - Note over OtelMgr,Evaluator: Dynamic config refresh — try/catch(Throwable) - - OtelMgr->>OtelMgr: tag == HYDRATE? SDK supported? - OtelMgr->>OtelMgr: extract remoteLoggingParams from ConfigModel - OtelMgr->>Evaluator: evaluate(old=currentConfig, new=freshConfig) - Evaluator-->>OtelMgr: OtelConfigAction - - alt Enable (was off, now on) - Note over OtelMgr: try/catch(Throwable) per feature - OtelMgr->>OtelMgr: startCrashHandler() - OtelMgr->>OtelMgr: startAnrDetector() - OtelMgr->>OtelMgr: startOtelLogging(logLevel) - else Disable (was on, now off) - Note over OtelMgr: try/catch(Throwable) per feature - OtelMgr->>OtelMgr: anrDetector.stop() - OtelMgr->>OtelMgr: crashHandler.unregister() - OtelMgr->>OtelMgr: Logging.setOtelTelemetry(null) - OtelMgr->>OtelMgr: remoteTelemetry.shutdown() - else UpdateLogLevel (level changed) - Note over OtelMgr: try/catch(Throwable) - OtelMgr->>OtelMgr: shutdown old telemetry - OtelMgr->>OtelMgr: startOtelLogging(newLevel) - else NoChange - OtelMgr-->>OtelMgr: no-op - end - end - - ConfigListener->>SharedPrefs: logging_config persisted (available for Session N+1) - end - - Note over App: App killed / cold start again - - Note over App: Cold Start — Session N+1 - App->>OneSignalImp: initWithContext(appId) - Note over OtelMgr: Reads FRESH config cached from Session N fetch - Note over OtelMgr: Same flow repeats — initializeFromCachedConfig() diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chartv1.svg b/OneSignalSDK/onesignal/otel/docs/flow_chartv1.svg deleted file mode 100644 index 606ea713b..000000000 --- a/OneSignalSDK/onesignal/otel/docs/flow_chartv1.svg +++ /dev/null @@ -1,67 +0,0 @@ -SharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceOneSignalOtelInitOneSignalImpAppSharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceOneSignalOtelInitOneSignalImpAppCold Start - Session NinitEssentials - runs FIRST, synchronousisRemoteLoggingEnabled = trueisRemoteLoggingEnabled = falsealt[logging_config has log_level][logging_config empty or missing]alt[SDK less than 26][SDK 26+]Same SDK + isEnabled gatesscheduleStart - runs AFTER, async background threadApp killed / cold start againCold Start - Session N+1Reads FRESH config cached from Session N fetchinitWithContext(appId)new OneSignalOtelInit(context)initializeCrashHandler()OtelSdkSupport.isSupported?skip everythingread cached logging_config from Session N-1init crash handler + ANR detectorskipinitializeOtelLogging()bootstrapServices()resolveAppId()configModel.appId = appIdscheduleStart()start()GET android_params.js with ETag200 logging_config or 304 Not Modifiedpersist logging_config for Session N+1initWithContext(appId) \ No newline at end of file diff --git a/OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg b/OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg deleted file mode 100644 index 7fdb47d52..000000000 --- a/OneSignalSDK/onesignal/otel/docs/flow_chartv2.svg +++ /dev/null @@ -1,67 +0,0 @@ -SharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceConfigModelStoreOtelConfigEvaluatorOtelLifecycleManagerOneSignalImpAppSharedPreferencesandroid_params.jsConfigModelStoreListenerStartupServiceConfigModelStoreOtelConfigEvaluatorOtelLifecycleManagerOneSignalImpAppCold Start — Session NinitEssentials() — synchronous, on IO threadtry/catch(Throwable) wraps entire blocktry/catch(Throwable) per featurealt[Enable (cached config has validlog_level)][Disable / NoChange]alt[SDK < 26][SDK >= 26]Subscribe to config store (observer pattern)Now listens for HYDRATE eventsscheduleStart() — async, background IO threadNotifies all subscribersDynamic config refresh — try/catch(Throwable)try/catch(Throwable) per featuretry/catch(Throwable) per featuretry/catch(Throwable)alt[Enable (was off, now on)][Disable (was on, now off)][UpdateLogLevel (level changed)][NoChange]App killed / cold start againCold Start — Session N+1Reads FRESH config cached from Session N fetchSame flow repeats — initializeFromCachedConfig()initWithContext(appId)new OtelLifecycleManager(context)initializeFromCachedConfig()OtelSdkSupport.isSupported?skip all Otel features, returnreadCurrentConfig() — cached logging_configOtelConfig(isEnabled, logLevel)evaluate(old=null, new=cachedConfig)OtelConfigActionstartCrashHandler()startAnrDetector()startOtelLogging(logLevel)no-opbootstrapServices() — IoC container readysubscribeToConfigStore(configModelStore)subscribe(this)resolveAppId()configModel.appId = appIdscheduleStart()start()GET android_params.js (with If-None-Match ETag)200 {logging_config} or 304 Not Modifiedbuild new ConfigModel from responsereplace(config, HYDRATE)onModelReplaced(model, HYDRATE)tag == HYDRATE? SDK supported?extract remoteLoggingParams from ConfigModelevaluate(old=currentConfig, new=freshConfig)OtelConfigActionstartCrashHandler()startAnrDetector()startOtelLogging(logLevel)anrDetector.stop()crashHandler.unregister()Logging.setOtelTelemetry(null)remoteTelemetry.shutdown()shutdown old telemetrystartOtelLogging(newLevel)no-oplogging_config persisted (available for Session N+1)initWithContext(appId) \ No newline at end of file From 34bccdbfd465253a889fa7a289f51f2bc5abe64c Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 24 Feb 2026 17:50:54 -0500 Subject: [PATCH 5/8] brought changes from https://github.com/OneSignal/OneSignal-Android-SDK/pull/2539/changes --- .../backend/impl/ParamsBackendService.kt | 33 +++++-------------- .../otel/android/OtelPlatformProvider.kt | 4 +-- .../com/onesignal/internal/OneSignalImp.kt | 2 +- .../internal/crash/OtelIntegrationTest.kt | 2 +- .../otel/android/OtelPlatformProviderTest.kt | 4 +-- .../internal/InAppMessagesManager.kt | 2 +- .../onesignal/otel/IOtelPlatformProvider.kt | 2 +- .../otel/attributes/OtelFieldsPerEventTest.kt | 4 +-- 8 files changed, 18 insertions(+), 35 deletions(-) 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..8328a69d1 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 @@ -136,36 +136,19 @@ internal class ParamsBackendService( } /** - * Parse LogLevel from JSON. Supports both string (enum name) and int (ordinal) formats. + * Parse LogLevel from JSON. Supports string (enum name) */ @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) { + private fun parseLogLevel(json: JSONObject): LogLevel { + val logLevel = json.safeString("log_level") + if (logLevel != null) { try { - return LogLevel.valueOf(logLevelString.uppercase()) - } catch (e: IllegalArgumentException) { - Logging.warn("Invalid log level string: $logLevelString") + return LogLevel.valueOf(logLevel.uppercase()) + } catch (_: IllegalArgumentException) { + Logging.warn("Invalid log_level string: $logLevel") } } - // 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 + return LogLevel.NONE } } 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 e2b30e50f..3d3a05be9 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 @@ -100,8 +100,8 @@ 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 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 fe4e76edd..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 @@ -447,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/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/logging/otel/android/OtelPlatformProviderTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt index 5f6c3d46a..509a6ab49 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 } 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/IOtelPlatformProvider.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt index 0dfe4f11f..cd54266c9 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 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" } From 7d02427afac86a87ca6a3b2f25025dad98c19114 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 26 Feb 2026 17:00:20 -0500 Subject: [PATCH 6/8] otel logging detekt --- OneSignalSDK/detekt/detekt-baseline-core.xml | 4 + .../internal/backend/IParamsBackendService.kt | 2 +- .../backend/impl/ParamsBackendService.kt | 19 +--- .../core/internal/config/ConfigModel.kt | 3 +- .../core/internal/http/OneSignalService.kt | 7 ++ .../main/java/com/onesignal/debug/LogLevel.kt | 14 +++ .../crash/OneSignalCrashUploaderWrapper.kt | 16 +-- .../debug/internal/crash/OtelAnrDetector.kt | 6 +- .../logging/otel/android/OtelIdResolver.kt | 17 +-- .../otel/android/OtelPlatformProvider.kt | 18 ++- .../internal/OtelLifecycleManager.kt | 39 +++---- .../internal/crash/OtelAnrDetectorTest.kt | 3 +- .../otel/android/OtelIdResolverTest.kt | 107 ++++++++++++++++++ .../otel/android/OtelPlatformProviderTest.kt | 8 ++ .../onesignal/otel/IOtelPlatformProvider.kt | 7 ++ .../onesignal/otel/OneSignalOpenTelemetry.kt | 7 +- .../otel/config/OtelConfigRemoteOneSignal.kt | 17 ++- .../onesignal/otel/crash/OtelCrashHandler.kt | 2 + .../otel/OneSignalOpenTelemetryTest.kt | 3 +- .../com/onesignal/otel/OtelFactoryTest.kt | 3 +- .../onesignal/otel/config/OtelConfigTest.kt | 11 +- 21 files changed, 223 insertions(+), 90 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt 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 73812206f..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,5 +58,5 @@ internal class FCMParamsObject( internal class RemoteLoggingParamsObject( val logLevel: com.onesignal.debug.LogLevel? = null, - val isEnabled: Boolean = logLevel != null && logLevel != com.onesignal.debug.LogLevel.NONE, + 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 8328a69d1..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,21 +134,4 @@ internal class ParamsBackendService( isUnattributedEnabled, ) } - - /** - * Parse LogLevel from JSON. Supports string (enum name) - */ - @Suppress("ReturnCount", "TooGenericExceptionCaught", "SwallowedException") - private fun parseLogLevel(json: JSONObject): LogLevel { - val logLevel = json.safeString("log_level") - if (logLevel != null) { - try { - return LogLevel.valueOf(logLevel.uppercase()) - } catch (_: IllegalArgumentException) { - Logging.warn("Invalid log_level string: $logLevel") - } - } - - return LogLevel.NONE - } } 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 e5c17a87d..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) } 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/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index b9898ae06..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. @@ -46,15 +46,15 @@ internal class OneSignalCrashUploaderWrapper( @Suppress("TooGenericExceptionCaught") override fun start() { if (!OtelSdkSupport.isSupported) return - try { - runBlocking { + OneSignalDispatchers.launchOnIO { + try { uploader.start() + } catch (t: Throwable) { + com.onesignal.debug.internal.logging.Logging.warn( + "OneSignal: Crash uploader failed to start: ${t.message}", + t, + ) } - } 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 7daddb37b..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 @@ -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 { @@ -81,8 +82,8 @@ internal class OtelAnrDetector( } 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) @@ -145,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)") 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 71f71309a..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 @@ -226,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 3d3a05be9..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 @@ -107,16 +108,11 @@ internal class OtelPlatformProvider( 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 @@ -141,6 +137,8 @@ internal class OtelPlatformProvider( 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/OtelLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt index 3a0012288..6742c8614 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt @@ -32,6 +32,7 @@ import com.onesignal.otel.crash.IOtelAnrDetector * Thread safety: methods are synchronized on [lock] so that concurrent * calls from initEssentials (main) and the config store callback (IO) are safe. */ +@Suppress("TooManyFunctions") internal class OtelLifecycleManager( private val context: Context, ) : ISingletonModelStoreChangeHandler { @@ -62,8 +63,10 @@ internal class OtelLifecycleManager( try { val cachedConfig = readCurrentCachedConfig() - val action = OtelConfigEvaluator.evaluate(old = null, new = cachedConfig) - applyAction(action, cachedConfig) + 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) } @@ -90,10 +93,10 @@ internal class OtelLifecycleManager( val logLevel = model.remoteLoggingParams.logLevel val isEnabled = model.remoteLoggingParams.isEnabled val newConfig = OtelConfig(isEnabled = isEnabled, logLevel = logLevel) - val action = synchronized(lock) { - OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig) + synchronized(lock) { + val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig) + applyAction(action, newConfig) } - applyAction(action, newConfig) } catch (t: Throwable) { Logging.warn("OneSignal: Failed to refresh Otel from remote config: ${t.message}", t) } @@ -109,30 +112,22 @@ internal class OtelLifecycleManager( private fun readCurrentCachedConfig(): OtelConfig { val enabled = platformProvider.isRemoteLoggingEnabled - val levelStr = platformProvider.remoteLogLevel - val level = levelStr?.let { - try { - LogLevel.valueOf(it) - } catch (_: Throwable) { - null - } - } + 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) { - synchronized(lock) { - 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") - } + 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 } + currentConfig = newConfig } @Suppress("TooGenericExceptionCaught") 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/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt index bb1fcb8e4..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 @@ -861,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 509a6ab49..e5c2a2470 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 @@ -729,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") { 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 cd54266c9..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 @@ -54,4 +54,11 @@ interface IOtelPlatformProvider { */ 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 1ee759a04..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 @@ -108,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 = @@ -119,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 229e841eb..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() { 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/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 From f408b4ac02038b90896cfb9b59a627ce55d6cf9e Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 26 Feb 2026 17:00:52 -0500 Subject: [PATCH 7/8] ANR simulation --- .../sdktest/ui/secondary/SecondaryActivity.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 d05ba4813..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 @@ -77,6 +77,13 @@ class SecondaryActivity : ComponentActivity() { text = "CRASH", onClick = { triggerCrash() } ) + + Spacer(modifier = Modifier.height(16.dp)) + + DestructiveButton( + text = "SIMULATE ANR (10s block)", + onClick = { triggerAnr() } + ) } } } @@ -88,4 +95,9 @@ class SecondaryActivity : ComponentActivity() { .format(Date()) throw RuntimeException("Test crash from OneSignal Demo App - $timestamp") } + + @Suppress("MagicNumber") + private fun triggerAnr() { + Thread.sleep(10_000) + } } From 5f44e38796ee84e241929cb3230eaad16f590b6e Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 27 Feb 2026 16:39:48 -0500 Subject: [PATCH 8/8] Injecting classes --- .../internal/OtelLifecycleManager.kt | 29 +- .../otel/android/OtelPlatformProviderTest.kt | 98 ++++++ .../internal/OtelLifecycleManagerFaultTest.kt | 311 ++++++++++++++++++ 3 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt 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 index 6742c8614..1b8b97b58 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt @@ -16,7 +16,9 @@ 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 @@ -31,18 +33,31 @@ import com.onesignal.otel.crash.IOtelAnrDetector * * 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 { - createAndroidOtelPlatformProvider(context) + platformProviderFactory(context) } - private val logger = AndroidOtelLogger() + private val logger: IOtelLogger by lazy { loggerFactory() } private var crashHandler: IOtelCrashHandler? = null private var anrDetector: IOtelAnrDetector? = null @@ -192,7 +207,7 @@ internal class OtelLifecycleManager( private fun startCrashHandler() { if (crashHandler != null) return - val handler = OneSignalCrashHandlerFactory.createCrashHandler(context, logger) + val handler = crashHandlerFactory(context, logger) handler.initialize() crashHandler = handler Logging.info("OneSignal: Crash handler initialized — logs at: ${platformProvider.crashStoragePath}") @@ -200,11 +215,11 @@ internal class OtelLifecycleManager( private fun startAnrDetector() { if (anrDetector != null) return - val detector = createAnrDetector( + val detector = anrDetectorFactory( platformProvider, logger, - anrThresholdMs = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, - checkIntervalMs = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, + AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + AnrConstants.DEFAULT_CHECK_INTERVAL_MS, ) detector.start() anrDetector = detector @@ -214,7 +229,7 @@ internal class OtelLifecycleManager( @Suppress("TooGenericExceptionCaught") private fun startOtelLogging(logLevel: LogLevel) { remoteTelemetry?.shutdown() - val telemetry = OtelFactory.createRemoteTelemetry(platformProvider) + val telemetry = remoteTelemetryFactory(platformProvider) remoteTelemetry = telemetry val shouldSend: (LogLevel) -> Boolean = { level -> logLevel != LogLevel.NONE && level <= logLevel 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 e5c2a2470..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 @@ -777,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/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 +}