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..f5dd0bac1 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,18 @@ 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/crash/OneSignalCrashHandlerFactory.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt index 162a4f7e0..1ed9c0cec 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. + * Creates the Otel crash handler. * This should be called as early as possible, before any other initialization. * * @param context Android context for creating platform provider * @param logger Logger instance (can be shared with other components) + * @throws IllegalArgumentException if called on an unsupported SDK */ fun createCrashHandler( context: Context, logger: IOtelLogger, ): IOtelCrashHandler { - // Otel requires SDK 26+, use no-op for older versions - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - Logging.info("OneSignal: Creating no-op crash handler (SDK ${Build.VERSION.SDK_INT} < 26)") - return NoOpCrashHandler() + require(OtelSdkSupport.isSupported) { + "createCrashHandler called on unsupported SDK (< ${OtelSdkSupport.MIN_SDK_VERSION})" } - Logging.info("OneSignal: Creating Otel crash handler (SDK ${Build.VERSION.SDK_INT} >= 26)") - // Create platform provider - uses OtelIdResolver internally + Logging.info("OneSignal: Creating Otel crash handler (SDK >= ${OtelSdkSupport.MIN_SDK_VERSION})") val platformProvider = createAndroidOtelPlatformProvider(context) return OtelFactory.createCrashHandler(platformProvider, logger) } } - -/** - * No-op crash handler for SDK < 26. - */ -private class NoOpCrashHandler : IOtelCrashHandler { - override fun initialize() { - Logging.info("OneSignal: No-op crash handler initialized (SDK < 26, Otel not supported)") - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index e9d620d09..d918a59e2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -2,6 +2,7 @@ package com.onesignal.debug.internal.crash import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.OtelFactory @@ -44,8 +45,14 @@ internal class OneSignalCrashUploaderWrapper( } override fun start() { + if (!OtelSdkSupport.isSupported) return + runBlocking { - uploader.start() + try { + uploader.start() + } catch (t: Throwable) { + Logging.error("Error attempting to upload crash log", t) + } } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt new file mode 100644 index 000000000..47fc0034d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt @@ -0,0 +1,27 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build + +/** + * Centralizes the SDK version requirement for Otel-based features + * (crash reporting, ANR detection, remote log shipping). + * + * [isSupported] is writable internally so that unit tests can override + * the device-level gate without Robolectric @Config gymnastics. + */ +internal object OtelSdkSupport { + /** Otel libraries require Android O (API 26) or above. */ + const val MIN_SDK_VERSION = Build.VERSION_CODES.O // 26 + + /** + * Whether the current device meets the minimum SDK requirement. + * Production code should treat this as read-only; tests may flip it via [reset]/direct set. + */ + var isSupported: Boolean = Build.VERSION.SDK_INT >= MIN_SDK_VERSION + internal set + + /** Restores the runtime-detected value — call in test teardown. */ + fun reset() { + isSupported = Build.VERSION.SDK_INT >= MIN_SDK_VERSION + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt index d305fe64e..585aa8789 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 @@ -6,6 +6,7 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.ILogListener import com.onesignal.debug.LogLevel import com.onesignal.debug.OneSignalLogEvent +import com.onesignal.debug.internal.crash.OtelSdkSupport import com.onesignal.otel.IOtelOpenTelemetryRemote import com.onesignal.otel.OtelLoggingHelper import kotlinx.coroutines.CoroutineScope @@ -217,6 +218,8 @@ object Logging { // Check if this log level should be sent remotely if (!shouldSendLogLevel(level)) return + if (!OtelSdkSupport.isSupported) return + // Log asynchronously (non-blocking) otelLoggingScope.launch { try { @@ -228,9 +231,9 @@ object Logging { exceptionMessage = throwable?.message, exceptionStacktrace = throwable?.stackTraceToString(), ) - } catch (e: Exception) { + } catch (e: Throwable) { // Don't log Otel errors to Otel (would cause infinite loop) - // Just log to logcat silently + // Just print to logcat android.util.Log.e(TAG, "Failed to log to Otel: ${e.message}", e) } } 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..a1528846e 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 @@ -3,6 +3,7 @@ package com.onesignal.debug.internal.logging.otel.android import android.app.ActivityManager import android.content.Context import android.os.Build +import android.os.SystemClock import com.onesignal.common.OneSignalUtils import com.onesignal.common.OneSignalWrapper import com.onesignal.debug.internal.logging.Logging @@ -100,8 +101,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() = 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/OneSignalCrashLogInit.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt index d69b25fd2..c913ab619 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt @@ -9,7 +9,6 @@ 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 @@ -70,49 +69,40 @@ internal class OneSignalCrashLogInit( fun initializeOtelLogging() { // Initialize Otel logging asynchronously to avoid blocking initialization // Remote logging is not critical for crashes, so it's safe to do this in the background - // Uses OtelIdResolver internally which reads directly from SharedPreferences - // No service dependencies required - fully decoupled from service architecture + // To remove the variable of refactors of internal dependencies breaking this + // initialization, OtelIdResolver reads directly from SharedPreferences, to fully decouple + // from our 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) + Logging.debug("OneSignal: Remote logging enabled at level $remoteLogLevelStr, initializing Otel logging integration...") + val remoteTelemetry = 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 - } + val remoteLogLevel = + try { + LogLevel.valueOf(remoteLogLevelStr) + } catch (_: Exception) { + LogLevel.NONE + } - // 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 + remoteLogLevel == LogLevel.NONE -> false + else -> level <= remoteLogLevel } } - // 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") + Logging.debug("OneSignal: ✅ Otel logging integration initialized - logs at level $remoteLogLevelStr and above will be sent to remote server") } else { - Logging.debug("OneSignal: Remote logging disabled (level: $remoteLogLevelStr), skipping Otel logging integration") + 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) + } catch (t: Throwable) { + // Never crash the app if Otel fails to initialize. + Logging.warn("OneSignal: Failed to initialize Otel logging: ${t.message}", t) } } } 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..8f514ea7d 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 @@ -453,7 +453,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/OneSignalCrashHandlerFactoryTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt index 5eaaa714d..28351c7b3 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt @@ -7,6 +7,7 @@ import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger import com.onesignal.otel.IOtelCrashHandler import com.onesignal.otel.IOtelLogger +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf @@ -18,7 +19,6 @@ import org.robolectric.annotation.Config class OneSignalCrashHandlerFactoryTest : FunSpec({ lateinit var appContext: Context lateinit var logger: AndroidOtelLogger - // Save original handler to restore after tests val originalHandler: Thread.UncaughtExceptionHandler? = Thread.getDefaultUncaughtExceptionHandler() beforeAny { @@ -27,24 +27,34 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ } afterEach { - // Restore original uncaught exception handler after each test Thread.setDefaultUncaughtExceptionHandler(originalHandler) + OtelSdkSupport.reset() } test("createCrashHandler should return IOtelCrashHandler") { + OtelSdkSupport.isSupported = true val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) handler.shouldBeInstanceOf() } test("createCrashHandler should create handler that can be initialized") { + OtelSdkSupport.isSupported = true val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) handler shouldNotBe null handler.initialize() } + test("createCrashHandler should throw when SDK is unsupported") { + OtelSdkSupport.isSupported = false + shouldThrow { + OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) + } + } + test("createCrashHandler should accept mock logger") { + OtelSdkSupport.isSupported = true val mockLogger = mockk(relaxed = true) val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, mockLogger) @@ -54,15 +64,17 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ } test("handler should be idempotent when initialized multiple times") { + OtelSdkSupport.isSupported = true val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) handler.initialize() - handler.initialize() // Should not throw + handler.initialize() handler shouldNotBe null } test("createCrashHandler should work with different contexts") { + OtelSdkSupport.isSupported = true val context1: Context = ApplicationProvider.getApplicationContext() val context2: Context = ApplicationProvider.getApplicationContext() 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..42c153f6c 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 @@ -15,6 +15,7 @@ import com.onesignal.otel.IOtelPlatformProvider import com.onesignal.otel.OtelFactory import com.onesignal.user.internal.backend.IdentityConstants import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf @@ -111,7 +112,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 shouldBeGreaterThan 0 provider.currentThreadName shouldBe Thread.currentThread().name } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt new file mode 100644 index 000000000..f7660108e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt @@ -0,0 +1,38 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.robolectric.annotation.Config + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelSdkSupportTest : FunSpec({ + + afterEach { + OtelSdkSupport.reset() + } + + test("isSupported is true on SDK >= 26") { + OtelSdkSupport.reset() + OtelSdkSupport.isSupported shouldBe true + } + + test("isSupported can be overridden to false for testing") { + OtelSdkSupport.isSupported = false + OtelSdkSupport.isSupported shouldBe false + } + + test("reset restores runtime-detected value") { + OtelSdkSupport.isSupported = false + OtelSdkSupport.isSupported shouldBe false + + OtelSdkSupport.reset() + OtelSdkSupport.isSupported shouldBe true + } + + test("MIN_SDK_VERSION is 26") { + OtelSdkSupport.MIN_SDK_VERSION shouldBe 26 + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt index 6bde1defb..c1558a864 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt @@ -3,230 +3,282 @@ package com.onesignal.debug.internal.logging import android.os.Build import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.crash.OtelSdkSupport import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.OtelLoggingHelper import io.kotest.core.spec.style.FunSpec +import io.mockk.coVerify import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.robolectric.annotation.Config @RobolectricTest @Config(sdk = [Build.VERSION_CODES.O]) -class LoggingOtelTest : FunSpec({ - val mockTelemetry = mockk(relaxed = true) +class LoggingOtelTest : + FunSpec({ + val mockTelemetry = mockk(relaxed = true) - beforeEach { - // Reset Logging state - Logging.setOtelTelemetry(null, { false }) + beforeEach { + // Reset Logging state + Logging.setOtelTelemetry(null, { false }) + } + + afterEach { + OtelSdkSupport.reset() + } - // Setup default mock behavior - relaxed mock automatically returns mocks for suspend functions - // The return type (LogRecordBuilder) is handled by the relaxed mock, but we can't verify it - // directly due to type visibility. We'll test behavior instead. - } + test("setOtelTelemetry should store telemetry and enabled check function") { + // Given + val shouldSend = { _: LogLevel -> true } - test("setOtelTelemetry should store telemetry and enabled check function") { - // Given - val shouldSend = { _: LogLevel -> true } + // When + Logging.setOtelTelemetry(mockTelemetry, shouldSend) - // When - Logging.setOtelTelemetry(mockTelemetry, shouldSend) + // Then - verify it's set (we'll test it works by logging) + Logging.info("test") - // Then - verify it's set (we'll test it works by logging) - Logging.info("test") + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - verify it doesn't crash (integration test) + // Note: We can't verify exact calls due to OpenTelemetry type visibility } - // Then - verify it doesn't crash (integration test) - // Note: We can't verify exact calls due to OpenTelemetry type visibility - } + test("logToOtel should work when remote logging is enabled") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - test("logToOtel should work when remote logging is enabled") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // When + Logging.info("test message") - // When - Logging.info("test message") + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash (integration test) + // The actual Otel call is verified in otel module tests } - // Then - should not crash (integration test) - // The actual Otel call is verified in otel module tests - } + test("logToOtel should NOT crash when remote logging is disabled") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> false }) - test("logToOtel should NOT crash when remote logging is disabled") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> false }) + // When + Logging.info("test message") - // When - Logging.info("test message") + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash } - // Then - should not crash - } + test("logToOtel should NOT crash when telemetry is null") { + // Given + Logging.setOtelTelemetry(null, { _: LogLevel -> true }) - test("logToOtel should NOT crash when telemetry is null") { - // Given - Logging.setOtelTelemetry(null, { _: LogLevel -> true }) + // When + Logging.info("test message") - // When - Logging.info("test message") + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash } - // Then - should not crash - } + test("logToOtel should handle all log levels without crashing") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - test("logToOtel should handle all log levels without crashing") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // When + Logging.verbose("verbose message") + Logging.debug("debug message") + Logging.info("info message") + Logging.warn("warn message") + Logging.error("error message") + Logging.fatal("fatal message") - // When - Logging.verbose("verbose message") - Logging.debug("debug message") - Logging.info("info message") - Logging.warn("warn message") - Logging.error("error message") - Logging.fatal("fatal message") + // Wait for async logging + runBlocking { + delay(200) + } - // Wait for async logging - runBlocking { - delay(200) + // Then - should not crash for any level } - // Then - should not crash for any level - } + test("logToOtel should NOT log NONE level") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - test("logToOtel should NOT log NONE level") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // When + Logging.log(LogLevel.NONE, "none message") - // When - Logging.log(LogLevel.NONE, "none message") + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash, NONE level is skipped } - // Then - should not crash, NONE level is skipped - } + test("logToOtel should handle exceptions in logs") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + val exception = RuntimeException("test exception") - test("logToOtel should handle exceptions in logs") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - val exception = RuntimeException("test exception") + // When + Logging.error("error with exception", exception) - // When - Logging.error("error with exception", exception) + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash, exception details are included } - // Then - should not crash, exception details are included - } + test("logToOtel should handle null exception message") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + val exception = RuntimeException() - test("logToOtel should handle null exception message") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - val exception = RuntimeException() + // When + Logging.error("error with null exception message", exception) - // When - Logging.error("error with null exception message", exception) + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash } - // Then - should not crash - } + test("logToOtel should handle Otel errors gracefully") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // Note: We can't mock getLogger() to throw due to OpenTelemetry type visibility, + // but the real implementation in Logging.logToOtel() handles errors gracefully - test("logToOtel should handle Otel errors gracefully") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - // Note: We can't mock getLogger() to throw due to OpenTelemetry type visibility, - // but the real implementation in Logging.logToOtel() handles errors gracefully + // When + Logging.info("test message") - // When - Logging.info("test message") + // Wait for async logging + runBlocking { + delay(100) + } - // Wait for async logging - runBlocking { - delay(100) + // Then - should not crash, error handling is tested in integration tests } - // Then - should not crash, error handling is tested in integration tests - } + test("logToOtel should use dynamic remote logging check") { + // Given + var isEnabled = false + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> isEnabled }) + + // When - initially disabled + Logging.info("message 1") + runBlocking { delay(50) } - test("logToOtel should use dynamic remote logging check") { - // Given - var isEnabled = false - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> isEnabled }) + // When - enable remote logging + isEnabled = true + Logging.info("message 2") + runBlocking { delay(50) } - // When - initially disabled - Logging.info("message 1") - runBlocking { delay(50) } + // When - disable again + isEnabled = false + Logging.info("message 3") + runBlocking { delay(50) } - // When - enable remote logging - isEnabled = true - Logging.info("message 2") - runBlocking { delay(50) } + // Then - should not crash, dynamic check works + } - // When - disable again - isEnabled = false - Logging.info("message 3") - runBlocking { delay(50) } + test("logToOtel should handle multiple rapid log calls") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - // Then - should not crash, dynamic check works - } + // When - rapid fire logging + repeat(10) { + Logging.info("message $it") + } - test("logToOtel should handle multiple rapid log calls") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // Wait for async logging + runBlocking { + delay(200) + } - // When - rapid fire logging - repeat(10) { - Logging.info("message $it") + // Then - should not crash } - // Wait for async logging - runBlocking { - delay(200) - } + test("logToOtel should work with different message formats") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) - // Then - should not crash - } + // When + Logging.info("simple message") + Logging.info("message with numbers: 12345") + Logging.info("message with special chars: !@#$%") + Logging.info("message with unicode: 测试 🚀") - test("logToOtel should work with different message formats") { - // Given - Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // Wait for async logging + runBlocking { + delay(200) + } - // When - Logging.info("simple message") - Logging.info("message with numbers: 12345") - Logging.info("message with special chars: !@#$%") - Logging.info("message with unicode: 测试 🚀") + // Then - should not crash + } - // Wait for async logging - runBlocking { - delay(200) + test("logToOtel should work on Android 8 and newer") { + // Given + mockkObject(OtelLoggingHelper) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + OtelSdkSupport.isSupported = true + + // When + Logging.fatal("simple message") + + coVerify(exactly = 1) { + OtelLoggingHelper.logToOtel( + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + + unmockkObject(OtelLoggingHelper) } - // Then - should not crash - } -}) + test("logToOtel should skip Android 7 and older") { + // Given + mockkObject(OtelLoggingHelper) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + OtelSdkSupport.isSupported = false + + // When + Logging.fatal("simple message") + + coVerify(exactly = 0) { + OtelLoggingHelper.logToOtel( + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + + unmockkObject(OtelLoggingHelper) + } + }) 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..8729b5201 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 @@ -14,6 +14,8 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IdentityConstants import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.comparables.shouldBeLessThan import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain @@ -416,8 +418,8 @@ class OtelPlatformProviderTest : FunSpec({ val result = provider.processUptime // Then - (result > 0.0) shouldBe true - (result < 1000000.0) shouldBe true // Reasonable upper bound + result shouldBeGreaterThan 0 + result shouldBeLessThan 1000000 // Reasonable upper bound } // ===== currentThreadName Tests ===== 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..c2656aa04 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.info( "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 83dfdb335..8f49f6877 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 ms val currentThreadName: String // Crash-specific configuration 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..4aad37a3b 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 @@ -15,7 +15,8 @@ import kotlinx.coroutines.runBlocking internal class OtelCrashHandler( private val crashReporter: IOtelCrashReporter, private val logger: IOtelLogger, -) : Thread.UncaughtExceptionHandler, com.onesignal.otel.IOtelCrashHandler { +) : Thread.UncaughtExceptionHandler, + com.onesignal.otel.IOtelCrashHandler { private var existingHandler: Thread.UncaughtExceptionHandler? = null private val seenThrowables: MutableList = mutableListOf() private var initialized = false @@ -33,9 +34,7 @@ internal class OtelCrashHandler( } 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 - // handler and was faulty in a specific way. + // Prevents infinite loops if another handler calls us again synchronized(seenThrowables) { if (seenThrowables.contains(throwable)) { logger.warn("OtelCrashHandler: Ignoring duplicate throwable instance") @@ -44,12 +43,26 @@ internal class OtelCrashHandler( seenThrowables.add(throwable) } + try { + internalUncaughtException(thread, throwable) + } catch (t: Throwable) { + // If there is a bug with our crash handling we at least want to + // ensure the existingHandler is not skipped. + logger.error("Error thrown when saving crash report: ${t.message}") + } + + logger.debug("OtelCrashHandler: Delegating to existing crash handler") + existingHandler?.uncaughtException(thread, throwable) + } + + private fun internalUncaughtException(thread: Thread, throwable: Throwable) { logger.info("OtelCrashHandler: Uncaught exception detected - ${throwable.javaClass.simpleName}: ${throwable.message}") // Check if this is an ANR exception (though standalone ANR detector already handles ANRs) // This would only catch ANRs if they're thrown as exceptions, which is rare - val isAnr = throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) || - throwable.message?.contains("Application Not Responding", ignoreCase = true) == true + val isAnr = + throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) || + throwable.message?.contains("Application Not Responding", ignoreCase = true) == true // NOTE: Future improvements: // - Catch anything we may throw and print only to logcat @@ -61,7 +74,6 @@ internal class OtelCrashHandler( // thrown as exceptions (unlikely), and we still check if OneSignal is at fault. if (!isAnr && !isOneSignalAtFault(throwable)) { logger.debug("OtelCrashHandler: Crash is not OneSignal-related, delegating to existing handler") - existingHandler?.uncaughtException(thread, throwable) return } @@ -70,7 +82,10 @@ internal class OtelCrashHandler( } logger.info("OtelCrashHandler: OneSignal-related crash detected, saving crash report...") + saveCrash(thread, throwable) + } + private fun saveCrash(thread: Thread, throwable: Throwable) { /** * NOTE: The order and running sequentially is important as: * The existingHandler.uncaughtException can immediately terminate the @@ -92,16 +107,19 @@ internal class OtelCrashHandler( 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}") + logger.error("OtelCrashHandler: Runtime error, could not 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}") + logger.error("OtelCrashHandler: IO error, could not save crash report: ${e.message}") } catch (e: IllegalStateException) { // Handle illegal state errors - logger.error("OtelCrashHandler: Illegal state error saving crash report: ${e.message}") + logger.error("OtelCrashHandler: Illegal state error, could not save crash report: ${e.message}") + } catch (e: NoClassDefFoundError) { + logger.error( + "OtelCrashHandler: NoClassDefFoundError, this happens on Android 7 or older, " + + "or if parts of otel code was omitted from the app, could not save crash report: ${e.message}" + ) } - logger.info("OtelCrashHandler: Delegating to existing crash handler") - existingHandler?.uncaughtException(thread, throwable) } } 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..8fb1da716 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,7 +29,7 @@ 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 100 every { mockPlatformProvider.currentThreadName } returns "main" every { mockPlatformProvider.crashStoragePath } returns "/test/path" every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L 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..9d15415c7 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,8 +19,8 @@ class OtelFieldsPerEventTest : FunSpec({ onesignalId: String? = "test-onesignal-id", pushSubscriptionId: String? = "test-subscription-id", appState: String = "foreground", - processUptime: Double = 100.5, - threadName: String = "main-thread" + processUptime: Long = 100, + threadName: String = "main-thread", ) { every { mockPlatformProvider.appId } returns appId every { mockPlatformProvider.onesignalId } returns onesignalId @@ -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" }