From e5b4e4ff6f563b7075f395e7df63114820aac3d0 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Mon, 3 Nov 2025 14:11:56 -0500 Subject: [PATCH 01/19] feat: base parts to send otel crash reports --- .../sdktest/application/MainApplicationKT.kt | 4 + OneSignalSDK/onesignal/core/build.gradle | 10 ++ .../java/com/onesignal/core/CoreModule.kt | 12 ++ .../internal/crash/IOneSignalCrashHandler.kt | 3 + .../internal/crash/IOneSignalCrashReporter.kt | 5 + .../internal/crash/OneSignalCrashHandler.kt | 52 ++++++++ .../logging/otel/IOneSignalOpenTelemetry.kt | 13 ++ .../otel/OneSignalCrashReporterOtel.kt | 54 ++++++++ .../logging/otel/OneSignalOpenTelemetry.kt | 126 ++++++++++++++++++ 9 files changed, 279 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt index 123e747499..450b726fe7 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class MainApplicationKT : MultiDexApplication() { @@ -81,6 +82,9 @@ class MainApplicationKT : MultiDexApplication() { OneSignal.Notifications.requestPermission(true) Log.d(Tag.LOG_TAG, Text.ONESIGNAL_SDK_INIT) + + delay(5000) + throw RuntimeException("test crash 2025-10-31") } } diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index b0f2580711..0c58625154 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -88,6 +88,16 @@ dependencies { } } + implementation('io.opentelemetry:opentelemetry-api:1.55.0') + implementation('io.opentelemetry:opentelemetry-sdk:1.55.0') + // Docs sounds like okhttp is already included... + implementation('io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0') + implementation('io.opentelemetry:opentelemetry-exporter-otlp:1.55.0') + implementation('io.opentelemetry.semconv:opentelemetry-semconv:1.37.0') + // TODO: Make sure by adding Android OTel that it doesn't auto send stuff to us + + + testImplementation(project(':OneSignal:testhelpers')) testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 9083cddade..102d3e29b4 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -33,6 +33,12 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time +import com.onesignal.debug.internal.crash.IOneSignalCrashHandler +import com.onesignal.debug.internal.crash.IOneSignalCrashReporter +import com.onesignal.debug.internal.crash.OneSignalCrashHandler +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetry +import com.onesignal.debug.internal.logging.otel.OneSignalCrashReporterOtel +import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetry import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -81,6 +87,12 @@ internal class CoreModule : IModule { // Purchase Tracking builder.register().provides() + // TODO: Should be a startable service instead (but we need to wait for the app id...) + builder.register().provides() + builder.register().provides() + builder.register().provides() + + // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt new file mode 100644 index 0000000000..59503b49c3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt @@ -0,0 +1,3 @@ +package com.onesignal.debug.internal.crash + +interface IOneSignalCrashHandler diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt new file mode 100644 index 0000000000..368972dd36 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt @@ -0,0 +1,5 @@ +package com.onesignal.debug.internal.crash + +internal interface IOneSignalCrashReporter { + suspend fun sendCrash(thread: Thread, throwable: Throwable) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt new file mode 100644 index 0000000000..16961e2a9e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -0,0 +1,52 @@ +package com.onesignal.debug.internal.crash + +import android.util.Log +import kotlinx.coroutines.runBlocking + +// NOTE: For future refactors, code is written assuming this is a singleton +internal class OneSignalCrashHandler( + private val _crashReporter: IOneSignalCrashReporter, +) : IOneSignalCrashHandler, + Thread.UncaughtExceptionHandler { + private val existingHandler: Thread.UncaughtExceptionHandler? = + Thread.getDefaultUncaughtExceptionHandler() + + // TODO: Write the code to call this after we get the appId + // Recommend we only create an instance after getting a appId, otherwise there + // is no point setting up the handler. + init { + Thread.setDefaultUncaughtExceptionHandler(this) + } + + override fun uncaughtException(thread: Thread, throwable: Throwable) { + // TODO: Catch anything we may throw and silence it (print only to logcat) + // TODO: Add stackoverflow loop prevention + Log.e("OSCrashHandling", "uncaughtException TOP") + if (!isOneSignalAtFault(throwable)) { + existingHandler?.uncaughtException(thread, throwable) + return + } + + /** + * NOTE: The order and running sequentially is important as: + * The existingHandler.uncaughtException can immediately terminate the + * process, either directly (if this is Android's + * KillApplicationHandler) OR the app's handler / 3rd party SDK (either + * directly or more likely, by it calling Android's + * KillApplicationHandler). + * Given this, we can't parallelize the existingHandler work with ours. + * The safest thing is to try to finish our work as fast as possible + * (including ensuring our logging write buffers are flushed) then call + * the existingHandler so any crash handlers the app also has gets the + * crash even too. + * + * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for + * Process.killProcess, which KillApplicationHandler calls. + */ + runBlocking { _crashReporter.sendCrash(thread, throwable) } + existingHandler?.uncaughtException(thread, throwable) + } +} + +internal fun isOneSignalAtFault(throwable: Throwable): Boolean = + throwable.stackTrace.any { it.className.startsWith("com.onesignal") } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt new file mode 100644 index 0000000000..51392f4f74 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt @@ -0,0 +1,13 @@ +package com.onesignal.debug.internal.logging.otel + +import android.os.Build +import androidx.annotation.RequiresApi +import io.opentelemetry.api.logs.Logger +import io.opentelemetry.sdk.common.CompletableResultCode + +@RequiresApi(Build.VERSION_CODES.O) +internal interface IOneSignalOpenTelemetry { + val logger: Logger + + suspend fun forceFlush(): CompletableResultCode +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt new file mode 100644 index 0000000000..75e1413ab6 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt @@ -0,0 +1,54 @@ +package com.onesignal.debug.internal.logging.otel + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.onesignal.debug.internal.crash.IOneSignalCrashReporter +import io.opentelemetry.api.common.Attributes +import java.io.PrintWriter +import java.io.StringWriter + +@RequiresApi(Build.VERSION_CODES.O) +internal class OneSignalCrashReporterOtel( + val _openTelemetry: IOneSignalOpenTelemetry +) : IOneSignalCrashReporter { + companion object { + private const val EXCEPTION_TYPE = "exception.type" + private const val EXCEPTION_MESSAGE = "exception.message" + private const val EXCEPTION_STACKTRACE = "exception.stacktrace" + } + + override suspend fun sendCrash(therad: Thread, throwable: Throwable) { + Log.e("OSCrashHandling", "sendCrash TOP") + val attributesBuilder = + Attributes + .builder() + .put(EXCEPTION_STACKTRACE, throwable.stackTraceToString()) + .put(EXCEPTION_TYPE, throwable.javaClass.name) + .build() + // TODO:1: Remaining attributes + // TODO:1.1: process name: +// final String processName = ActivityThread.currentProcessName(); +// if (processName != null) { +// message.append("Process: ").append(processName).append(", "); +// } + + _openTelemetry.logger + .logRecordBuilder() + .setAllAttributes(attributesBuilder) + .emit() + + _openTelemetry.forceFlush() + Log.e("OSCrashHandling", "sendCrash BOTTOM") + } + + private fun stackTraceToString(throwable: Throwable): String { + val stringWriter = StringWriter(256) + val printWriter = PrintWriter(stringWriter) + + throwable.printStackTrace(printWriter) + printWriter.flush() + + return stringWriter.toString() + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt new file mode 100644 index 0000000000..5b9ab55cab --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -0,0 +1,126 @@ +package com.onesignal.debug.internal.logging.otel + +import android.os.Build +import androidx.annotation.RequiresApi +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import io.opentelemetry.api.logs.Logger +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.LogLimits +import io.opentelemetry.sdk.logs.LogRecordProcessor +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.semconv.ServiceAttributes +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal object LogLimitsConfig { + fun logLimits(): LogLimits = + LogLimits + .builder() + .setMaxNumberOfAttributes(128) + // We want a high value max length as the exception.stacktrace + // value can be lengthly. + .setMaxAttributeValueLength(32000) + .build() +} + +internal object LogRecordProcessorConfig { + @RequiresApi(Build.VERSION_CODES.O) + fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor = + BatchLogRecordProcessor + .builder(logRecordExporter) + .setMaxQueueSize(100) + .setMaxExportBatchSize(100) + .setExporterTimeout(Duration.ofSeconds(30)) + .setScheduleDelay(Duration.ofSeconds(1)) + .build() +} + +internal object LogRecordExporterConfig { + @RequiresApi(Build.VERSION_CODES.O) + fun otlpHttpLogRecordExporter( + headers: Map, + endpoint: String, + ): LogRecordExporter { + val builder = OtlpHttpLogRecordExporter.builder() + headers.forEach { builder.addHeader(it.key, it.value) } + builder + .setEndpoint(endpoint) + .setTimeout(Duration.ofSeconds(10)) + return builder.build() + } +} + +internal object SdkLoggerProviderConfig { + // TODO: Switch to sdklogs.onesignal.com + const val BASE_URL = "https://api.honeycomb.io:443" + + @RequiresApi(Build.VERSION_CODES.O) + fun create( + resource: Resource, + extraHttpHeaders: Map, + ): SdkLoggerProvider = + SdkLoggerProvider + .builder() + .setResource(resource) + .addLogRecordProcessor( + LogRecordProcessorConfig.batchLogRecordProcessor( + LogRecordExporterConfig.otlpHttpLogRecordExporter( + extraHttpHeaders, + "${BASE_URL}/v1/logs" + ) + ) + ).setLogLimits(LogLimitsConfig::logLimits) + .build() +} + +internal object ResourceConfig { + fun create(configModel: ConfigModel): Resource = + Resource + .getDefault() + .toBuilder() +// .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") + .put(ServiceAttributes.SERVICE_NAME, "OS-Android-SDK-Test") + .put("ossdk.app_id", configModel.appId) + // TODO: other fields + // TODO: Why not set all top level fields here? Use a top level provider + .build() +} + +@RequiresApi(Build.VERSION_CODES.O) +internal class OneSignalOpenTelemetry( + private val _configModelStore: ConfigModelStore, +) : IOneSignalOpenTelemetry { + private val sdk: OpenTelemetrySdk by lazy { + val extraHttpHeaders = + mapOf( + "OS-App-Id" to "value", + "x-honeycomb-team" to "", // TODO: REMOVE + ) + OpenTelemetrySdk + .builder() + .setLoggerProvider( + SdkLoggerProviderConfig.create( + ResourceConfig.create(_configModelStore.model), + extraHttpHeaders + ) + ).build() + } + + override val logger: Logger + get() = sdk.sdkLoggerProvider.loggerBuilder("loggerBuilder").build() + + override suspend fun forceFlush(): CompletableResultCode = + suspendCoroutine { + it.resume( + sdk.sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) + ) + } +} From 483c8d14bc869b49e616d0e87f54e6225d0c00d7 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Tue, 4 Nov 2025 21:14:05 -0500 Subject: [PATCH 02/19] feat: disk buffering for Otel Adding disk-buffering to otel logging required bumping to Kotlin 2.2. Adding the code to implement this in a follow up commit. --- OneSignalSDK/build.gradle | 2 +- OneSignalSDK/onesignal/core/build.gradle | 12 +++++++----- .../impl/NotificationRestoreWorkManager.kt | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index 41a491f000..95eb107dd1 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -14,7 +14,7 @@ buildscript { huaweiAgconnectVersion = '1.9.1.304' huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' - kotlinVersion = '1.9.25' + kotlinVersion = '2.2.21' coroutinesVersion = '1.7.3' kotestVersion = '5.8.0' ioMockVersion = '1.13.2' diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index 0c58625154..a581fa085e 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -88,12 +88,14 @@ dependencies { } } - implementation('io.opentelemetry:opentelemetry-api:1.55.0') - implementation('io.opentelemetry:opentelemetry-sdk:1.55.0') - // Docs sounds like okhttp is already included... - implementation('io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0') - implementation('io.opentelemetry:opentelemetry-exporter-otlp:1.55.0') + implementation platform("io.opentelemetry:opentelemetry-bom:1.55.0") + + implementation('io.opentelemetry:opentelemetry-api') + implementation('io.opentelemetry:opentelemetry-sdk') + + implementation('io.opentelemetry:opentelemetry-exporter-otlp') // includes okhttp implementation('io.opentelemetry.semconv:opentelemetry-semconv:1.37.0') + implementation('io.opentelemetry.contrib:opentelemetry-disk-buffering:1.51.0-alpha') // TODO: Make sure by adding Android OTel that it doesn't auto send stuff to us diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt index 1f91f2b4c7..78d9c4718d 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt @@ -17,13 +17,14 @@ internal class NotificationRestoreWorkManager : INotificationRestoreWorkManager // Notifications will never be force removed when the app's process is running, // so we only need to restore at most once per cold start of the app. private var restored = false + private val lock = Any() override fun beginEnqueueingWork( context: Context, shouldDelay: Boolean, ) { // Only allow one piece of work to be enqueued. - synchronized(restored) { + synchronized(lock) { if (restored) { return } From 3a60cefd5a2e6a502953062e03e2333ba9f2f98a Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Tue, 4 Nov 2025 21:19:28 -0500 Subject: [PATCH 03/19] feat: Otel crash reports log to disk When the app is started again we send any pending crash reports. --- .../sdktest/application/MainApplicationKT.kt | 4 +- .../java/com/onesignal/core/CoreModule.kt | 18 ++- .../internal/crash/IOneSignalCrashHandler.kt | 3 - .../internal/crash/OneSignalCrashHandler.kt | 35 +++-- .../otel/IOneSignalCrashConfigProvider.kt | 7 + .../logging/otel/IOneSignalOpenTelemetry.kt | 10 +- .../otel/OneSignalCrashConfigProvider.kt | 17 +++ .../otel/OneSignalCrashReporterOtel.kt | 2 +- .../logging/otel/OneSignalCrashUploader.kt | 60 ++++++++ .../logging/otel/OneSignalOpenTelemetry.kt | 128 ++++++------------ .../otel/config/OtelConfigCrashFile.kt | 53 ++++++++ .../otel/config/OtelConfigRemoteOneSignal.kt | 57 ++++++++ .../logging/otel/config/OtelConfigShared.kt | 50 +++++++ 13 files changed, 334 insertions(+), 110 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt index 450b726fe7..f5f78029bc 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt @@ -83,8 +83,8 @@ class MainApplicationKT : MultiDexApplication() { Log.d(Tag.LOG_TAG, Text.ONESIGNAL_SDK_INIT) - delay(5000) - throw RuntimeException("test crash 2025-10-31") + delay(3000) + //throw RuntimeException("test crash 2025-11-04 18") } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 102d3e29b4..5175d3c701 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -33,12 +33,17 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time -import com.onesignal.debug.internal.crash.IOneSignalCrashHandler import com.onesignal.debug.internal.crash.IOneSignalCrashReporter import com.onesignal.debug.internal.crash.OneSignalCrashHandler +import com.onesignal.debug.internal.logging.otel.IOneSignalCrashConfigProvider import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetry +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote +import com.onesignal.debug.internal.logging.otel.OneSignalCrashConfigProvider import com.onesignal.debug.internal.logging.otel.OneSignalCrashReporterOtel -import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetry +import com.onesignal.debug.internal.logging.otel.OneSignalCrashUploader +import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryCrashLocal +import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryRemote import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -88,10 +93,15 @@ internal class CoreModule : IModule { builder.register().provides() // TODO: Should be a startable service instead (but we need to wait for the app id...) + builder.register().provides() builder.register().provides() - builder.register().provides() - builder.register().provides() + builder.register().provides() + builder.register().provides() + builder.register().provides() + + builder.register().provides() + builder.register().provides() // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt deleted file mode 100644 index 59503b49c3..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashHandler.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.onesignal.debug.internal.crash - -interface IOneSignalCrashHandler diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt index 16961e2a9e..7ced040948 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -1,26 +1,41 @@ package com.onesignal.debug.internal.crash import android.util.Log +import com.onesignal.core.internal.startup.IStartableService import kotlinx.coroutines.runBlocking -// NOTE: For future refactors, code is written assuming this is a singleton +/** + * Purpose: Writes any crashes involving OneSignal to a file where they can + * later be send to OneSignal to help improve reliability. + * NOTE: For future refactors, code is written assuming this is a singleton + */ internal class OneSignalCrashHandler( private val _crashReporter: IOneSignalCrashReporter, -) : IOneSignalCrashHandler, +) : IStartableService, Thread.UncaughtExceptionHandler { - private val existingHandler: Thread.UncaughtExceptionHandler? = - Thread.getDefaultUncaughtExceptionHandler() + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() - // TODO: Write the code to call this after we get the appId - // Recommend we only create an instance after getting a appId, otherwise there - // is no point setting up the handler. - init { + override fun start() { + existingHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler(this) } override fun uncaughtException(thread: Thread, throwable: Throwable) { - // TODO: Catch anything we may throw and silence it (print only to logcat) - // TODO: Add stackoverflow loop prevention + // Ensure we never attempt to process the same throwable instance + // more than once. This would only happen if there was another crash + // handler faulty in a specific way. + synchronized(seenThrowables) { + if (seenThrowables.contains(throwable)) + return + seenThrowables.add(throwable) + } + + // TODO: Catch anything we may throw and print only to logcat + // TODO: Also send a stop command to OneSignalCrashUploader, + // give a bit of time to finish and then call existingHandler. + // * This way the app doesn't have to open a 2nd time to get the + // crash report and should help prevent duplicated reports. Log.e("OSCrashHandling", "uncaughtException TOP") if (!isOneSignalAtFault(throwable)) { existingHandler?.uncaughtException(thread, throwable) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt new file mode 100644 index 0000000000..b15f7160ae --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt @@ -0,0 +1,7 @@ +package com.onesignal.debug.internal.logging.otel + +interface IOneSignalCrashConfigProvider { + val path: String + + val minFileAgeForReadMillis: Long +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt index 51392f4f74..a6de9c75b3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt @@ -1,13 +1,17 @@ package com.onesignal.debug.internal.logging.otel -import android.os.Build -import androidx.annotation.RequiresApi import io.opentelemetry.api.logs.Logger import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.export.LogRecordExporter -@RequiresApi(Build.VERSION_CODES.O) internal interface IOneSignalOpenTelemetry { val logger: Logger suspend fun forceFlush(): CompletableResultCode } + +internal interface IOneSignalOpenTelemetryCrash : IOneSignalOpenTelemetry + +internal interface IOneSignalOpenTelemetryRemote : IOneSignalOpenTelemetry { + val logExporter: LogRecordExporter +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt new file mode 100644 index 0000000000..21b16470cf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt @@ -0,0 +1,17 @@ +package com.onesignal.debug.internal.logging.otel + +import com.onesignal.core.internal.application.IApplicationService +import java.io.File + +class OneSignalCrashConfigProvider( + private val _applicationService: IApplicationService +) : IOneSignalCrashConfigProvider { + override val path: String by lazy { + _applicationService.appContext.cacheDir.path + File.separator + + "onesignal" + File.separator + + "otel" + File.separator + + "crashes" + } + + override val minFileAgeForReadMillis: Long = 5_000 +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt index 75e1413ab6..94d49829c8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt @@ -10,7 +10,7 @@ import java.io.StringWriter @RequiresApi(Build.VERSION_CODES.O) internal class OneSignalCrashReporterOtel( - val _openTelemetry: IOneSignalOpenTelemetry + val _openTelemetry: IOneSignalOpenTelemetryCrash ) : IOneSignalCrashReporter { companion object { private const val EXCEPTION_TYPE = "exception.type" diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt new file mode 100644 index 0000000000..5a2b6b105b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt @@ -0,0 +1,60 @@ +package com.onesignal.debug.internal.logging.otel + +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile +import io.opentelemetry.sdk.logs.data.LogRecordData +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit + + +/** + * Purpose: This reads a local crash report files created by OneSignal's + * crash handler and sends them to OneSignal on the app's next start. + */ +internal class OneSignalCrashUploader( + private val _openTelemetryRemote: IOneSignalOpenTelemetryRemote, + private val _crashPathProvider: IOneSignalCrashConfigProvider, +) : IStartableService { + companion object { + const val SEND_TIMEOUT_SECONDS = 30L + } + + private fun getReports() = + OtelConfigCrashFile.SdkLoggerProviderConfig + .getFileLogRecordStorage( + _crashPathProvider.path, + _crashPathProvider.minFileAgeForReadMillis + ).iterator() + + override fun start() { + runBlocking { internalStart() } + } + + /** + * NOTE: sendCrashReports is called twice for the these reasons: + * 1. We want to send crash reports as soon as possible. + * - App may crash quickly after starting a 2nd time. + * 2. Reports could be delayed until the 2nd start after a crash + * - Otel doesn't let you read a file it could be writing so we must + * wait a minium amount of time after a crash to ensure we get the + * report from the last crash. + */ + suspend fun internalStart() { + sendCrashReports(getReports()) + delay(_crashPathProvider.minFileAgeForReadMillis) + sendCrashReports(getReports()) + } + + private fun sendCrashReports(reports: Iterator>) { + val networkExporter = _openTelemetryRemote.logExporter + var failed = false + // NOTE: next() will delete the previous report, so we only want to send + // another one if there isn't an issue making network calls. + while (reports.hasNext() && !failed) { + val future = networkExporter.export(reports.next()) + val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + failed = !result.isSuccess + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt index 5b9ab55cab..c06b5952f1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -2,116 +2,70 @@ package com.onesignal.debug.internal.logging.otel import android.os.Build import androidx.annotation.RequiresApi -import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile +import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared import io.opentelemetry.api.logs.Logger -import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.common.CompletableResultCode -import io.opentelemetry.sdk.logs.LogLimits -import io.opentelemetry.sdk.logs.LogRecordProcessor -import io.opentelemetry.sdk.logs.SdkLoggerProvider -import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor -import io.opentelemetry.sdk.logs.export.LogRecordExporter -import io.opentelemetry.sdk.resources.Resource -import io.opentelemetry.semconv.ServiceAttributes -import java.time.Duration import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -internal object LogLimitsConfig { - fun logLimits(): LogLimits = - LogLimits - .builder() - .setMaxNumberOfAttributes(128) - // We want a high value max length as the exception.stacktrace - // value can be lengthly. - .setMaxAttributeValueLength(32000) - .build() -} - -internal object LogRecordProcessorConfig { - @RequiresApi(Build.VERSION_CODES.O) - fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor = - BatchLogRecordProcessor - .builder(logRecordExporter) - .setMaxQueueSize(100) - .setMaxExportBatchSize(100) - .setExporterTimeout(Duration.ofSeconds(30)) - .setScheduleDelay(Duration.ofSeconds(1)) - .build() -} - -internal object LogRecordExporterConfig { - @RequiresApi(Build.VERSION_CODES.O) - fun otlpHttpLogRecordExporter( - headers: Map, - endpoint: String, - ): LogRecordExporter { - val builder = OtlpHttpLogRecordExporter.builder() - headers.forEach { builder.addHeader(it.key, it.value) } - builder - .setEndpoint(endpoint) - .setTimeout(Duration.ofSeconds(10)) - return builder.build() +@RequiresApi(Build.VERSION_CODES.O) +internal class OneSignalOpenTelemetryRemote( + private val _configModelStore: ConfigModelStore, +) : IOneSignalOpenTelemetryRemote { + val extraHttpHeaders by lazy { + mapOf( + "OS-App-Id" to _configModelStore.model.appId, + "x-honeycomb-team" to "", // TODO: REMOVE + ) } -} -internal object SdkLoggerProviderConfig { - // TODO: Switch to sdklogs.onesignal.com - const val BASE_URL = "https://api.honeycomb.io:443" + override val logExporter by lazy { + OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders) + } - @RequiresApi(Build.VERSION_CODES.O) - fun create( - resource: Resource, - extraHttpHeaders: Map, - ): SdkLoggerProvider = - SdkLoggerProvider + private val sdk: OpenTelemetrySdk by lazy { + OpenTelemetrySdk .builder() - .setResource(resource) - .addLogRecordProcessor( - LogRecordProcessorConfig.batchLogRecordProcessor( - LogRecordExporterConfig.otlpHttpLogRecordExporter( - extraHttpHeaders, - "${BASE_URL}/v1/logs" - ) + .setLoggerProvider( + OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create(_configModelStore.model), + extraHttpHeaders ) - ).setLogLimits(LogLimitsConfig::logLimits) - .build() -} + ).build() + } -internal object ResourceConfig { - fun create(configModel: ConfigModel): Resource = - Resource - .getDefault() - .toBuilder() -// .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") - .put(ServiceAttributes.SERVICE_NAME, "OS-Android-SDK-Test") - .put("ossdk.app_id", configModel.appId) - // TODO: other fields - // TODO: Why not set all top level fields here? Use a top level provider - .build() + override val logger: Logger + get() = sdk.sdkLoggerProvider.loggerBuilder("loggerBuilder").build() + + override suspend fun forceFlush(): CompletableResultCode = + suspendCoroutine { + it.resume( + sdk.sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) + ) + } } @RequiresApi(Build.VERSION_CODES.O) -internal class OneSignalOpenTelemetry( +internal class OneSignalOpenTelemetryCrashLocal( private val _configModelStore: ConfigModelStore, -) : IOneSignalOpenTelemetry { + private val _crashPathProvider: IOneSignalCrashConfigProvider, +) : IOneSignalOpenTelemetryCrash { private val sdk: OpenTelemetrySdk by lazy { - val extraHttpHeaders = - mapOf( - "OS-App-Id" to "value", - "x-honeycomb-team" to "", // TODO: REMOVE - ) OpenTelemetrySdk .builder() .setLoggerProvider( - SdkLoggerProviderConfig.create( - ResourceConfig.create(_configModelStore.model), - extraHttpHeaders + OtelConfigCrashFile.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create(_configModelStore.model), + _crashPathProvider.path, + _crashPathProvider.minFileAgeForReadMillis, ) - ).build() + ) + .build() } override val logger: Logger diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt new file mode 100644 index 0000000000..213eabb701 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt @@ -0,0 +1,53 @@ +package com.onesignal.debug.internal.logging.otel.config + +import android.os.Build +import androidx.annotation.RequiresApi +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig.logLimits +import io.opentelemetry.contrib.disk.buffering.exporters.LogRecordToDiskExporter +import io.opentelemetry.contrib.disk.buffering.storage.impl.FileLogRecordStorage +import io.opentelemetry.contrib.disk.buffering.storage.impl.FileStorageConfiguration +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.resources.Resource +import java.io.File +import kotlin.time.Duration.Companion.hours + +class OtelConfigCrashFile { + internal object SdkLoggerProviderConfig { + fun getFileLogRecordStorage( + rootDir: String, + minFileAgeForReadMillis: Long + ): FileLogRecordStorage = + FileLogRecordStorage.create( + File(rootDir), + FileStorageConfiguration + .builder() + // NOTE: Only use such as small maxFileAgeForWrite for + // crashes, as we want to send them as soon as possible + // without have to wait too long for buffers. + .setMaxFileAgeForWriteMillis(2_000) + .setMinFileAgeForReadMillis(minFileAgeForReadMillis) + .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds) + .build() + ) + + fun create( + resource: Resource, + rootDir: String, + minFileAgeForReadMillis: Long, + ): SdkLoggerProvider { + val logToDiskExporter = + LogRecordToDiskExporter + .builder(getFileLogRecordStorage(rootDir, minFileAgeForReadMillis)) + .build() + return SdkLoggerProvider + .builder() + .setResource(resource) + .addLogRecordProcessor( + BatchLogRecordProcessor.builder(logToDiskExporter).build() + ).setLogLimits(LogLimitsConfig::logLimits) + .build() + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt new file mode 100644 index 0000000000..6f7023c830 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt @@ -0,0 +1,57 @@ +package com.onesignal.debug.internal.logging.otel.config + +import android.os.Build +import androidx.annotation.RequiresApi +import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.BASE_URL +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.resources.Resource +import java.time.Duration + +internal class OtelConfigRemoteOneSignal { + object LogRecordExporterConfig { + @RequiresApi(Build.VERSION_CODES.O) + fun otlpHttpLogRecordExporter( + headers: Map, + endpoint: String, + ): LogRecordExporter { + val builder = OtlpHttpLogRecordExporter.builder() + headers.forEach { builder.addHeader(it.key, it.value) } + builder + .setEndpoint(endpoint) + .setTimeout(Duration.ofSeconds(10)) + return builder.build() + } + } + + object SdkLoggerProviderConfig { + // TODO: Switch to sdklogs.onesignal.com + const val BASE_URL = "https://api.honeycomb.io:443" + + @RequiresApi(Build.VERSION_CODES.O) + fun create( + resource: Resource, + extraHttpHeaders: Map, + ): SdkLoggerProvider = + SdkLoggerProvider + .builder() + .setResource(resource) + .addLogRecordProcessor( + OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor( + HttpRecordBatchExporter.create(extraHttpHeaders) + ) + ).setLogLimits(LogLimitsConfig::logLimits) + .build() + } + + object HttpRecordBatchExporter { + @RequiresApi(Build.VERSION_CODES.O) + fun create(extraHttpHeaders: Map) = + LogRecordExporterConfig.otlpHttpLogRecordExporter( + extraHttpHeaders, + "${BASE_URL}/v1/logs" + ) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt new file mode 100644 index 0000000000..61d65362a3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt @@ -0,0 +1,50 @@ +package com.onesignal.debug.internal.logging.otel.config + +import android.os.Build +import androidx.annotation.RequiresApi +import com.onesignal.core.internal.config.ConfigModel +import io.opentelemetry.sdk.logs.LogLimits +import io.opentelemetry.sdk.logs.LogRecordProcessor +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.semconv.ServiceAttributes +import java.time.Duration + +internal class OtelConfigShared { + object ResourceConfig { + fun create(configModel: ConfigModel): Resource = + Resource + .getDefault() + .toBuilder() + // .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") + .put(ServiceAttributes.SERVICE_NAME, "OS-Android-SDK-Test") + .put("ossdk.app_id", configModel.appId) + // TODO: other fields + // TODO: Why not set all top level fields here? Use a top level provider + .build() + } + + object LogRecordProcessorConfig { + @RequiresApi(Build.VERSION_CODES.O) + fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor = + BatchLogRecordProcessor + .builder(logRecordExporter) + .setMaxQueueSize(100) + .setMaxExportBatchSize(100) + .setExporterTimeout(Duration.ofSeconds(30)) + .setScheduleDelay(Duration.ofSeconds(1)) + .build() + } + + object LogLimitsConfig { + fun logLimits(): LogLimits = + LogLimits + .builder() + .setMaxNumberOfAttributes(128) + // We want a high value max length as the exception.stacktrace + // value can be lengthly. + .setMaxAttributeValueLength(32000) + .build() + } +} From 04a620c99d9ecc3458509ac5fb27843abdc18f22 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Wed, 5 Nov 2025 13:46:28 -0500 Subject: [PATCH 04/19] feat: add top level fields Also refactored crash handle classes into their own namespace. Refactored IOneSignalOpenTelemetry properties into suspend functions, as some values we want to add to all Otel requests were suspend. --- .../java/com/onesignal/core/CoreModule.kt | 13 ++-- .../logging/otel/IOneSignalOpenTelemetry.kt | 2 +- .../logging/otel/OneSignalOpenTelemetry.kt | 78 ++++++++++++------- .../attributes/OneSignalOtelTopLevelFields.kt | 58 ++++++++++++++ .../otel/config/OtelConfigCrashFile.kt | 5 +- .../logging/otel/config/OtelConfigShared.kt | 15 ++-- .../IOneSignalCrashConfigProvider.kt | 2 +- .../OneSignalCrashConfigProvider.kt | 2 +- .../{ => crash}/OneSignalCrashReporterOtel.kt | 23 ++---- .../{ => crash}/OneSignalCrashUploader.kt | 5 +- 10 files changed, 135 insertions(+), 68 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/{ => crash}/IOneSignalCrashConfigProvider.kt (65%) rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/{ => crash}/OneSignalCrashConfigProvider.kt (89%) rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/{ => crash}/OneSignalCrashReporterOtel.kt (68%) rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/{ => crash}/OneSignalCrashUploader.kt (91%) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 5175d3c701..59a3a1dcca 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -35,15 +35,16 @@ import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time import com.onesignal.debug.internal.crash.IOneSignalCrashReporter import com.onesignal.debug.internal.crash.OneSignalCrashHandler -import com.onesignal.debug.internal.logging.otel.IOneSignalCrashConfigProvider +import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetry import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote -import com.onesignal.debug.internal.logging.otel.OneSignalCrashConfigProvider -import com.onesignal.debug.internal.logging.otel.OneSignalCrashReporterOtel -import com.onesignal.debug.internal.logging.otel.OneSignalCrashUploader +import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashConfigProvider +import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashReporterOtel +import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashUploader import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryCrashLocal import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryRemote +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelTopLevelFields import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -92,7 +93,7 @@ internal class CoreModule : IModule { // Purchase Tracking builder.register().provides() - // TODO: Should be a startable service instead (but we need to wait for the app id...) + // Remote Crash and error logging builder.register().provides() builder.register().provides() builder.register().provides() @@ -103,6 +104,8 @@ internal class CoreModule : IModule { builder.register().provides() builder.register().provides() + builder.register().provides() + // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt index a6de9c75b3..4d5c606bb2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt @@ -5,7 +5,7 @@ import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.logs.export.LogRecordExporter internal interface IOneSignalOpenTelemetry { - val logger: Logger + suspend fun getLogger(): Logger suspend fun forceFlush(): CompletableResultCode } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt index c06b5952f1..831ffd4989 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -3,9 +3,11 @@ package com.onesignal.debug.internal.logging.otel import android.os.Build import androidx.annotation.RequiresApi import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelTopLevelFields import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared +import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider import io.opentelemetry.api.logs.Logger import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.common.CompletableResultCode @@ -13,10 +15,46 @@ import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +internal abstract class OneSignalOpenTelemetryBase( + private val _osFields: OneSignalOtelTopLevelFields +) : IOneSignalOpenTelemetry { + private val lock = Any() + private var sdk: OpenTelemetrySdk? = null + protected suspend fun getSdk(): OpenTelemetrySdk { + val attributes = _osFields.getAttributes() + synchronized(lock) { + var localSdk = sdk + if (localSdk != null) { + return localSdk + } + + localSdk = getSdkInstance(attributes) + sdk = localSdk + return localSdk + } + } + + protected abstract fun getSdkInstance(attributes: Map): OpenTelemetrySdk + + override suspend fun forceFlush(): CompletableResultCode { + val sdkLoggerProvider = getSdk().sdkLoggerProvider + return suspendCoroutine { + it.resume( + sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) + ) + } + } + + override suspend fun getLogger(): Logger = + getSdk().sdkLoggerProvider.loggerBuilder("loggerBuilder").build() +} + @RequiresApi(Build.VERSION_CODES.O) internal class OneSignalOpenTelemetryRemote( private val _configModelStore: ConfigModelStore, -) : IOneSignalOpenTelemetryRemote { + _osFields: OneSignalOtelTopLevelFields, +) : OneSignalOpenTelemetryBase(_osFields), + IOneSignalOpenTelemetryRemote { val extraHttpHeaders by lazy { mapOf( "OS-App-Id" to _configModelStore.model.appId, @@ -28,53 +66,33 @@ internal class OneSignalOpenTelemetryRemote( OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders) } - private val sdk: OpenTelemetrySdk by lazy { + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = OpenTelemetrySdk .builder() .setLoggerProvider( OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( - OtelConfigShared.ResourceConfig.create(_configModelStore.model), + OtelConfigShared.ResourceConfig.create(attributes), extraHttpHeaders ) ).build() - } - - override val logger: Logger - get() = sdk.sdkLoggerProvider.loggerBuilder("loggerBuilder").build() - - override suspend fun forceFlush(): CompletableResultCode = - suspendCoroutine { - it.resume( - sdk.sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) - ) - } } -@RequiresApi(Build.VERSION_CODES.O) internal class OneSignalOpenTelemetryCrashLocal( - private val _configModelStore: ConfigModelStore, private val _crashPathProvider: IOneSignalCrashConfigProvider, -) : IOneSignalOpenTelemetryCrash { - private val sdk: OpenTelemetrySdk by lazy { + _osFields: OneSignalOtelTopLevelFields, +) : OneSignalOpenTelemetryBase(_osFields), + IOneSignalOpenTelemetryCrash { + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = OpenTelemetrySdk .builder() .setLoggerProvider( OtelConfigCrashFile.SdkLoggerProviderConfig.create( - OtelConfigShared.ResourceConfig.create(_configModelStore.model), + OtelConfigShared.ResourceConfig.create( + attributes + ), _crashPathProvider.path, _crashPathProvider.minFileAgeForReadMillis, ) ) .build() - } - - override val logger: Logger - get() = sdk.sdkLoggerProvider.loggerBuilder("loggerBuilder").build() - - override suspend fun forceFlush(): CompletableResultCode = - suspendCoroutine { - it.resume( - sdk.sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) - ) - } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt new file mode 100644 index 0000000000..c8f0da58a4 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt @@ -0,0 +1,58 @@ +package com.onesignal.debug.internal.logging.otel.attributes + +import android.os.Build +import com.onesignal.common.AndroidUtils +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.squareup.wire.internal.toUnmodifiableMap + +/** + * Purpose: Fields to be included in every Otel request that goes out. + * Requirements: Only include fields that can NOT change during runtime, + * as these are only fetched once. (Calculated fields are ok) + */ +class OneSignalOtelTopLevelFields( + private val _applicationService: IApplicationService, + private val _configModelStore: ConfigModelStore, + private val _installIdService: IInstallIdService, +) { + suspend fun getAttributes(): Map { + val attributes: MutableMap = + mutableMapOf( + "ossdk.app_id" to _configModelStore.model.appId, + "ossdk.install_id" to _installIdService.getId().toString(), + "ossdk.sdk_base" to "android", + "ossdk.sdk_base_version" to OneSignalUtils.sdkVersion, + "ossdk.app_package_id" to + _applicationService.appContext.packageName, + "ossdk.app_version" to + (AndroidUtils.getAppVersion(_applicationService.appContext) ?: "unknown"), + "device.manufacturer" to Build.MANUFACTURER, + "device.model.identifier" to Build.MODEL, + "os.name" to "Android", + "os.version" to Build.VERSION.RELEASE, + "os.build_id" to Build.ID, + ) + + attributes + .putIfValueNotNull( + "ossdk.sdk_wrapper", + OneSignalWrapper.sdkType + ) + .putIfValueNotNull( + "ossdk.sdk_wrapper_version", + OneSignalWrapper.sdkVersion + ) + + return attributes.toUnmodifiableMap() + } +} + +internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { + if (value != null) + this[key] = value + return this +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt index 213eabb701..612f1f8c7f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt @@ -1,9 +1,6 @@ package com.onesignal.debug.internal.logging.otel.config -import android.os.Build -import androidx.annotation.RequiresApi import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig -import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig.logLimits import io.opentelemetry.contrib.disk.buffering.exporters.LogRecordToDiskExporter import io.opentelemetry.contrib.disk.buffering.storage.impl.FileLogRecordStorage import io.opentelemetry.contrib.disk.buffering.storage.impl.FileStorageConfiguration @@ -25,7 +22,7 @@ class OtelConfigCrashFile { .builder() // NOTE: Only use such as small maxFileAgeForWrite for // crashes, as we want to send them as soon as possible - // without have to wait too long for buffers. + // without having to wait too long for buffers. .setMaxFileAgeForWriteMillis(2_000) .setMinFileAgeForReadMillis(minFileAgeForReadMillis) .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt index 61d65362a3..8c0d7242ab 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt @@ -2,26 +2,29 @@ package com.onesignal.debug.internal.logging.otel.config import android.os.Build import androidx.annotation.RequiresApi -import com.onesignal.core.internal.config.ConfigModel import io.opentelemetry.sdk.logs.LogLimits import io.opentelemetry.sdk.logs.LogRecordProcessor import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor import io.opentelemetry.sdk.logs.export.LogRecordExporter import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.resources.ResourceBuilder import io.opentelemetry.semconv.ServiceAttributes import java.time.Duration +internal fun ResourceBuilder.putAll(attributes: Map): ResourceBuilder { + attributes.forEach { this.put(it.key, it.value) } + return this +} + internal class OtelConfigShared { object ResourceConfig { - fun create(configModel: ConfigModel): Resource = + fun create(attributes: Map): Resource = Resource .getDefault() .toBuilder() - // .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") + // .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") .put(ServiceAttributes.SERVICE_NAME, "OS-Android-SDK-Test") - .put("ossdk.app_id", configModel.appId) - // TODO: other fields - // TODO: Why not set all top level fields here? Use a top level provider + .putAll(attributes) .build() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt similarity index 65% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt index b15f7160ae..e6ba9fd0ff 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalCrashConfigProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt @@ -1,4 +1,4 @@ -package com.onesignal.debug.internal.logging.otel +package com.onesignal.debug.internal.logging.otel.crash interface IOneSignalCrashConfigProvider { val path: String diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt similarity index 89% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt index 21b16470cf..4648d56d53 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashConfigProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt @@ -1,4 +1,4 @@ -package com.onesignal.debug.internal.logging.otel +package com.onesignal.debug.internal.logging.otel.crash import com.onesignal.core.internal.application.IApplicationService import java.io.File diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt similarity index 68% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt index 94d49829c8..84ad67a269 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashReporterOtel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt @@ -1,14 +1,10 @@ -package com.onesignal.debug.internal.logging.otel +package com.onesignal.debug.internal.logging.otel.crash -import android.os.Build import android.util.Log -import androidx.annotation.RequiresApi import com.onesignal.debug.internal.crash.IOneSignalCrashReporter +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash import io.opentelemetry.api.common.Attributes -import java.io.PrintWriter -import java.io.StringWriter -@RequiresApi(Build.VERSION_CODES.O) internal class OneSignalCrashReporterOtel( val _openTelemetry: IOneSignalOpenTelemetryCrash ) : IOneSignalCrashReporter { @@ -18,11 +14,12 @@ internal class OneSignalCrashReporterOtel( private const val EXCEPTION_STACKTRACE = "exception.stacktrace" } - override suspend fun sendCrash(therad: Thread, throwable: Throwable) { + override suspend fun sendCrash(thread: Thread, throwable: Throwable) { Log.e("OSCrashHandling", "sendCrash TOP") val attributesBuilder = Attributes .builder() + .put(EXCEPTION_MESSAGE, throwable.message) .put(EXCEPTION_STACKTRACE, throwable.stackTraceToString()) .put(EXCEPTION_TYPE, throwable.javaClass.name) .build() @@ -33,7 +30,7 @@ internal class OneSignalCrashReporterOtel( // message.append("Process: ").append(processName).append(", "); // } - _openTelemetry.logger + _openTelemetry.getLogger() .logRecordBuilder() .setAllAttributes(attributesBuilder) .emit() @@ -41,14 +38,4 @@ internal class OneSignalCrashReporterOtel( _openTelemetry.forceFlush() Log.e("OSCrashHandling", "sendCrash BOTTOM") } - - private fun stackTraceToString(throwable: Throwable): String { - val stringWriter = StringWriter(256) - val printWriter = PrintWriter(stringWriter) - - throwable.printStackTrace(printWriter) - printWriter.flush() - - return stringWriter.toString() - } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt similarity index 91% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt index 5a2b6b105b..6089966dca 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalCrashUploader.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt @@ -1,13 +1,14 @@ -package com.onesignal.debug.internal.logging.otel +package com.onesignal.debug.internal.logging.otel.crash import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile import io.opentelemetry.sdk.logs.data.LogRecordData import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.util.concurrent.TimeUnit - /** * Purpose: This reads a local crash report files created by OneSignal's * crash handler and sends them to OneSignal on the app's next start. From 17dcf00121ee4060adde8084d10be577ca807b63 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Wed, 5 Nov 2025 18:32:38 -0500 Subject: [PATCH 05/19] feat: add Otel shared per event fields Also made all otel types internal and a few other misc cleanup. --- .../java/com/onesignal/core/CoreModule.kt | 6 +- .../com/onesignal/core/internal/time/ITime.kt | 5 ++ .../onesignal/core/internal/time/impl/Time.kt | 6 ++ .../internal/crash/OneSignalCrashHandler.kt | 4 +- .../logging/otel/IOneSignalOpenTelemetry.kt | 4 +- .../logging/otel/OneSignalOpenTelemetry.kt | 33 +++++++--- .../attributes/OneSignalOtelFieldsPerEvent.kt | 64 +++++++++++++++++++ ...elds.kt => OneSignalOtelFieldsTopLevel.kt} | 49 ++++++++------ .../otel/config/OtelConfigCrashFile.kt | 2 +- .../logging/otel/config/OtelConfigShared.kt | 3 +- .../crash/IOneSignalCrashConfigProvider.kt | 2 +- .../crash/OneSignalCrashConfigProvider.kt | 2 +- .../otel/crash/OneSignalCrashReporterOtel.kt | 29 ++++----- .../otel/crash/OneSignalCrashUploader.kt | 1 - 14 files changed, 153 insertions(+), 57 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/{OneSignalOtelTopLevelFields.kt => OneSignalOtelFieldsTopLevel.kt} (51%) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 59a3a1dcca..ff16b2ae1c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -44,7 +44,8 @@ import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashReporterOte import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashUploader import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryCrashLocal import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryRemote -import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelTopLevelFields +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -104,7 +105,8 @@ internal class CoreModule : IModule { builder.register().provides() builder.register().provides() - builder.register().provides() + builder.register().provides() + builder.register().provides() // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt index ff35096efd..8f1824d481 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt @@ -10,4 +10,9 @@ interface ITime { * current time and midnight, January 1, 1970 UTC). */ val currentTimeMillis: Long + + /** + * Returns how long the app has been running. + */ + val processUptimeMillis: Long } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt index 231f37edf3..753ef124d5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt @@ -1,8 +1,14 @@ package com.onesignal.core.internal.time.impl +import android.os.Build +import android.os.SystemClock +import androidx.annotation.RequiresApi import com.onesignal.core.internal.time.ITime internal class Time : ITime { override val currentTimeMillis: Long get() = System.currentTimeMillis() + override val processUptimeMillis: Long + @RequiresApi(Build.VERSION_CODES.N) + get() = SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt index 7ced040948..4440c37d66 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -1,6 +1,5 @@ package com.onesignal.debug.internal.crash -import android.util.Log import com.onesignal.core.internal.startup.IStartableService import kotlinx.coroutines.runBlocking @@ -24,7 +23,7 @@ internal class OneSignalCrashHandler( 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 faulty in a specific way. + // handler and was faulty in a specific way. synchronized(seenThrowables) { if (seenThrowables.contains(throwable)) return @@ -36,7 +35,6 @@ internal class OneSignalCrashHandler( // give a bit of time to finish and then call existingHandler. // * This way the app doesn't have to open a 2nd time to get the // crash report and should help prevent duplicated reports. - Log.e("OSCrashHandling", "uncaughtException TOP") if (!isOneSignalAtFault(throwable)) { existingHandler?.uncaughtException(thread, throwable) return diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt index 4d5c606bb2..2dd5590301 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt @@ -1,11 +1,11 @@ package com.onesignal.debug.internal.logging.otel -import io.opentelemetry.api.logs.Logger +import io.opentelemetry.api.logs.LogRecordBuilder import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.logs.export.LogRecordExporter internal interface IOneSignalOpenTelemetry { - suspend fun getLogger(): Logger + suspend fun getLogger(): LogRecordBuilder suspend fun forceFlush(): CompletableResultCode } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt index 831ffd4989..88a089b130 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -3,25 +3,32 @@ package com.onesignal.debug.internal.logging.otel import android.os.Build import androidx.annotation.RequiresApi import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelTopLevelFields +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider -import io.opentelemetry.api.logs.Logger +import io.opentelemetry.api.logs.LogRecordBuilder import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.common.CompletableResultCode import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +internal fun LogRecordBuilder.setAllAttributes(attributes: Map): LogRecordBuilder { + attributes.forEach { this.setAttribute(it.key, it.value) } + return this +} + internal abstract class OneSignalOpenTelemetryBase( - private val _osFields: OneSignalOtelTopLevelFields + private val _osTopLevelFields: OneSignalOtelFieldsTopLevel, + private val _osPerEventFields: OneSignalOtelFieldsPerEvent, ) : IOneSignalOpenTelemetry { private val lock = Any() private var sdk: OpenTelemetrySdk? = null protected suspend fun getSdk(): OpenTelemetrySdk { - val attributes = _osFields.getAttributes() + val attributes = _osTopLevelFields.getAttributes() synchronized(lock) { var localSdk = sdk if (localSdk != null) { @@ -45,15 +52,20 @@ internal abstract class OneSignalOpenTelemetryBase( } } - override suspend fun getLogger(): Logger = - getSdk().sdkLoggerProvider.loggerBuilder("loggerBuilder").build() + override suspend fun getLogger(): LogRecordBuilder = + getSdk().sdkLoggerProvider + .loggerBuilder("loggerBuilder") + .build() + .logRecordBuilder() + .setAllAttributes(_osPerEventFields.getAttributes()) } @RequiresApi(Build.VERSION_CODES.O) internal class OneSignalOpenTelemetryRemote( private val _configModelStore: ConfigModelStore, - _osFields: OneSignalOtelTopLevelFields, -) : OneSignalOpenTelemetryBase(_osFields), + _osTopLevelFields: OneSignalOtelFieldsTopLevel, + _osPerEventFields: OneSignalOtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), IOneSignalOpenTelemetryRemote { val extraHttpHeaders by lazy { mapOf( @@ -79,8 +91,9 @@ internal class OneSignalOpenTelemetryRemote( internal class OneSignalOpenTelemetryCrashLocal( private val _crashPathProvider: IOneSignalCrashConfigProvider, - _osFields: OneSignalOtelTopLevelFields, -) : OneSignalOpenTelemetryBase(_osFields), + _osTopLevelFields: OneSignalOtelFieldsTopLevel, + _osPerEventFields: OneSignalOtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), IOneSignalOpenTelemetryCrash { override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = OpenTelemetrySdk diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt new file mode 100644 index 0000000000..937755fc05 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt @@ -0,0 +1,64 @@ +package com.onesignal.debug.internal.logging.otel.attributes + +import com.onesignal.common.IDManager +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.time.ITime +import com.onesignal.user.internal.identity.IdentityModelStore +import com.squareup.wire.internal.toUnmodifiableMap + +internal class OneSignalOtelFieldsPerEvent( + private val _applicationService: IApplicationService, + private val _configModelStore: ConfigModelStore, + private val _identityModelStore: IdentityModelStore, + private val _time: ITime, +) { + fun getAttributes(): Map { + val attributes: MutableMap = mutableMapOf() + + attributes + .putIfValueNotNull( + "$OS_OTEL_NAMESPACE.onesignal_id", + onesignalId + ) + .putIfValueNotNull( + "$OS_OTEL_NAMESPACE.push_subscription_id", + subscriptionId + ) + + attributes.put("android.app.state", appState) + attributes.put("process.uptime", processUptime.toString()) + attributes.put("thread.name", currentThreadName) + + return attributes.toUnmodifiableMap() + } + + private val onesignalId: String? get() { + val onesignalId = _identityModelStore.model.onesignalId + if (IDManager.isLocalId(onesignalId)) { + return null + } + return onesignalId + } + + private val subscriptionId: String? get() { + val pushSubscriptionId = _configModelStore.model.pushSubscriptionId + if (pushSubscriptionId == null || + IDManager.isLocalId(pushSubscriptionId)) { + return null + } + return pushSubscriptionId + } + + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ + private val appState: String get() = + if (_applicationService.isInForeground) "foreground" else "background" + + // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime + private val processUptime: Double get() = + _time.processUptimeMillis / 1_000.toDouble() + + // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes + private val currentThreadName: String get() = + Thread.currentThread().name +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt similarity index 51% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt index c8f0da58a4..50d929774a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelTopLevelFields.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt @@ -9,41 +9,53 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService import com.squareup.wire.internal.toUnmodifiableMap +// Used on all attributes / fields we add to Otel events that is NOT part of +// their spec. We do this to make it clear where the source of this field is. +internal const val OS_OTEL_NAMESPACE: String = "ossdk" + /** * Purpose: Fields to be included in every Otel request that goes out. * Requirements: Only include fields that can NOT change during runtime, * as these are only fetched once. (Calculated fields are ok) */ -class OneSignalOtelTopLevelFields( +internal class OneSignalOtelFieldsTopLevel( private val _applicationService: IApplicationService, private val _configModelStore: ConfigModelStore, private val _installIdService: IInstallIdService, ) { suspend fun getAttributes(): Map { - val attributes: MutableMap = + val attributes: MutableMap = mutableMapOf( - "ossdk.app_id" to _configModelStore.model.appId, - "ossdk.install_id" to _installIdService.getId().toString(), - "ossdk.sdk_base" to "android", - "ossdk.sdk_base_version" to OneSignalUtils.sdkVersion, - "ossdk.app_package_id" to + "$OS_OTEL_NAMESPACE.app_id" to + _configModelStore.model.appId, + "$OS_OTEL_NAMESPACE.install_id" to + _installIdService.getId().toString(), + "$OS_OTEL_NAMESPACE.sdk_base" + to "android", + "$OS_OTEL_NAMESPACE.sdk_base_version" to + OneSignalUtils.sdkVersion, + "$OS_OTEL_NAMESPACE.app_package_id" to _applicationService.appContext.packageName, - "ossdk.app_version" to + "$OS_OTEL_NAMESPACE.app_version" to (AndroidUtils.getAppVersion(_applicationService.appContext) ?: "unknown"), - "device.manufacturer" to Build.MANUFACTURER, - "device.model.identifier" to Build.MODEL, - "os.name" to "Android", - "os.version" to Build.VERSION.RELEASE, - "os.build_id" to Build.ID, + "device.manufacturer" + to Build.MANUFACTURER, + "device.model.identifier" + to Build.MODEL, + "os.name" + to "Android", + "os.version" + to Build.VERSION.RELEASE, + "os.build_id" + to Build.ID, ) attributes .putIfValueNotNull( - "ossdk.sdk_wrapper", + "$OS_OTEL_NAMESPACE.sdk_wrapper", OneSignalWrapper.sdkType - ) - .putIfValueNotNull( - "ossdk.sdk_wrapper_version", + ).putIfValueNotNull( + "$OS_OTEL_NAMESPACE.sdk_wrapper_version", OneSignalWrapper.sdkVersion ) @@ -52,7 +64,8 @@ class OneSignalOtelTopLevelFields( } internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { - if (value != null) + if (value != null) { this[key] = value + } return this } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt index 612f1f8c7f..0556d75a5b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt @@ -10,7 +10,7 @@ import io.opentelemetry.sdk.resources.Resource import java.io.File import kotlin.time.Duration.Companion.hours -class OtelConfigCrashFile { +internal class OtelConfigCrashFile { internal object SdkLoggerProviderConfig { fun getFileLogRecordStorage( rootDir: String, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt index 8c0d7242ab..a5b09ef149 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt @@ -22,8 +22,7 @@ internal class OtelConfigShared { Resource .getDefault() .toBuilder() - // .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") - .put(ServiceAttributes.SERVICE_NAME, "OS-Android-SDK-Test") + .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") .putAll(attributes) .build() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt index e6ba9fd0ff..37b9741a7c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt @@ -1,6 +1,6 @@ package com.onesignal.debug.internal.logging.otel.crash -interface IOneSignalCrashConfigProvider { +internal interface IOneSignalCrashConfigProvider { val path: String val minFileAgeForReadMillis: Long diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt index 4648d56d53..4e40dccaf1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt @@ -3,7 +3,7 @@ package com.onesignal.debug.internal.logging.otel.crash import com.onesignal.core.internal.application.IApplicationService import java.io.File -class OneSignalCrashConfigProvider( +internal class OneSignalCrashConfigProvider( private val _applicationService: IApplicationService ) : IOneSignalCrashConfigProvider { override val path: String by lazy { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt index 84ad67a269..37fb3a08bb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt @@ -1,41 +1,38 @@ package com.onesignal.debug.internal.logging.otel.crash -import android.util.Log import com.onesignal.debug.internal.crash.IOneSignalCrashReporter import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash +import com.onesignal.debug.internal.logging.otel.attributes.OS_OTEL_NAMESPACE import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity internal class OneSignalCrashReporterOtel( val _openTelemetry: IOneSignalOpenTelemetryCrash ) : IOneSignalCrashReporter { companion object { - private const val EXCEPTION_TYPE = "exception.type" - private const val EXCEPTION_MESSAGE = "exception.message" - private const val EXCEPTION_STACKTRACE = "exception.stacktrace" + private const val OTEL_EXCEPTION_TYPE = "exception.type" + private const val OTEL_EXCEPTION_MESSAGE = "exception.message" + private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" + } override suspend fun sendCrash(thread: Thread, throwable: Throwable) { - Log.e("OSCrashHandling", "sendCrash TOP") val attributesBuilder = Attributes .builder() - .put(EXCEPTION_MESSAGE, throwable.message) - .put(EXCEPTION_STACKTRACE, throwable.stackTraceToString()) - .put(EXCEPTION_TYPE, throwable.javaClass.name) + .put(OTEL_EXCEPTION_MESSAGE, throwable.message) + .put(OTEL_EXCEPTION_STACKTRACE, throwable.stackTraceToString()) + .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name) + // This matches the top level thread.name today, but it may not + // always if things are refactored to use a different thread. + .put("$OS_OTEL_NAMESPACE.exception.thread.name", thread.name) .build() - // TODO:1: Remaining attributes - // TODO:1.1: process name: -// final String processName = ActivityThread.currentProcessName(); -// if (processName != null) { -// message.append("Process: ").append(processName).append(", "); -// } _openTelemetry.getLogger() - .logRecordBuilder() .setAllAttributes(attributesBuilder) + .setSeverity(Severity.FATAL) .emit() _openTelemetry.forceFlush() - Log.e("OSCrashHandling", "sendCrash BOTTOM") } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt index 6089966dca..6346b260c2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt @@ -1,7 +1,6 @@ package com.onesignal.debug.internal.logging.otel.crash import com.onesignal.core.internal.startup.IStartableService -import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile import io.opentelemetry.sdk.logs.data.LogRecordData From 052ddfa787c9d8696b5ca48a6adb71fdf9c1737d Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Wed, 5 Nov 2025 19:16:36 -0500 Subject: [PATCH 06/19] chore: improved names and lint fixes --- .../internal/crash/IOneSignalCrashReporter.kt | 2 +- .../debug/internal/crash/OneSignalCrashHandler.kt | 7 ++++--- .../logging/otel/OneSignalOpenTelemetry.kt | 15 ++++++++------- .../attributes/OneSignalOtelFieldsPerEvent.kt | 3 +-- .../otel/config/OtelConfigRemoteOneSignal.kt | 4 ++-- .../otel/crash/OneSignalCrashReporterOtel.kt | 6 +++--- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt index 368972dd36..c51391c3d6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt @@ -1,5 +1,5 @@ package com.onesignal.debug.internal.crash internal interface IOneSignalCrashReporter { - suspend fun sendCrash(thread: Thread, throwable: Throwable) + suspend fun saveCrash(thread: Thread, throwable: Throwable) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt index 4440c37d66..65d580a42d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -25,8 +25,9 @@ internal class OneSignalCrashHandler( // more than once. This would only happen if there was another crash // handler and was faulty in a specific way. synchronized(seenThrowables) { - if (seenThrowables.contains(throwable)) + if (seenThrowables.contains(throwable)) { return + } seenThrowables.add(throwable) } @@ -55,8 +56,8 @@ internal class OneSignalCrashHandler( * * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for * Process.killProcess, which KillApplicationHandler calls. - */ - runBlocking { _crashReporter.sendCrash(thread, throwable) } + */ + runBlocking { _crashReporter.saveCrash(thread, throwable) } existingHandler?.uncaughtException(thread, throwable) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt index 88a089b130..0a5f691eb7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -26,22 +26,23 @@ internal abstract class OneSignalOpenTelemetryBase( private val _osPerEventFields: OneSignalOtelFieldsPerEvent, ) : IOneSignalOpenTelemetry { private val lock = Any() - private var sdk: OpenTelemetrySdk? = null + private var sdkCachedValue: OpenTelemetrySdk? = null + protected suspend fun getSdk(): OpenTelemetrySdk { val attributes = _osTopLevelFields.getAttributes() synchronized(lock) { - var localSdk = sdk + var localSdk = sdkCachedValue if (localSdk != null) { return localSdk } localSdk = getSdkInstance(attributes) - sdk = localSdk + sdkCachedValue = localSdk return localSdk } } - protected abstract fun getSdkInstance(attributes: Map): OpenTelemetrySdk + protected abstract fun getSdkInstance(attributes: Map): OpenTelemetrySdk override suspend fun forceFlush(): CompletableResultCode { val sdkLoggerProvider = getSdk().sdkLoggerProvider @@ -53,7 +54,8 @@ internal abstract class OneSignalOpenTelemetryBase( } override suspend fun getLogger(): LogRecordBuilder = - getSdk().sdkLoggerProvider + getSdk() + .sdkLoggerProvider .loggerBuilder("loggerBuilder") .build() .logRecordBuilder() @@ -106,6 +108,5 @@ internal class OneSignalOpenTelemetryCrashLocal( _crashPathProvider.path, _crashPathProvider.minFileAgeForReadMillis, ) - ) - .build() + ).build() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt index 937755fc05..1f1c281658 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt @@ -20,8 +20,7 @@ internal class OneSignalOtelFieldsPerEvent( .putIfValueNotNull( "$OS_OTEL_NAMESPACE.onesignal_id", onesignalId - ) - .putIfValueNotNull( + ).putIfValueNotNull( "$OS_OTEL_NAMESPACE.push_subscription_id", subscriptionId ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt index 6f7023c830..b6ffb30c53 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt @@ -27,7 +27,7 @@ internal class OtelConfigRemoteOneSignal { } object SdkLoggerProviderConfig { - // TODO: Switch to sdklogs.onesignal.com + // TODO: Switch to https://sdklogs.onesignal.com:443/sdk/otel const val BASE_URL = "https://api.honeycomb.io:443" @RequiresApi(Build.VERSION_CODES.O) @@ -40,7 +40,7 @@ internal class OtelConfigRemoteOneSignal { .setResource(resource) .addLogRecordProcessor( OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor( - HttpRecordBatchExporter.create(extraHttpHeaders) + HttpRecordBatchExporter.create(extraHttpHeaders) ) ).setLogLimits(LogLimitsConfig::logLimits) .build() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt index 37fb3a08bb..e711978ca6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt @@ -13,10 +13,9 @@ internal class OneSignalCrashReporterOtel( private const val OTEL_EXCEPTION_TYPE = "exception.type" private const val OTEL_EXCEPTION_MESSAGE = "exception.message" private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" - } - override suspend fun sendCrash(thread: Thread, throwable: Throwable) { + override suspend fun saveCrash(thread: Thread, throwable: Throwable) { val attributesBuilder = Attributes .builder() @@ -28,7 +27,8 @@ internal class OneSignalCrashReporterOtel( .put("$OS_OTEL_NAMESPACE.exception.thread.name", thread.name) .build() - _openTelemetry.getLogger() + _openTelemetry + .getLogger() .setAllAttributes(attributesBuilder) .setSeverity(Severity.FATAL) .emit() From 38ba6fba9504f52e90a3d33940ffe7d9d415ab92 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Wed, 5 Nov 2025 19:16:59 -0500 Subject: [PATCH 07/19] feat: Add idempotency key to Otel events --- .../otel/attributes/OneSignalOtelFieldsPerEvent.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt index 1f1c281658..eeff4da6d5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt @@ -6,6 +6,7 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.time.ITime import com.onesignal.user.internal.identity.IdentityModelStore import com.squareup.wire.internal.toUnmodifiableMap +import java.util.UUID internal class OneSignalOtelFieldsPerEvent( private val _applicationService: IApplicationService, @@ -16,6 +17,8 @@ internal class OneSignalOtelFieldsPerEvent( fun getAttributes(): Map { val attributes: MutableMap = mutableMapOf() + attributes.put("log.record.uid", recordId.toString()) + attributes .putIfValueNotNull( "$OS_OTEL_NAMESPACE.onesignal_id", @@ -60,4 +63,9 @@ internal class OneSignalOtelFieldsPerEvent( // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes private val currentThreadName: String get() = Thread.currentThread().name + + // idempotency so the backend can filter on duplicate events + // https://opentelemetry.io/docs/specs/semconv/general/logs/#general-log-identification-attributes + private val recordId: UUID get() = + UUID.randomUUID() } From fc0d830ad15123cf6edbbee759c61bcb72c5d450 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Thu, 6 Nov 2025 16:09:49 -0500 Subject: [PATCH 08/19] fix: update HTTP headers sent to otel endpoints --- .../java/com/onesignal/core/internal/http/impl/HttpClient.kt | 5 ++++- .../debug/internal/logging/otel/OneSignalOpenTelemetry.kt | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 825637f31c..29b5578aec 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -26,6 +26,9 @@ import java.net.UnknownHostException import java.util.Scanner import javax.net.ssl.HttpsURLConnection +internal const val HTTP_SDK_VERSION_HEADER_KEY = "SDK-Version" +internal val HTTP_SDK_VERSION_HEADER_VALUE = "onesignal/android/${OneSignalUtils.sdkVersion}" + internal class HttpClient( private val _connectionFactory: IHttpConnectionFactory, private val _prefs: IPreferencesService, @@ -131,7 +134,7 @@ internal class HttpClient( con.useCaches = false con.connectTimeout = timeout con.readTimeout = timeout - con.setRequestProperty("SDK-Version", "onesignal/android/" + OneSignalUtils.sdkVersion) + con.setRequestProperty(HTTP_SDK_VERSION_HEADER_KEY, HTTP_SDK_VERSION_HEADER_VALUE) if (OneSignalWrapper.sdkType != null && OneSignalWrapper.sdkVersion != null) { con.setRequestProperty("SDK-Wrapper", "onesignal/${OneSignalWrapper.sdkType}/${OneSignalWrapper.sdkVersion}") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt index 0a5f691eb7..f0a82c5053 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -3,6 +3,8 @@ package com.onesignal.debug.internal.logging.otel import android.os.Build import androidx.annotation.RequiresApi import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_KEY +import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_VALUE import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile @@ -71,7 +73,8 @@ internal class OneSignalOpenTelemetryRemote( IOneSignalOpenTelemetryRemote { val extraHttpHeaders by lazy { mapOf( - "OS-App-Id" to _configModelStore.model.appId, + "X-OneSignal-App-Id" to _configModelStore.model.appId, + HTTP_SDK_VERSION_HEADER_KEY to HTTP_SDK_VERSION_HEADER_VALUE, "x-honeycomb-team" to "", // TODO: REMOVE ) } From f7ab8a057abc55f9570ec47e169c64a983ddd214 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Mon, 17 Nov 2025 12:08:59 -0500 Subject: [PATCH 09/19] fix: start crash handler sooner We need to start it sooner to catch crashes in things like other IStartableService's. --- .../core/src/main/java/com/onesignal/core/CoreModule.kt | 2 +- .../onesignal/debug/internal/crash/OneSignalCrashHandler.kt | 6 ++---- .../src/main/java/com/onesignal/internal/OneSignalImp.kt | 6 ++++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index ff16b2ae1c..1c3da1fa48 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -102,7 +102,7 @@ internal class CoreModule : IModule { builder.register().provides() builder.register().provides() - builder.register().provides() + builder.register().provides() builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt index 65d580a42d..c2555dd78b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -1,6 +1,5 @@ package com.onesignal.debug.internal.crash -import com.onesignal.core.internal.startup.IStartableService import kotlinx.coroutines.runBlocking /** @@ -10,12 +9,11 @@ import kotlinx.coroutines.runBlocking */ internal class OneSignalCrashHandler( private val _crashReporter: IOneSignalCrashReporter, -) : IStartableService, - Thread.UncaughtExceptionHandler { +) : Thread.UncaughtExceptionHandler { private var existingHandler: Thread.UncaughtExceptionHandler? = null private val seenThrowables: MutableList = mutableListOf() - override fun start() { + init { existingHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler(this) } 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 7b6ee4041e..b0c8c9cdfa 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 @@ -23,6 +23,7 @@ import com.onesignal.core.internal.startup.StartupService import com.onesignal.debug.IDebugManager import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.DebugManager +import com.onesignal.debug.internal.crash.OneSignalCrashHandler import com.onesignal.debug.internal.logging.Logging import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.location.ILocationManager @@ -214,6 +215,11 @@ internal class OneSignalImp( // Give the logging singleton access to the application service to support visual logging. Logging.applicationService = applicationService + + // 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. + services.getService() } private fun updateConfig() { From a61e2bcaca1754170860b302f1bf9766c18edb38 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Mon, 17 Nov 2025 12:12:02 -0500 Subject: [PATCH 10/19] fix: error handling around models Models can throw if something goes wrong initializing them. --- .../logging/otel/OneSignalOpenTelemetry.kt | 13 +++++- .../attributes/OneSignalOtelFieldsPerEvent.kt | 45 ++++++++++++++----- .../attributes/OneSignalOtelFieldsTopLevel.kt | 4 -- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt index f0a82c5053..bff009784a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -5,6 +5,7 @@ import androidx.annotation.RequiresApi import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_KEY import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_VALUE +import com.onesignal.debug.internal.logging.Logging import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile @@ -71,9 +72,17 @@ internal class OneSignalOpenTelemetryRemote( _osPerEventFields: OneSignalOtelFieldsPerEvent, ) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), IOneSignalOpenTelemetryRemote { - val extraHttpHeaders by lazy { + private val appId: String get() = + try { + _configModelStore.model.appId + } catch (_: NullPointerException) { + Logging.error("Auth missing for crash log reporting!") + "" + } + + val extraHttpHeaders: Map by lazy { mapOf( - "X-OneSignal-App-Id" to _configModelStore.model.appId, + "X-OneSignal-App-Id" to appId, HTTP_SDK_VERSION_HEADER_KEY to HTTP_SDK_VERSION_HEADER_VALUE, "x-honeycomb-team" to "", // TODO: REMOVE ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt index eeff4da6d5..ede397474e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt @@ -4,6 +4,7 @@ import com.onesignal.common.IDManager import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.time.ITime +import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.identity.IdentityModelStore import com.squareup.wire.internal.toUnmodifiableMap import java.util.UUID @@ -17,10 +18,13 @@ internal class OneSignalOtelFieldsPerEvent( fun getAttributes(): Map { val attributes: MutableMap = mutableMapOf() - attributes.put("log.record.uid", recordId.toString()) + attributes["log.record.uid"] = recordId.toString() attributes .putIfValueNotNull( + "$OS_OTEL_NAMESPACE.app_id", + appId + ).putIfValueNotNull( "$OS_OTEL_NAMESPACE.onesignal_id", onesignalId ).putIfValueNotNull( @@ -28,28 +32,47 @@ internal class OneSignalOtelFieldsPerEvent( subscriptionId ) - attributes.put("android.app.state", appState) - attributes.put("process.uptime", processUptime.toString()) - attributes.put("thread.name", currentThreadName) + attributes["android.app.state"] = appState + attributes["process.uptime"] = processUptime.toString() + attributes["thread.name"] = currentThreadName return attributes.toUnmodifiableMap() } + private val appId: String? get() { + try { + return _configModelStore.model.appId + } catch (_: NullPointerException) { + Logging.warn("app_id not available to add to crash log") + return null + } + } + private val onesignalId: String? get() { - val onesignalId = _identityModelStore.model.onesignalId - if (IDManager.isLocalId(onesignalId)) { + try { + val onesignalId = _identityModelStore.model.onesignalId + if (IDManager.isLocalId(onesignalId)) { + return null + } + return onesignalId + } catch (_: NullPointerException) { + Logging.warn("onesignalId not available to add to crash log") return null } - return onesignalId } private val subscriptionId: String? get() { - val pushSubscriptionId = _configModelStore.model.pushSubscriptionId - if (pushSubscriptionId == null || - IDManager.isLocalId(pushSubscriptionId)) { + try { + val pushSubscriptionId = _configModelStore.model.pushSubscriptionId + if (pushSubscriptionId == null || + IDManager.isLocalId(pushSubscriptionId)) { + return null + } + return pushSubscriptionId + } catch (_: NullPointerException) { + Logging.warn("subscriptionId not available to add to crash log") return null } - return pushSubscriptionId } // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt index 50d929774a..0ec1827df1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt @@ -5,7 +5,6 @@ import com.onesignal.common.AndroidUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.OneSignalWrapper import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService import com.squareup.wire.internal.toUnmodifiableMap @@ -20,14 +19,11 @@ internal const val OS_OTEL_NAMESPACE: String = "ossdk" */ internal class OneSignalOtelFieldsTopLevel( private val _applicationService: IApplicationService, - private val _configModelStore: ConfigModelStore, private val _installIdService: IInstallIdService, ) { suspend fun getAttributes(): Map { val attributes: MutableMap = mutableMapOf( - "$OS_OTEL_NAMESPACE.app_id" to - _configModelStore.model.appId, "$OS_OTEL_NAMESPACE.install_id" to _installIdService.getId().toString(), "$OS_OTEL_NAMESPACE.sdk_base" From 597b858f673a07408d01a63818fdd9949a2aa9f3 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Thu, 11 Dec 2025 18:46:29 -0500 Subject: [PATCH 11/19] feat: add remote param for remote logging --- .../internal/backend/IParamsBackendService.kt | 5 ++++ .../backend/impl/ParamsBackendService.kt | 11 +++++++++ .../core/internal/config/ConfigModel.kt | 23 +++++++++++++++++++ .../config/impl/ConfigModelStoreListener.kt | 2 ++ .../otel/crash/OneSignalCrashUploader.kt | 12 ++++++++++ 5 files changed, 53 insertions(+) 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 514cc798bc..216810b302 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 @@ -36,6 +36,7 @@ class ParamsObject( var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, + val remoteLoggingParams: RemoteLoggingParamsObject, ) class InfluenceParamsObject( @@ -53,3 +54,7 @@ class FCMParamsObject( val appId: String? = null, val apiKey: String? = null, ) + +class RemoteLoggingParamsObject( + val enable: Boolean? = 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 85dd452d41..be7dbc4e44 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 @@ -11,6 +11,7 @@ import com.onesignal.core.internal.backend.FCMParamsObject import com.onesignal.core.internal.backend.IParamsBackendService import com.onesignal.core.internal.backend.InfluenceParamsObject import com.onesignal.core.internal.backend.ParamsObject +import com.onesignal.core.internal.backend.RemoteLoggingParamsObject import com.onesignal.core.internal.http.CacheKeys import com.onesignal.core.internal.http.IHttpClient import com.onesignal.core.internal.http.impl.OptionalHeaders @@ -57,6 +58,15 @@ internal class ParamsBackendService( ) } + // Process Remote Logging params + var remoteLoggingParams: RemoteLoggingParamsObject? = null + responseJson.expandJSONObject("remote_logging") { + remoteLoggingParams = + RemoteLoggingParamsObject( + enable = it.safeBool("enable"), + ) + } + return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), @@ -75,6 +85,7 @@ internal class ParamsBackendService( opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), influenceParams = influenceParams ?: InfluenceParamsObject(), fcmParams = fcmParams ?: FCMParamsObject(), + remoteLoggingParams = remoteLoggingParams ?: RemoteLoggingParamsObject(), ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index 74d31c4669..ff4423a06c 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 @@ -301,6 +301,9 @@ class ConfigModel : Model() { val fcmParams: FCMConfigModel get() = getAnyProperty(::fcmParams.name) { FCMConfigModel(this, ::fcmParams.name) } as FCMConfigModel + val remoteLoggingParams: RemoteLoggingConfigModel + get() = getAnyProperty(::remoteLoggingParams.name) { RemoteLoggingConfigModel(this, ::remoteLoggingParams.name) } as RemoteLoggingConfigModel + override fun createModelForProperty( property: String, jsonObject: JSONObject, @@ -317,6 +320,12 @@ class ConfigModel : Model() { return model } + if (property == ::remoteLoggingParams.name) { + val model = RemoteLoggingConfigModel(this, ::remoteLoggingParams.name) + model.initializeFromJson(jsonObject) + return model + } + return null } } @@ -425,3 +434,17 @@ class FCMConfigModel(parentModel: Model, parentProperty: String) : Model(parentM setOptStringProperty(::apiKey.name, value) } } + +class RemoteLoggingConfigModel( + parentModel: Model, + parentProperty: String, +) : Model(parentModel, parentProperty) { + /** + * Do we send OneSignal related logs to OneSignal's server. + */ + var enable: Boolean? + get() = getOptBooleanProperty(::enable.name) { null } + set(value) { + setOptBooleanProperty(::enable.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 5e3664e5f7..2f3c415619 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 @@ -103,6 +103,8 @@ internal class ConfigModelStoreListener( params.influenceParams.isIndirectEnabled?.let { config.influenceParams.isIndirectEnabled = it } params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it } + params.remoteLoggingParams.enable?.let { config.remoteLoggingParams.enable = it } + _configModelStore.replace(config, ModelChangeTags.HYDRATE) success = true } catch (ex: BackendException) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt index 6346b260c2..f39d593d93 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt @@ -1,6 +1,8 @@ package com.onesignal.debug.internal.logging.otel.crash +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile import io.opentelemetry.sdk.logs.data.LogRecordData @@ -15,6 +17,7 @@ import java.util.concurrent.TimeUnit internal class OneSignalCrashUploader( private val _openTelemetryRemote: IOneSignalOpenTelemetryRemote, private val _crashPathProvider: IOneSignalCrashConfigProvider, + _configModelStore: ConfigModelStore, ) : IStartableService { companion object { const val SEND_TIMEOUT_SECONDS = 30L @@ -27,7 +30,14 @@ internal class OneSignalCrashUploader( _crashPathProvider.minFileAgeForReadMillis ).iterator() + private val enable = + _configModelStore.model.remoteLoggingParams.enable ?: false + override fun start() { + Logging.info("OneSignalCrashUploader.enable: $enable") + if (!enable) { + return + } runBlocking { internalStart() } } @@ -53,8 +63,10 @@ internal class OneSignalCrashUploader( // another one if there isn't an issue making network calls. while (reports.hasNext() && !failed) { val future = networkExporter.export(reports.next()) + Logging.debug("Sending OneSignal crash report") val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) failed = !result.isSuccess + Logging.debug("Done OneSignal crash report, failed: $failed") } } } From 76460ee5ea698821762c55f076ea2a0fc322a220 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Sun, 28 Dec 2025 15:21:38 -0500 Subject: [PATCH 12/19] decoupled and modularized --- OneSignalSDK/build.gradle | 12 +- OneSignalSDK/detekt/detekt-baseline-core.xml | 37 +-- OneSignalSDK/detekt/detekt-config.yml | 2 +- OneSignalSDK/onesignal/core/build.gradle | 14 +- .../core/src/main/AndroidManifest.xml | 7 +- .../java/com/onesignal/core/CoreModule.kt | 29 +-- .../internal/backend/IParamsBackendService.kt | 1 + .../core/internal/config/ConfigModel.kt | 3 + .../internal/crash/IOneSignalCrashReporter.kt | 5 - .../internal/crash/OneSignalCrashHandler.kt | 64 ----- .../crash/OneSignalCrashHandlerFactory.kt | 59 +++++ .../crash/OneSignalCrashUploaderWrapper.kt | 63 +++++ .../debug/internal/logging/Logging.kt | 73 ++++++ .../logging/otel/IOneSignalOpenTelemetry.kt | 17 -- .../logging/otel/android/AndroidOtelLogger.kt | 26 ++ .../android/AndroidOtelPlatformProvider.kt | 133 ++++++++++ .../attributes/OneSignalOtelFieldsPerEvent.kt | 94 ------- .../attributes/OneSignalOtelFieldsTopLevel.kt | 67 ----- .../crash/IOneSignalCrashConfigProvider.kt | 7 - .../crash/OneSignalCrashConfigProvider.kt | 17 -- .../otel/crash/OneSignalCrashReporterOtel.kt | 38 --- .../com/onesignal/internal/OneSignalImp.kt | 83 ++++++- .../core/src/test/AndroidManifest.xml | 7 + .../onesignal/debug/internal/LoggingTests.kt | 4 +- .../crash/OneSignalCrashHandlerFactoryTest.kt | 68 +++++ .../internal/crash/OtelIntegrationTest.kt | 173 +++++++++++++ .../debug/internal/logging/LoggingOtelTest.kt | 232 ++++++++++++++++++ OneSignalSDK/onesignal/otel/.gitignore | 1 + OneSignalSDK/onesignal/otel/build.gradle | 67 +++++ .../onesignal/otel/consumer-rules.pro | 0 .../onesignal/otel/proguard-rules.pro | 21 ++ .../otel/src/main/AndroidManifest.xml | 4 + .../com/onesignal/otel/IOtelCrashHandler.kt | 13 + .../com/onesignal/otel/IOtelCrashReporter.kt | 8 + .../java/com/onesignal/otel/IOtelLogger.kt | 12 + .../com/onesignal/otel/IOtelOpenTelemetry.kt | 25 ++ .../onesignal/otel/IOtelPlatformProvider.kt | 37 +++ .../onesignal}/otel/OneSignalOpenTelemetry.kt | 92 +++---- .../java/com/onesignal/otel/OtelFactory.kt | 78 ++++++ .../com/onesignal/otel/OtelLoggingHelper.kt | 63 +++++ .../otel/attributes/OtelFieldsPerEvent.kt | 35 +++ .../otel/attributes/OtelFieldsTopLevel.kt | 42 ++++ .../otel/config/OtelConfigCrashFile.kt | 18 +- .../otel/config/OtelConfigRemoteOneSignal.kt | 22 +- .../otel/config/OtelConfigShared.kt | 30 ++- .../onesignal/otel/crash/OtelCrashHandler.kt | 97 ++++++++ .../onesignal/otel/crash/OtelCrashReporter.kt | 60 +++++ .../otel/crash/OtelCrashUploader.kt} | 63 +++-- .../com/onesignal/otel/OtelFactoryTest.kt | 65 +++++ .../otel/attributes/OtelFieldsPerEventTest.kt | 67 +++++ .../otel/attributes/OtelFieldsTopLevelTest.kt | 69 ++++++ .../otel/crash/OtelCrashHandlerTest.kt | 124 ++++++++++ .../otel/crash/OtelCrashReporterTest.kt | 85 +++++++ OneSignalSDK/settings.gradle | 1 + 54 files changed, 2059 insertions(+), 475 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt create mode 100644 OneSignalSDK/onesignal/otel/.gitignore create mode 100644 OneSignalSDK/onesignal/otel/build.gradle create mode 100644 OneSignalSDK/onesignal/otel/consumer-rules.pro create mode 100644 OneSignalSDK/onesignal/otel/proguard-rules.pro create mode 100644 OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt rename OneSignalSDK/onesignal/{core/src/main/java/com/onesignal/debug/internal/logging => otel/src/main/java/com/onesignal}/otel/OneSignalOpenTelemetry.kt (53%) create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt rename OneSignalSDK/onesignal/{core/src/main/java/com/onesignal/debug/internal/logging => otel/src/main/java/com/onesignal}/otel/config/OtelConfigCrashFile.kt (73%) rename OneSignalSDK/onesignal/{core/src/main/java/com/onesignal/debug/internal/logging => otel/src/main/java/com/onesignal}/otel/config/OtelConfigRemoteOneSignal.kt (65%) rename OneSignalSDK/onesignal/{core/src/main/java/com/onesignal/debug/internal/logging => otel/src/main/java/com/onesignal}/otel/config/OtelConfigShared.kt (59%) create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt rename OneSignalSDK/onesignal/{core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt => otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt} (51%) create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index a55c59451e..7e650b439d 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -21,6 +21,10 @@ buildscript { // AndroidX Lifecycle and Activity versions lifecycleVersion = '2.6.2' activityVersion = '1.7.2' + // OpenTelemetry versions + opentelemetryBomVersion = '1.55.0' + opentelemetrySemconvVersion = '1.37.0' + opentelemetryDiskBufferingVersion = '1.51.0-alpha' ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10) spotlessVersion = '6.25.0' tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP @@ -43,11 +47,9 @@ buildscript { ] } - buildscript { - repositories sharedRepos - dependencies { - classpath sharedDeps - } + repositories sharedRepos + dependencies { + classpath sharedDeps } } diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 38625ed0f4..c2717cee33 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -16,6 +16,11 @@ ComplexMethod:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse ComplexMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse ComplexMethod:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse + ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _applicationService: IApplicationService + ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _installIdService: IInstallIdService + ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _time: ITime ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _applicationService: IApplicationService ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _backgroundServices: List<(IBackgroundService)> ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _time: ITime @@ -61,6 +66,11 @@ ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _time: ITime ConstructorParameterNaming:OSDatabase.kt$OSDatabase$private val _outcomeTableProvider: OutcomeTableProvider + ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _applicationService: IApplicationService + ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _installIdService: IInstallIdService + ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _time: ITime ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _newRecordState: NewRecordsState ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _operationModelStore: OperationModelStore @@ -102,6 +112,7 @@ ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _operationRepo: IOperationRepo ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _outcomeEventsController: IOutcomeEventsController + ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:SessionListener.kt$SessionListener$private val _sessionService: ISessionService ConstructorParameterNaming:SessionManager.kt$SessionManager$private val _outcomeController: IOutcomeEventsController ConstructorParameterNaming:SessionService.kt$SessionService$private val _applicationService: IApplicationService @@ -137,7 +148,6 @@ 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 _applicationService: IApplicationService ConstructorParameterNaming:UserManager.kt$UserManager$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:UserManager.kt$UserManager$private val _languageContext: ILanguageContext ConstructorParameterNaming:UserManager.kt$UserManager$private val _propertiesModelStore: PropertiesModelStore @@ -192,9 +202,6 @@ 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: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, ) - LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, ) LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, ) LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, ) LongParameterList:SubscriptionObject.kt$SubscriptionObject$( val id: String? = null, val type: SubscriptionObjectType? = null, val token: String? = null, val enabled: Boolean? = null, val notificationTypes: Int? = null, val sdk: String? = null, val deviceModel: String? = null, val deviceOS: String? = null, val rooted: Boolean? = null, val netType: Int? = null, val carrier: String? = null, val appVersion: String? = null, ) @@ -205,7 +212,6 @@ MagicNumber:BackgroundManager.kt$BackgroundManager$5000 MagicNumber:ChannelTracker.kt$ChannelTracker$1000L MagicNumber:ChannelTracker.kt$ChannelTracker$60 - MagicNumber:CompletionAwaiter.kt$CompletionAwaiter$10 MagicNumber:ConfigModelStoreListener.kt$ConfigModelStoreListener$1000 MagicNumber:HttpClient.kt$HttpClient$1_000 MagicNumber:HttpClient.kt$HttpClient$429 @@ -284,6 +290,7 @@ 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 ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean + ReturnCount:Logging.kt$Logging$private fun logToOtel( level: LogLevel, message: String, throwable: Throwable?, ) ReturnCount:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private suspend fun loginUser(loginUserOp: LoginUserFromSubscriptionOperation): ExecutionResponse ReturnCount:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse ReturnCount:Model.kt$Model$protected fun getOptBigDecimalProperty( name: String, create: (() -> BigDecimal?)? = null, ): BigDecimal? @@ -316,22 +323,25 @@ SwallowedException:DeviceService.kt$DeviceService$e: ClassNotFoundException SwallowedException:DeviceService.kt$DeviceService$e: PackageManager.NameNotFoundException SwallowedException:JSONUtils.kt$JSONUtils$t: Throwable - SwallowedException:OneSignalImp.kt$OneSignalImp$e: TimeoutCancellationException SwallowedException:PermissionsActivity.kt$PermissionsActivity$e: ClassNotFoundException SwallowedException:PreferencesService.kt$PreferencesService$ex: Exception + SwallowedException:SyncJobService.kt$SyncJobService$e: Exception SwallowedException:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$t: Throwable - ThrowsCount:OneSignalImp.kt$OneSignalImp$private suspend fun suspendUntilInit() + ThrowsCount:OneSignalImp.kt$OneSignalImp$private suspend fun waitUntilInitInternal(operationName: String? = null) TooGenericExceptionCaught:AndroidUtils.kt$AndroidUtils$e: Throwable TooGenericExceptionCaught:DeviceUtils.kt$DeviceUtils$t: Throwable TooGenericExceptionCaught:HttpClient.kt$HttpClient$e: Throwable TooGenericExceptionCaught:HttpClient.kt$HttpClient$t: Throwable TooGenericExceptionCaught:JSONUtils.kt$JSONUtils$t: Throwable + TooGenericExceptionCaught:Logging.kt$Logging$e: Exception TooGenericExceptionCaught:Logging.kt$Logging$t: Throwable TooGenericExceptionCaught:OneSignalDispatchers.kt$OneSignalDispatchers$e: Exception + TooGenericExceptionCaught:OneSignalImp.kt$OneSignalImp$e: Exception TooGenericExceptionCaught:OperationRepo.kt$OperationRepo$e: Throwable TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$ex: Exception + TooGenericExceptionCaught:SyncJobService.kt$SyncJobService$e: Exception TooGenericExceptionCaught:ThreadUtils.kt$e: Exception TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$t: Throwable @@ -349,7 +359,7 @@ TooGenericExceptionThrown:OperationRepo.kt$OperationRepo$throw Exception("Both comparison keys can not be blank!") TooGenericExceptionThrown:OperationRepo.kt$OperationRepo$throw Exception("Could not find executor for operation ${startingOp.operation.name}") TooGenericExceptionThrown:PermissionsActivity.kt$PermissionsActivity$throw RuntimeException( "Could not find callback class for PermissionActivity: $className", ) - TooGenericExceptionThrown:PermissionsViewModel.kt$PermissionsViewModel$throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") + TooGenericExceptionThrown:PermissionsViewModel.kt$PermissionsViewModel$throw RuntimeException("Missing handler for permissionRequestType: $type") TooGenericExceptionThrown:PreferencesService.kt$PreferencesService$throw Exception("Store not found: $store") TooGenericExceptionThrown:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$throw Exception("Unrecognized operation(s)! Attempted operations:\n$operations") TooGenericExceptionThrown:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$throw Exception("Unrecognized operation: $startingOp") @@ -374,12 +384,13 @@ TooManyFunctions:Model.kt$Model : IEventNotifier TooManyFunctions:ModelStore.kt$ModelStore<TModel> : IEventNotifierIModelStoreIModelChangedHandler TooManyFunctions:OSDatabase.kt$OSDatabase : SQLiteOpenHelperIDatabase + TooManyFunctions:OneSignal.kt$OneSignal$OneSignal TooManyFunctions:OneSignalImp.kt$OneSignalImp : IOneSignalIServiceProvider TooManyFunctions:OperationRepo.kt$OperationRepo : IOperationRepoIStartableService TooManyFunctions:OutcomeEventsController.kt$OutcomeEventsController : IOutcomeEventsControllerIStartableServiceISessionLifecycleHandler TooManyFunctions:PreferencesService.kt$PreferencesService : IPreferencesServiceIStartableService TooManyFunctions:SubscriptionManager.kt$SubscriptionManager : ISubscriptionManagerIModelStoreChangeHandlerISessionLifecycleHandler - TooManyFunctions:UserManager.kt$UserManager : IUserManagerIApplicationLifecycleHandlerISingletonModelStoreChangeHandler + TooManyFunctions:UserManager.kt$UserManager : IUserManagerISingletonModelStoreChangeHandler UndocumentedPublicClass:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$Callback UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils$SchemaType @@ -413,6 +424,7 @@ UndocumentedPublicClass:IParamsBackendService.kt$IParamsBackendService UndocumentedPublicClass:IParamsBackendService.kt$InfluenceParamsObject UndocumentedPublicClass:IParamsBackendService.kt$ParamsObject + UndocumentedPublicClass:IParamsBackendService.kt$RemoteLoggingParamsObject UndocumentedPublicClass:IPreferencesService.kt$PreferenceOneSignalKeys UndocumentedPublicClass:IPreferencesService.kt$PreferencePlayerPurchasesKeys UndocumentedPublicClass:IPreferencesService.kt$PreferenceStores @@ -477,11 +489,6 @@ UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'login'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'logout'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Initialization failed. Cannot proceed.") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'login'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'logout'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("initWithContext was not called or timed out") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("initWithContext was timed out after $MAX_TIMEOUT_TO_INIT ms") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") diff --git a/OneSignalSDK/detekt/detekt-config.yml b/OneSignalSDK/detekt/detekt-config.yml index b6ef41623e..eda87c5be4 100644 --- a/OneSignalSDK/detekt/detekt-config.yml +++ b/OneSignalSDK/detekt/detekt-config.yml @@ -66,7 +66,7 @@ complexity: LongParameterList: active: true - functionThreshold: 6 + functionThreshold: 7 constructorThreshold: 8 ComplexCondition: diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index a581fa085e..bf11425121 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -88,18 +88,8 @@ dependencies { } } - implementation platform("io.opentelemetry:opentelemetry-bom:1.55.0") - - implementation('io.opentelemetry:opentelemetry-api') - implementation('io.opentelemetry:opentelemetry-sdk') - - implementation('io.opentelemetry:opentelemetry-exporter-otlp') // includes okhttp - implementation('io.opentelemetry.semconv:opentelemetry-semconv:1.37.0') - implementation('io.opentelemetry.contrib:opentelemetry-disk-buffering:1.51.0-alpha') - // TODO: Make sure by adding Android OTel that it doesn't auto send stuff to us - - - + // Otel module dependency + implementation(project(':OneSignal:otel')) testImplementation(project(':OneSignal:testhelpers')) testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") diff --git a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml index 69421766c8..a89e519027 100644 --- a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml +++ b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ - + + + + + diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 1c3da1fa48..8897bb13a6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -33,19 +33,7 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time -import com.onesignal.debug.internal.crash.IOneSignalCrashReporter -import com.onesignal.debug.internal.crash.OneSignalCrashHandler -import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider -import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetry -import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash -import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote -import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashConfigProvider -import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashReporterOtel -import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashUploader -import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryCrashLocal -import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryRemote -import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent -import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel +import com.onesignal.debug.internal.crash.OneSignalCrashUploaderWrapper import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -94,19 +82,8 @@ internal class CoreModule : IModule { // Purchase Tracking builder.register().provides() - // Remote Crash and error logging - builder.register().provides() - builder.register().provides() - builder.register().provides() - - builder.register().provides() - builder.register().provides() - - builder.register().provides() - builder.register().provides() - - builder.register().provides() - builder.register().provides() + // Crash Uploader (crash handler is initialized directly in OneSignalImp for early initialization) + builder.register().provides() // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. 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 216810b302..316583eae9 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 @@ -20,6 +20,7 @@ interface IParamsBackendService { ): ParamsObject } +@Suppress("LongParameterList") class ParamsObject( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index ff4423a06c..9b7a9b490c 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 @@ -435,6 +435,9 @@ class FCMConfigModel(parentModel: Model, parentProperty: String) : Model(parentM } } +/** + * Configuration related to OneSignal's remote logging. + */ class RemoteLoggingConfigModel( parentModel: Model, parentProperty: String, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt deleted file mode 100644 index c51391c3d6..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.onesignal.debug.internal.crash - -internal interface IOneSignalCrashReporter { - suspend fun saveCrash(thread: Thread, throwable: Throwable) -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt deleted file mode 100644 index c2555dd78b..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.onesignal.debug.internal.crash - -import kotlinx.coroutines.runBlocking - -/** - * Purpose: Writes any crashes involving OneSignal to a file where they can - * later be send to OneSignal to help improve reliability. - * NOTE: For future refactors, code is written assuming this is a singleton - */ -internal class OneSignalCrashHandler( - private val _crashReporter: IOneSignalCrashReporter, -) : Thread.UncaughtExceptionHandler { - private var existingHandler: Thread.UncaughtExceptionHandler? = null - private val seenThrowables: MutableList = mutableListOf() - - init { - existingHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler(this) - } - - 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. - synchronized(seenThrowables) { - if (seenThrowables.contains(throwable)) { - return - } - seenThrowables.add(throwable) - } - - // TODO: Catch anything we may throw and print only to logcat - // TODO: Also send a stop command to OneSignalCrashUploader, - // give a bit of time to finish and then call existingHandler. - // * This way the app doesn't have to open a 2nd time to get the - // crash report and should help prevent duplicated reports. - if (!isOneSignalAtFault(throwable)) { - existingHandler?.uncaughtException(thread, throwable) - return - } - - /** - * NOTE: The order and running sequentially is important as: - * The existingHandler.uncaughtException can immediately terminate the - * process, either directly (if this is Android's - * KillApplicationHandler) OR the app's handler / 3rd party SDK (either - * directly or more likely, by it calling Android's - * KillApplicationHandler). - * Given this, we can't parallelize the existingHandler work with ours. - * The safest thing is to try to finish our work as fast as possible - * (including ensuring our logging write buffers are flushed) then call - * the existingHandler so any crash handlers the app also has gets the - * crash even too. - * - * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for - * Process.killProcess, which KillApplicationHandler calls. - */ - runBlocking { _crashReporter.saveCrash(thread, throwable) } - existingHandler?.uncaughtException(thread, throwable) - } -} - -internal fun isOneSignalAtFault(throwable: Throwable): Boolean = - throwable.stackTrace.any { it.className.startsWith("com.onesignal") } 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 new file mode 100644 index 0000000000..ea208c27cb --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt @@ -0,0 +1,59 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.time.ITime +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.OtelFactory +import com.onesignal.user.internal.identity.IdentityModelStore + +/** + * 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. + */ +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. + * All dependencies must be pre-populated. + */ + fun createCrashHandler( + applicationService: IApplicationService, + installIdService: IInstallIdService, + configModelStore: ConfigModelStore, + identityModelStore: IdentityModelStore, + time: ITime, + ): IOtelCrashHandler { + // Otel requires SDK 26+, use no-op for older versions + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + com.onesignal.debug.internal.logging.Logging.info("OneSignal: Creating no-op crash handler (SDK ${Build.VERSION.SDK_INT} < 26)") + return NoOpCrashHandler() + } + + com.onesignal.debug.internal.logging.Logging.info("OneSignal: Creating Otel crash handler (SDK ${Build.VERSION.SDK_INT} >= 26)") + val platformProvider = AndroidOtelPlatformProvider( + applicationService, + installIdService, + configModelStore, + identityModelStore, + time + ) + val logger = AndroidOtelLogger() + + return OtelFactory.createCrashHandler(platformProvider, logger) + } +} + +/** + * No-op crash handler for SDK < 26. + */ +private class NoOpCrashHandler : IOtelCrashHandler { + override fun initialize() { + com.onesignal.debug.internal.logging.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 new file mode 100644 index 0000000000..f505704764 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -0,0 +1,63 @@ +package com.onesignal.debug.internal.crash + +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.core.internal.time.ITime +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider +import com.onesignal.otel.OtelFactory +import com.onesignal.otel.crash.OtelCrashUploader +import com.onesignal.user.internal.identity.IdentityModelStore +import kotlinx.coroutines.runBlocking + +/** + * Android-specific wrapper for OtelCrashUploader that implements IStartableService. + * + * This is a thin adapter layer that: + * 1. Takes Android-specific services as dependencies + * 2. Creates platform-agnostic implementations (IOtelPlatformProvider, IOtelLogger) + * 3. Wraps the platform-agnostic OtelCrashUploader for Android service architecture + * + * The OtelCrashUploader itself is fully platform-agnostic and can be used directly + * in KMP projects by providing platform-specific implementations of: + * - IOtelPlatformProvider (inject all platform values) + * - IOtelLogger (platform logging interface) + * + * Example KMP usage: + * ```kotlin + * val platformProvider = MyPlatformProvider(...) // iOS/Android specific + * val logger = MyPlatformLogger() // iOS/Android specific + * val uploader = OtelFactory.createCrashUploader(platformProvider, logger) + * // Use uploader.start() in a coroutine + * ``` + */ +internal class OneSignalCrashUploaderWrapper( + private val _applicationService: IApplicationService, + private val _installIdService: IInstallIdService, + private val _configModelStore: ConfigModelStore, + private val _identityModelStore: IdentityModelStore, + private val _time: ITime, +) : IStartableService { + private val uploader: OtelCrashUploader by lazy { + // Create Android-specific platform provider (injects Android values) + val platformProvider = AndroidOtelPlatformProvider( + _applicationService, + _installIdService, + _configModelStore, + _identityModelStore, + _time + ) + // Create Android-specific logger (delegates to Android Logging) + val logger = AndroidOtelLogger() + // Create platform-agnostic uploader using factory + OtelFactory.createCrashUploader(platformProvider, logger) + } + + override fun start() { + runBlocking { + uploader.start() + } + } +} 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 a4db03407a..141f0a427f 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,12 @@ 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.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.OtelLoggingHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import java.io.PrintWriter import java.io.StringWriter import java.util.concurrent.CopyOnWriteArraySet @@ -17,6 +23,38 @@ object Logging { private val logListeners = CopyOnWriteArraySet() + /** + * Optional Otel remote telemetry for logging SDK events. + * Set this when remote logging is enabled. + */ + @Volatile + private var otelRemoteTelemetry: IOtelOpenTelemetryRemote? = null + + /** + * Function to check if remote logging is enabled. + * Set this to dynamically check remote logging configuration. + */ + @Volatile + private var isRemoteLoggingEnabled: () -> Boolean = { false } + + /** + * Sets the Otel remote telemetry instance and remote logging check function. + * This should be called when remote logging is enabled. + * + * @param telemetry The Otel remote telemetry instance + * @param isEnabled Function that returns true if remote logging is currently enabled + */ + fun setOtelTelemetry( + telemetry: IOtelOpenTelemetryRemote?, + isEnabled: () -> Boolean = { false }, + ) { + otelRemoteTelemetry = telemetry + isRemoteLoggingEnabled = isEnabled + } + + // Coroutine scope for async Otel logging (non-blocking) + private val otelLoggingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @JvmStatic var logLevel = LogLevel.WARN @@ -93,6 +131,7 @@ object Logging { logToLogcat(level, fullMessage, throwable) showVisualLogging(level, fullMessage, throwable) callLogListeners(level, fullMessage, throwable) + logToOtel(level, fullMessage, throwable) } private fun logToLogcat( @@ -160,6 +199,40 @@ object Logging { } } + /** + * Logs to Otel remote telemetry if enabled. + * This is non-blocking and runs asynchronously. + */ + private fun logToOtel( + level: LogLevel, + message: String, + throwable: Throwable?, + ) { + val telemetry = otelRemoteTelemetry ?: return + if (!isRemoteLoggingEnabled()) return + + // Skip NONE level + if (level == LogLevel.NONE) return + + // Log asynchronously (non-blocking) + otelLoggingScope.launch { + try { + OtelLoggingHelper.logToOtel( + telemetry = telemetry, + level = level.name, + message = message, + exceptionType = throwable?.javaClass?.name, + exceptionMessage = throwable?.message, + exceptionStacktrace = throwable?.stackTraceToString(), + ) + } catch (e: Exception) { + // 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) + } + } + } + fun addListener(listener: ILogListener) { logListeners.add(listener) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt deleted file mode 100644 index 2dd5590301..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.onesignal.debug.internal.logging.otel - -import io.opentelemetry.api.logs.LogRecordBuilder -import io.opentelemetry.sdk.common.CompletableResultCode -import io.opentelemetry.sdk.logs.export.LogRecordExporter - -internal interface IOneSignalOpenTelemetry { - suspend fun getLogger(): LogRecordBuilder - - suspend fun forceFlush(): CompletableResultCode -} - -internal interface IOneSignalOpenTelemetryCrash : IOneSignalOpenTelemetry - -internal interface IOneSignalOpenTelemetryRemote : IOneSignalOpenTelemetry { - val logExporter: LogRecordExporter -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt new file mode 100644 index 0000000000..0452a8dca3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt @@ -0,0 +1,26 @@ +package com.onesignal.debug.internal.logging.otel.android + +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelLogger + +/** + * Android-specific implementation of IOtelLogger. + * Delegates to the existing Logging object. + */ +internal class AndroidOtelLogger : IOtelLogger { + override fun error(message: String) { + Logging.error(message) + } + + override fun warn(message: String) { + Logging.warn(message) + } + + override fun info(message: String) { + Logging.info(message) + } + + override fun debug(message: String) { + Logging.debug(message) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt new file mode 100644 index 0000000000..6b0e936d0a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt @@ -0,0 +1,133 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.os.Build +import com.onesignal.common.AndroidUtils +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.time.ITime +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelPlatformProvider +import com.onesignal.user.internal.identity.IdentityModelStore +import java.io.File + +/** + * Android-specific implementation of IOtelPlatformProvider. + * This injects all Android-specific values into the platform-agnostic otel module. + */ +internal class AndroidOtelPlatformProvider( + private val _applicationService: IApplicationService, + private val _installIdService: IInstallIdService, + private val _configModelStore: ConfigModelStore, + private val _identityModelStore: IdentityModelStore, + private val _time: ITime, +) : IOtelPlatformProvider { + // Top-level attributes (static, calculated once) + override suspend fun getInstallId(): String = + _installIdService.getId().toString() + + override val sdkBase: String = "android" + + override val sdkBaseVersion: String = OneSignalUtils.sdkVersion + + override val appPackageId: String + get() = _applicationService.appContext.packageName + + override val appVersion: String + get() = AndroidUtils.getAppVersion(_applicationService.appContext) ?: "unknown" + + override val deviceManufacturer: String = Build.MANUFACTURER + + override val deviceModel: String = Build.MODEL + + override val osName: String = "Android" + + override val osVersion: String = Build.VERSION.RELEASE + + override val osBuildId: String = Build.ID + + override val sdkWrapper: String? = OneSignalWrapper.sdkType + + override val sdkWrapperVersion: String? = OneSignalWrapper.sdkVersion + + // Per-event attributes (dynamic, calculated per event) + override val appId: String? + get() = try { + _configModelStore.model.appId + } catch (_: NullPointerException) { + Logging.warn("app_id not available to add to crash log") + null + } + + override val onesignalId: String? + get() = try { + val onesignalId = _identityModelStore.model.onesignalId + if (com.onesignal.common.IDManager.isLocalId(onesignalId)) { + null + } else { + onesignalId + } + } catch (_: NullPointerException) { + Logging.warn("onesignalId not available to add to crash log") + null + } + + override val pushSubscriptionId: String? + get() = try { + val pushSubscriptionId = _configModelStore.model.pushSubscriptionId + if (pushSubscriptionId == null || + com.onesignal.common.IDManager.isLocalId(pushSubscriptionId) + ) { + null + } else { + pushSubscriptionId + } + } catch (_: NullPointerException) { + Logging.warn("subscriptionId not available to add to crash log") + null + } + + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ + override val appState: String + get() = if (_applicationService.isInForeground) "foreground" else "background" + + // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime + override val processUptime: Double + get() = _time.processUptimeMillis / 1_000.0 + + // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes + override val currentThreadName: String + get() = Thread.currentThread().name + + // Crash-specific configuration + override val crashStoragePath: String + get() { + val path = _applicationService.appContext.cacheDir.path + File.separator + + "onesignal" + File.separator + + "otel" + File.separator + + "crashes" + // Log the path on first access so developers know where to find crash logs + Logging.info("OneSignal: Crash logs stored at: $path") + return path + } + + override val minFileAgeForReadMillis: Long = 5_000 + + // Remote logging configuration + override val remoteLoggingEnabled: Boolean + get() = try { + _configModelStore.model.remoteLoggingParams.enable ?: true + } catch (_: NullPointerException) { + false + } + + override val appIdForHeaders: String + get() = try { + _configModelStore.model.appId + } catch (_: NullPointerException) { + Logging.error("Auth missing for crash log reporting!") + "" + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt deleted file mode 100644 index ede397474e..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.onesignal.debug.internal.logging.otel.attributes - -import com.onesignal.common.IDManager -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.time.ITime -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.user.internal.identity.IdentityModelStore -import com.squareup.wire.internal.toUnmodifiableMap -import java.util.UUID - -internal class OneSignalOtelFieldsPerEvent( - private val _applicationService: IApplicationService, - private val _configModelStore: ConfigModelStore, - private val _identityModelStore: IdentityModelStore, - private val _time: ITime, -) { - fun getAttributes(): Map { - val attributes: MutableMap = mutableMapOf() - - attributes["log.record.uid"] = recordId.toString() - - attributes - .putIfValueNotNull( - "$OS_OTEL_NAMESPACE.app_id", - appId - ).putIfValueNotNull( - "$OS_OTEL_NAMESPACE.onesignal_id", - onesignalId - ).putIfValueNotNull( - "$OS_OTEL_NAMESPACE.push_subscription_id", - subscriptionId - ) - - attributes["android.app.state"] = appState - attributes["process.uptime"] = processUptime.toString() - attributes["thread.name"] = currentThreadName - - return attributes.toUnmodifiableMap() - } - - private val appId: String? get() { - try { - return _configModelStore.model.appId - } catch (_: NullPointerException) { - Logging.warn("app_id not available to add to crash log") - return null - } - } - - private val onesignalId: String? get() { - try { - val onesignalId = _identityModelStore.model.onesignalId - if (IDManager.isLocalId(onesignalId)) { - return null - } - return onesignalId - } catch (_: NullPointerException) { - Logging.warn("onesignalId not available to add to crash log") - return null - } - } - - private val subscriptionId: String? get() { - try { - val pushSubscriptionId = _configModelStore.model.pushSubscriptionId - if (pushSubscriptionId == null || - IDManager.isLocalId(pushSubscriptionId)) { - return null - } - return pushSubscriptionId - } catch (_: NullPointerException) { - Logging.warn("subscriptionId not available to add to crash log") - return null - } - } - - // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ - private val appState: String get() = - if (_applicationService.isInForeground) "foreground" else "background" - - // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime - private val processUptime: Double get() = - _time.processUptimeMillis / 1_000.toDouble() - - // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes - private val currentThreadName: String get() = - Thread.currentThread().name - - // idempotency so the backend can filter on duplicate events - // https://opentelemetry.io/docs/specs/semconv/general/logs/#general-log-identification-attributes - private val recordId: UUID get() = - UUID.randomUUID() -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt deleted file mode 100644 index 0ec1827df1..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.onesignal.debug.internal.logging.otel.attributes - -import android.os.Build -import com.onesignal.common.AndroidUtils -import com.onesignal.common.OneSignalUtils -import com.onesignal.common.OneSignalWrapper -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.device.IInstallIdService -import com.squareup.wire.internal.toUnmodifiableMap - -// Used on all attributes / fields we add to Otel events that is NOT part of -// their spec. We do this to make it clear where the source of this field is. -internal const val OS_OTEL_NAMESPACE: String = "ossdk" - -/** - * Purpose: Fields to be included in every Otel request that goes out. - * Requirements: Only include fields that can NOT change during runtime, - * as these are only fetched once. (Calculated fields are ok) - */ -internal class OneSignalOtelFieldsTopLevel( - private val _applicationService: IApplicationService, - private val _installIdService: IInstallIdService, -) { - suspend fun getAttributes(): Map { - val attributes: MutableMap = - mutableMapOf( - "$OS_OTEL_NAMESPACE.install_id" to - _installIdService.getId().toString(), - "$OS_OTEL_NAMESPACE.sdk_base" - to "android", - "$OS_OTEL_NAMESPACE.sdk_base_version" to - OneSignalUtils.sdkVersion, - "$OS_OTEL_NAMESPACE.app_package_id" to - _applicationService.appContext.packageName, - "$OS_OTEL_NAMESPACE.app_version" to - (AndroidUtils.getAppVersion(_applicationService.appContext) ?: "unknown"), - "device.manufacturer" - to Build.MANUFACTURER, - "device.model.identifier" - to Build.MODEL, - "os.name" - to "Android", - "os.version" - to Build.VERSION.RELEASE, - "os.build_id" - to Build.ID, - ) - - attributes - .putIfValueNotNull( - "$OS_OTEL_NAMESPACE.sdk_wrapper", - OneSignalWrapper.sdkType - ).putIfValueNotNull( - "$OS_OTEL_NAMESPACE.sdk_wrapper_version", - OneSignalWrapper.sdkVersion - ) - - return attributes.toUnmodifiableMap() - } -} - -internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { - if (value != null) { - this[key] = value - } - return this -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt deleted file mode 100644 index 37b9741a7c..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.onesignal.debug.internal.logging.otel.crash - -internal interface IOneSignalCrashConfigProvider { - val path: String - - val minFileAgeForReadMillis: Long -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt deleted file mode 100644 index 4e40dccaf1..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.onesignal.debug.internal.logging.otel.crash - -import com.onesignal.core.internal.application.IApplicationService -import java.io.File - -internal class OneSignalCrashConfigProvider( - private val _applicationService: IApplicationService -) : IOneSignalCrashConfigProvider { - override val path: String by lazy { - _applicationService.appContext.cacheDir.path + File.separator + - "onesignal" + File.separator + - "otel" + File.separator + - "crashes" - } - - override val minFileAgeForReadMillis: Long = 5_000 -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt deleted file mode 100644 index e711978ca6..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.onesignal.debug.internal.logging.otel.crash - -import com.onesignal.debug.internal.crash.IOneSignalCrashReporter -import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash -import com.onesignal.debug.internal.logging.otel.attributes.OS_OTEL_NAMESPACE -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.logs.Severity - -internal class OneSignalCrashReporterOtel( - val _openTelemetry: IOneSignalOpenTelemetryCrash -) : IOneSignalCrashReporter { - companion object { - private const val OTEL_EXCEPTION_TYPE = "exception.type" - private const val OTEL_EXCEPTION_MESSAGE = "exception.message" - private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" - } - - override suspend fun saveCrash(thread: Thread, throwable: Throwable) { - val attributesBuilder = - Attributes - .builder() - .put(OTEL_EXCEPTION_MESSAGE, throwable.message) - .put(OTEL_EXCEPTION_STACKTRACE, throwable.stackTraceToString()) - .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name) - // This matches the top level thread.name today, but it may not - // always if things are refactored to use a different thread. - .put("$OS_OTEL_NAMESPACE.exception.thread.name", thread.name) - .build() - - _openTelemetry - .getLogger() - .setAllAttributes(attributesBuilder) - .setSeverity(Severity.FATAL) - .emit() - - _openTelemetry.forceFlush() - } -} 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 9da2bfef94..f5063eb4c1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -16,18 +16,23 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceStoreFix import com.onesignal.core.internal.startup.StartupService +import com.onesignal.core.internal.time.ITime import com.onesignal.debug.IDebugManager import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.DebugManager -import com.onesignal.debug.internal.crash.OneSignalCrashHandler +import com.onesignal.debug.internal.crash.OneSignalCrashHandlerFactory import com.onesignal.debug.internal.logging.Logging +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.location.ILocationManager import com.onesignal.notifications.INotificationsManager +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.OtelFactory import com.onesignal.session.ISessionManager import com.onesignal.session.SessionModule import com.onesignal.user.IUserManager @@ -222,7 +227,81 @@ internal class OneSignalImp( // 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. - services.getService() + // Initialize crash handler early, before any other services that might crash. + // This is decoupled from getService to ensure fast initialization. + initializeCrashHandlerEarly(applicationService) + + // Initialize Otel logging integration after services are available + initializeOtelLogging(applicationService) + } + + private fun initializeCrashHandlerEarly(applicationService: IApplicationService) { + try { + Logging.info("OneSignal: Initializing crash handler early...") + // Get minimal dependencies needed for crash handler + val installIdService = services.getService() + val configModelStore = services.getService() + val identityModelStore = services.getService() + val time = services.getService() + + Logging.info("OneSignal: Creating crash handler...") + val crashHandler = OneSignalCrashHandlerFactory.createCrashHandler( + applicationService, + installIdService, + configModelStore, + identityModelStore, + time + ) + Logging.info("OneSignal: Crash handler created, initializing...") + crashHandler.initialize() + + // Log crash storage location for debugging + val crashPath = (applicationService.appContext.cacheDir.path + + java.io.File.separator + "onesignal" + + java.io.File.separator + "otel" + + java.io.File.separator + "crashes") + Logging.info("OneSignal: ✅ Crash handler initialized successfully and ready to capture crashes") + Logging.info("OneSignal: 📁 Crash logs will be stored at: $crashPath") + Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as com.onesignal.sdktest ls -la $crashPath") + } 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) + } + } + + private fun initializeOtelLogging(applicationService: IApplicationService) { + try { + // Get dependencies needed for Otel logging + val installIdService = services.getService() + val configModelStore = services.getService() + val identityModelStore = services.getService() + val time = services.getService() + + val platformProvider = AndroidOtelPlatformProvider( + applicationService, + installIdService, + configModelStore, + identityModelStore, + time + ) + + // Check if remote logging is enabled + val isRemoteLoggingEnabled = { platformProvider.remoteLoggingEnabled } + + if (isRemoteLoggingEnabled()) { + Logging.info("OneSignal: Remote logging enabled, initializing Otel logging integration...") + val remoteTelemetry: IOtelOpenTelemetryRemote = OtelFactory.createRemoteTelemetry(platformProvider) + + // Inject Otel telemetry into Logging class + Logging.setOtelTelemetry(remoteTelemetry, isRemoteLoggingEnabled) + Logging.info("OneSignal: ✅ Otel logging integration initialized - logs will be sent to remote server") + } else { + Logging.debug("OneSignal: Remote logging disabled, 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) + } } private fun updateConfig() { diff --git a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..8768713b9a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt index ca6ce9b308..6ca33e840a 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt @@ -99,9 +99,9 @@ class LoggingTests : FunSpec({ listener = ILogListener { calls += it.entry - Logging.removeListener(listener!!) + listener?.let { listener -> Logging.removeListener(listener) } } - Logging.addListener(listener!!) + Logging.addListener(listener) // When Logging.debug("test") 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 new file mode 100644 index 0000000000..92e88fc524 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt @@ -0,0 +1,68 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.time.ITime +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.user.internal.identity.IdentityModelStore +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import org.robolectric.annotation.Config + +class OneSignalCrashHandlerFactoryTest : FunSpec({ + val mockApplicationService = mockk(relaxed = true) + val mockInstallIdService = mockk(relaxed = true) + val mockConfigModelStore = mockk(relaxed = true) + val mockIdentityModelStore = mockk(relaxed = true) + val mockTime = mockk(relaxed = true) + + beforeEach { + every { mockTime.processUptimeMillis } returns 100000L + } + + test("createCrashHandler should return IOtelCrashHandler") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + handler.shouldBeInstanceOf() + } + + @Config(sdk = [Build.VERSION_CODES.O]) + test("createCrashHandler should create Otel handler for SDK 26+") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + handler shouldNotBe null + // Should be able to initialize + handler.initialize() + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + test("createCrashHandler should return no-op handler for SDK < 26") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + handler shouldNotBe null + handler.initialize() // Should not crash + } +}) 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 new file mode 100644 index 0000000000..165d7ca821 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt @@ -0,0 +1,173 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.time.ITime +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelPlatformProvider +import com.onesignal.otel.OtelFactory +import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.IdentityModelStore +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.robolectric.annotation.Config +import java.util.UUID +import android.content.Context as AndroidContext + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelIntegrationTest : FunSpec({ + val mockContext = mockk(relaxed = true) + val mockApplicationService = mockk(relaxed = true) + val mockInstallIdService = mockk(relaxed = true) + val mockConfigModelStore = mockk(relaxed = true) + val mockIdentityModelStore = mockk(relaxed = true) + val mockTime = mockk(relaxed = true) + val mockConfigModel = mockk(relaxed = true) + val mockIdentityModel = mockk(relaxed = true) + + beforeEach { + every { mockContext.packageName } returns "com.test.app" + every { mockContext.cacheDir } returns mockk(relaxed = true) { + every { path } returns "/test/cache" + } + every { mockApplicationService.appContext } returns mockContext + every { mockApplicationService.isInForeground } returns true + coEvery { mockInstallIdService.getId() } returns UUID.randomUUID() + every { mockConfigModelStore.model } returns mockConfigModel + every { mockIdentityModelStore.model } returns mockIdentityModel + every { mockTime.processUptimeMillis } returns 100000L + every { mockConfigModel.appId } returns "test-app-id" + every { mockConfigModel.remoteLoggingParams } returns mockk(relaxed = true) { + every { enable } returns true + } + every { mockIdentityModel.onesignalId } returns "test-onesignal-id" + every { mockConfigModel.pushSubscriptionId } returns "test-subscription-id" + } + + test("AndroidOtelPlatformProvider should provide correct Android values") { + val provider = AndroidOtelPlatformProvider( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + provider.shouldBeInstanceOf() + provider.sdkBase shouldBe "android" + provider.appPackageId shouldBe "com.test.app" + provider.osName shouldBe "Android" + provider.deviceManufacturer shouldBe Build.MANUFACTURER + provider.deviceModel shouldBe Build.MODEL + provider.osVersion shouldBe Build.VERSION.RELEASE + provider.osBuildId shouldBe Build.ID + + runBlocking { + provider.getInstallId() shouldNotBe null + } + } + + test("AndroidOtelPlatformProvider should provide per-event values") { + val provider = AndroidOtelPlatformProvider( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + provider.appId shouldBe "test-app-id" + provider.onesignalId shouldBe "test-onesignal-id" + provider.pushSubscriptionId shouldBe "test-subscription-id" + provider.appState shouldBe "foreground" + provider.processUptime shouldBe 100.0 + provider.currentThreadName shouldBe Thread.currentThread().name + } + + test("AndroidOtelLogger should delegate to Logging") { + val logger = AndroidOtelLogger() + + logger.shouldBeInstanceOf() + // Should not throw + logger.debug("test") + logger.info("test") + logger.warn("test") + logger.error("test") + } + + test("OtelFactory should create crash handler with Android provider") { + val provider = AndroidOtelPlatformProvider( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + val logger = AndroidOtelLogger() + + val handler = OtelFactory.createCrashHandler(provider, logger) + + handler shouldNotBe null + handler.shouldBeInstanceOf() + handler.initialize() // Should not throw + } + + test("OneSignalCrashHandlerFactory should create working crash handler") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + handler shouldNotBe null + handler.shouldBeInstanceOf() + handler.initialize() // Should not throw + } + + test("AndroidOtelPlatformProvider should provide crash storage path") { + val provider = AndroidOtelPlatformProvider( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + provider.crashStoragePath.contains("onesignal") shouldBe true + provider.crashStoragePath.contains("otel") shouldBe true + provider.crashStoragePath.contains("crashes") shouldBe true + provider.minFileAgeForReadMillis shouldBe 5000L + } + + test("AndroidOtelPlatformProvider should handle remote logging config") { + every { mockConfigModel.remoteLoggingParams } returns mockk(relaxed = true) { + every { enable } returns true + } + + val provider = AndroidOtelPlatformProvider( + mockApplicationService, + mockInstallIdService, + mockConfigModelStore, + mockIdentityModelStore, + mockTime + ) + + provider.remoteLoggingEnabled shouldBe true + provider.appIdForHeaders shouldBe "test-app-id" + } +}) 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 new file mode 100644 index 0000000000..5e321fad9e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt @@ -0,0 +1,232 @@ +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.otel.IOtelOpenTelemetryRemote +import io.kotest.core.spec.style.FunSpec +import io.mockk.mockk +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) + + beforeEach { + // Reset Logging state + Logging.setOtelTelemetry(null, { false }) + + // 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 isEnabled = { true } + + // When + Logging.setOtelTelemetry(mockTelemetry, isEnabled) + + // Then - verify it's set (we'll test it works by logging) + Logging.info("test") + + // 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 + } + + test("logToOtel should work when remote logging is enabled") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { true }) + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // 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, { false }) + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash + } + + test("logToOtel should NOT crash when telemetry is null") { + // Given + Logging.setOtelTelemetry(null, { true }) + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash + } + + test("logToOtel should handle all log levels without crashing") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { 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") + + // Wait for async logging + runBlocking { + delay(200) + } + + // Then - should not crash for any level + } + + test("logToOtel should NOT log NONE level") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { true }) + + // When + Logging.log(LogLevel.NONE, "none message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash, NONE level is skipped + } + + test("logToOtel should handle exceptions in logs") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { true }) + val exception = RuntimeException("test exception") + + // When + Logging.error("error with exception", exception) + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash, exception details are included + } + + test("logToOtel should handle null exception message") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { true }) + val exception = RuntimeException() + + // When + Logging.error("error with null exception message", exception) + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash + } + + test("logToOtel should handle Otel errors gracefully") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { 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") + + // Wait for async logging + runBlocking { + delay(100) + } + + // 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, { isEnabled }) + + // When - initially disabled + Logging.info("message 1") + runBlocking { delay(50) } + + // When - enable remote logging + isEnabled = true + Logging.info("message 2") + runBlocking { delay(50) } + + // When - disable again + isEnabled = false + Logging.info("message 3") + runBlocking { delay(50) } + + // Then - should not crash, dynamic check works + } + + test("logToOtel should handle multiple rapid log calls") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { true }) + + // When - rapid fire logging + repeat(10) { + Logging.info("message $it") + } + + // Wait for async logging + runBlocking { + delay(200) + } + + // Then - should not crash + } + + test("logToOtel should work with different message formats") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { true }) + + // When + Logging.info("simple message") + Logging.info("message with numbers: 12345") + Logging.info("message with special chars: !@#$%") + Logging.info("message with unicode: 测试 🚀") + + // Wait for async logging + runBlocking { + delay(200) + } + + // Then - should not crash + } +}) diff --git a/OneSignalSDK/onesignal/otel/.gitignore b/OneSignalSDK/onesignal/otel/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/.gitignore @@ -0,0 +1 @@ +/build diff --git a/OneSignalSDK/onesignal/otel/build.gradle b/OneSignalSDK/onesignal/otel/build.gradle new file mode 100644 index 0000000000..7860f04201 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.diffplug.spotless' + id 'io.gitlab.arturbosch.detekt' +} + +android { + namespace 'com.onesignal.otel' + compileSdkVersion rootProject.buildVersions.compileSdkVersion + + defaultConfig { + minSdkVersion 26 + consumerProguardFiles "consumer-rules.pro" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + original { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + release { + minifyEnabled false + } + unity { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + freeCompilerArgs += ['-module-name', namespace] + } +} + +ext { + projectName = "OneSignal SDK Otel" + projectDescription = "OneSignal Android SDK - OpenTelemetry Module" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" + + implementation platform("io.opentelemetry:opentelemetry-bom:$rootProject.opentelemetryBomVersion") + implementation('io.opentelemetry:opentelemetry-api') + implementation('io.opentelemetry:opentelemetry-sdk') + implementation('io.opentelemetry:opentelemetry-exporter-otlp') + implementation("io.opentelemetry.semconv:opentelemetry-semconv:$rootProject.opentelemetrySemconvVersion") + implementation("io.opentelemetry.contrib:opentelemetry-disk-buffering:$rootProject.opentelemetryDiskBufferingVersion") + + testImplementation(project(':OneSignal:testhelpers')) + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.mockk:mockk:$ioMockVersion") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") +} + +apply from: '../detekt.gradle' +apply from: '../spotless.gradle' diff --git a/OneSignalSDK/onesignal/otel/consumer-rules.pro b/OneSignalSDK/onesignal/otel/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/OneSignalSDK/onesignal/otel/proguard-rules.pro b/OneSignalSDK/onesignal/otel/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/OneSignalSDK/onesignal/otel/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + 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 new file mode 100644 index 0000000000..bd3860eea4 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt @@ -0,0 +1,13 @@ +package com.onesignal.otel + +/** + * Platform-agnostic crash handler interface. + * This should be initialized as early as possible and be independent of service architecture. + */ +interface IOtelCrashHandler { + /** + * Initialize the crash handler. This should be called as early as possible, + * before any other initialization that might crash. + */ + fun initialize() +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt new file mode 100644 index 0000000000..8464ed594a --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt @@ -0,0 +1,8 @@ +package com.onesignal.otel + +/** + * Platform-agnostic crash reporter interface. + */ +internal interface IOtelCrashReporter { + suspend fun saveCrash(thread: Thread, throwable: Throwable) +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt new file mode 100644 index 0000000000..c5154ae775 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt @@ -0,0 +1,12 @@ +package com.onesignal.otel + +/** + * Platform-agnostic logger interface for the Otel module. + * Implementations should be provided by the platform (Android/iOS). + */ +interface IOtelLogger { + fun error(message: String) + fun warn(message: String) + fun info(message: String) + fun debug(message: String) +} 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 new file mode 100644 index 0000000000..c0579b960c --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt @@ -0,0 +1,25 @@ +package com.onesignal.otel + +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.export.LogRecordExporter + +/** + * Platform-agnostic OpenTelemetry interface. + */ +interface IOtelOpenTelemetry { + suspend fun getLogger(): LogRecordBuilder + suspend fun forceFlush(): CompletableResultCode +} + +/** + * Interface for crash-specific OpenTelemetry (local file storage). + */ +interface IOtelOpenTelemetryCrash : IOtelOpenTelemetry + +/** + * Interface for remote OpenTelemetry (network export). + */ +interface IOtelOpenTelemetryRemote : IOtelOpenTelemetry { + val logExporter: LogRecordExporter +} 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 new file mode 100644 index 0000000000..b53196032f --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt @@ -0,0 +1,37 @@ +package com.onesignal.otel + +/** + * Platform-agnostic provider interface for injecting platform-specific values. + * All Android/iOS specific values should be provided through this interface. + */ +interface IOtelPlatformProvider { + // Top-level attributes (static, calculated once) + suspend fun getInstallId(): String + val sdkBase: String + val sdkBaseVersion: String + val appPackageId: String + val appVersion: String + val deviceManufacturer: String + val deviceModel: String + val osName: String + val osVersion: String + val osBuildId: String + val sdkWrapper: String? + val sdkWrapperVersion: String? + + // Per-event attributes (dynamic, calculated per event) + val appId: String? + val onesignalId: String? + val pushSubscriptionId: String? + val appState: String // "foreground" or "background" + val processUptime: Double // in seconds + val currentThreadName: String + + // Crash-specific configuration + val crashStoragePath: String + val minFileAgeForReadMillis: Long + + // Remote logging configuration + val remoteLoggingEnabled: Boolean + val appIdForHeaders: String +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt similarity index 53% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt rename to OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt index bff009784a..7009b30e1d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt @@ -1,17 +1,10 @@ -package com.onesignal.debug.internal.logging.otel - -import android.os.Build -import androidx.annotation.RequiresApi -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_KEY -import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_VALUE -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent -import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel -import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile -import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal -import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared -import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider +package com.onesignal.otel + +import com.onesignal.otel.attributes.OtelFieldsPerEvent +import com.onesignal.otel.attributes.OtelFieldsTopLevel +import com.onesignal.otel.config.OtelConfigCrashFile +import com.onesignal.otel.config.OtelConfigRemoteOneSignal +import com.onesignal.otel.config.OtelConfigShared import io.opentelemetry.api.logs.LogRecordBuilder import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.common.CompletableResultCode @@ -24,15 +17,33 @@ internal fun LogRecordBuilder.setAllAttributes(attributes: Map): return this } +/** + * Extension function to set all attributes from an Attributes object. + * Made public so it can be used from other modules (e.g., core module for logging). + */ +fun LogRecordBuilder.setAllAttributes(attributes: io.opentelemetry.api.common.Attributes): LogRecordBuilder { + attributes.forEach { key, value -> + val keyString = key.key + when (value) { + is String -> this.setAttribute(keyString, value) + is Long -> this.setAttribute(keyString, value) + is Double -> this.setAttribute(keyString, value) + is Boolean -> this.setAttribute(keyString, value) + else -> this.setAttribute(keyString, value.toString()) + } + } + return this +} + internal abstract class OneSignalOpenTelemetryBase( - private val _osTopLevelFields: OneSignalOtelFieldsTopLevel, - private val _osPerEventFields: OneSignalOtelFieldsPerEvent, -) : IOneSignalOpenTelemetry { + private val osTopLevelFields: OtelFieldsTopLevel, + private val osPerEventFields: OtelFieldsPerEvent, +) : IOtelOpenTelemetry { private val lock = Any() private var sdkCachedValue: OpenTelemetrySdk? = null protected suspend fun getSdk(): OpenTelemetrySdk { - val attributes = _osTopLevelFields.getAttributes() + val attributes = osTopLevelFields.getAttributes() synchronized(lock) { var localSdk = sdkCachedValue if (localSdk != null) { @@ -51,40 +62,37 @@ internal abstract class OneSignalOpenTelemetryBase( val sdkLoggerProvider = getSdk().sdkLoggerProvider return suspendCoroutine { it.resume( - sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) + sdkLoggerProvider.forceFlush().join(FORCE_FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS) ) } } + companion object { + private const val FORCE_FLUSH_TIMEOUT_SECONDS = 10L + } + override suspend fun getLogger(): LogRecordBuilder = getSdk() .sdkLoggerProvider .loggerBuilder("loggerBuilder") .build() .logRecordBuilder() - .setAllAttributes(_osPerEventFields.getAttributes()) + .setAllAttributes(osPerEventFields.getAttributes()) } -@RequiresApi(Build.VERSION_CODES.O) internal class OneSignalOpenTelemetryRemote( - private val _configModelStore: ConfigModelStore, - _osTopLevelFields: OneSignalOtelFieldsTopLevel, - _osPerEventFields: OneSignalOtelFieldsPerEvent, -) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), - IOneSignalOpenTelemetryRemote { - private val appId: String get() = - try { - _configModelStore.model.appId - } catch (_: NullPointerException) { - Logging.error("Auth missing for crash log reporting!") - "" - } + private val platformProvider: IOtelPlatformProvider, + osTopLevelFields: OtelFieldsTopLevel, + osPerEventFields: OtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields), + IOtelOpenTelemetryRemote { + + private val appId: String get() = platformProvider.appIdForHeaders val extraHttpHeaders: Map by lazy { mapOf( "X-OneSignal-App-Id" to appId, - HTTP_SDK_VERSION_HEADER_KEY to HTTP_SDK_VERSION_HEADER_VALUE, - "x-honeycomb-team" to "", // TODO: REMOVE + "X-OneSignal-SDK-Version" to platformProvider.sdkBaseVersion, ) } @@ -104,11 +112,11 @@ internal class OneSignalOpenTelemetryRemote( } internal class OneSignalOpenTelemetryCrashLocal( - private val _crashPathProvider: IOneSignalCrashConfigProvider, - _osTopLevelFields: OneSignalOtelFieldsTopLevel, - _osPerEventFields: OneSignalOtelFieldsPerEvent, -) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), - IOneSignalOpenTelemetryCrash { + private val platformProvider: IOtelPlatformProvider, + osTopLevelFields: OtelFieldsTopLevel, + osPerEventFields: OtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields), + IOtelOpenTelemetryCrash { override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = OpenTelemetrySdk .builder() @@ -117,8 +125,8 @@ internal class OneSignalOpenTelemetryCrashLocal( OtelConfigShared.ResourceConfig.create( attributes ), - _crashPathProvider.path, - _crashPathProvider.minFileAgeForReadMillis, + platformProvider.crashStoragePath, + platformProvider.minFileAgeForReadMillis, ) ).build() } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt new file mode 100644 index 0000000000..38c0c138c9 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt @@ -0,0 +1,78 @@ +package com.onesignal.otel + +import com.onesignal.otel.attributes.OtelFieldsPerEvent +import com.onesignal.otel.attributes.OtelFieldsTopLevel +import com.onesignal.otel.crash.OtelCrashHandler +import com.onesignal.otel.crash.OtelCrashReporter +import com.onesignal.otel.crash.OtelCrashUploader + +/** + * Factory class for creating Otel components. + * This allows for fast initialization of the crash handler with all dependencies + * pre-populated. + */ +object OtelFactory { + /** + * Creates a fully configured crash handler that can be initialized immediately. + * All fields are pre-populated for fast initialization. + */ + fun createCrashHandler( + platformProvider: IOtelPlatformProvider, + logger: IOtelLogger, + ): IOtelCrashHandler { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + val crashLocal = OneSignalOpenTelemetryCrashLocal( + platformProvider, + topLevelFields, + perEventFields + ) + val crashReporter = OtelCrashReporter(crashLocal, logger) + return OtelCrashHandler(crashReporter, logger) + } + + /** + * Creates a crash uploader for sending crash reports to the server. + * + * This is platform-agnostic and can be used in KMP projects. + * All platform-specific values must be provided through IOtelPlatformProvider. + * + * @param platformProvider Platform-specific provider that injects all required values + * @param logger Platform-specific logger implementation + * @return Platform-agnostic crash uploader that can be used on Android/iOS + */ + fun createCrashUploader( + platformProvider: IOtelPlatformProvider, + logger: IOtelLogger, + ): OtelCrashUploader { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + val remote = OneSignalOpenTelemetryRemote( + platformProvider, + topLevelFields, + perEventFields + ) + return OtelCrashUploader(remote, platformProvider, logger) + } + + /** + * Creates a remote OpenTelemetry instance for logging SDK events. + * + * This is platform-agnostic and can be used in KMP projects. + * All platform-specific values must be provided through IOtelPlatformProvider. + * + * @param platformProvider Platform-specific provider that injects all required values + * @return Platform-agnostic remote telemetry instance for logging + */ + fun createRemoteTelemetry( + platformProvider: IOtelPlatformProvider, + ): IOtelOpenTelemetryRemote { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + return OneSignalOpenTelemetryRemote( + platformProvider, + topLevelFields, + perEventFields + ) + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt new file mode 100644 index 0000000000..7b83e0b714 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt @@ -0,0 +1,63 @@ +package com.onesignal.otel + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity + +/** + * Helper class for logging to Otel from the Logging class. + * This abstracts away OpenTelemetry-specific types so the core module + * doesn't need direct OpenTelemetry dependencies. + */ +object OtelLoggingHelper { + /** + * Logs a message to Otel remote telemetry. + * This method handles all OpenTelemetry-specific types internally. + * + * @param telemetry The Otel remote telemetry instance + * @param level The log level as a string (VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL) + * @param message The log message + * @param exceptionType Optional exception type + * @param exceptionMessage Optional exception message + * @param exceptionStacktrace Optional exception stacktrace + */ + suspend fun logToOtel( + telemetry: IOtelOpenTelemetryRemote, + level: String, + message: String, + exceptionType: String? = null, + exceptionMessage: String? = null, + exceptionStacktrace: String? = null, + ) { + val severity = when (level.uppercase()) { + "VERBOSE" -> Severity.TRACE + "DEBUG" -> Severity.DEBUG + "INFO" -> Severity.INFO + "WARN" -> Severity.WARN + "ERROR" -> Severity.ERROR + "FATAL" -> Severity.FATAL + else -> Severity.INFO + } + + val attributes = Attributes.builder() + .put("log.message", message) + .put("log.level", level) + .apply { + if (exceptionType != null) { + put("exception.type", exceptionType) + } + if (exceptionMessage != null) { + put("exception.message", exceptionMessage) + } + if (exceptionStacktrace != null) { + put("exception.stacktrace", exceptionStacktrace) + } + } + .build() + + val logRecordBuilder = telemetry.getLogger() + logRecordBuilder.setAllAttributes(attributes) + logRecordBuilder.setSeverity(severity) + logRecordBuilder.setBody(message) + logRecordBuilder.emit() + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt new file mode 100644 index 0000000000..2d2cb20026 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt @@ -0,0 +1,35 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import com.squareup.wire.internal.toUnmodifiableMap +import java.util.UUID + +/** + * Purpose: Fields to be included in each individual Otel event. + * These can change during runtime. + */ +internal class OtelFieldsPerEvent( + private val platformProvider: IOtelPlatformProvider, +) { + fun getAttributes(): Map { + val attributes: MutableMap = mutableMapOf() + + attributes["log.record.uid"] = recordId.toString() + + attributes + .putIfValueNotNull("ossdk.app_id", platformProvider.appId) + .putIfValueNotNull("ossdk.onesignal_id", platformProvider.onesignalId) + .putIfValueNotNull("ossdk.push_subscription_id", platformProvider.pushSubscriptionId) + + // Use platform-agnostic attribute name (works for both Android and iOS) + attributes["app.state"] = platformProvider.appState + attributes["process.uptime"] = platformProvider.processUptime.toString() + attributes["thread.name"] = platformProvider.currentThreadName + + return attributes.toUnmodifiableMap() + } + + // idempotency so the backend can filter on duplicate events + // https://opentelemetry.io/docs/specs/semconv/general/logs/#general-log-identification-attributes + private val recordId: UUID get() = UUID.randomUUID() +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt new file mode 100644 index 0000000000..8021535f67 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt @@ -0,0 +1,42 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import com.squareup.wire.internal.toUnmodifiableMap + +/** + * Purpose: Fields to be included in every Otel request that goes out. + * Requirements: Only include fields that can NOT change during runtime, + * as these are only fetched once. (Calculated fields are ok) + */ +internal class OtelFieldsTopLevel( + private val platformProvider: IOtelPlatformProvider, +) { + suspend fun getAttributes(): Map { + val attributes: MutableMap = + mutableMapOf( + "ossdk.install_id" to platformProvider.getInstallId(), + "ossdk.sdk_base" to platformProvider.sdkBase, + "ossdk.sdk_base_version" to platformProvider.sdkBaseVersion, + "ossdk.app_package_id" to platformProvider.appPackageId, + "ossdk.app_version" to platformProvider.appVersion, + "device.manufacturer" to platformProvider.deviceManufacturer, + "device.model.identifier" to platformProvider.deviceModel, + "os.name" to platformProvider.osName, + "os.version" to platformProvider.osVersion, + "os.build_id" to platformProvider.osBuildId, + ) + + attributes + .putIfValueNotNull("ossdk.sdk_wrapper", platformProvider.sdkWrapper) + .putIfValueNotNull("ossdk.sdk_wrapper_version", platformProvider.sdkWrapperVersion) + + return attributes.toUnmodifiableMap() + } +} + +internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { + if (value != null) { + this[key] = value + } + return this +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt similarity index 73% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt rename to OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt index 0556d75a5b..aa99748589 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt @@ -1,17 +1,20 @@ -package com.onesignal.debug.internal.logging.otel.config +package com.onesignal.otel.config -import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig import io.opentelemetry.contrib.disk.buffering.exporters.LogRecordToDiskExporter import io.opentelemetry.contrib.disk.buffering.storage.impl.FileLogRecordStorage import io.opentelemetry.contrib.disk.buffering.storage.impl.FileStorageConfiguration import io.opentelemetry.sdk.logs.SdkLoggerProvider import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor -import io.opentelemetry.sdk.resources.Resource import java.io.File import kotlin.time.Duration.Companion.hours internal class OtelConfigCrashFile { internal object SdkLoggerProviderConfig { + // NOTE: Only use such as small maxFileAgeForWrite for + // crashes, as we want to send them as soon as possible + // without having to wait too long for buffers. + private const val MAX_FILE_AGE_FOR_WRITE_MILLIS = 2_000L + fun getFileLogRecordStorage( rootDir: String, minFileAgeForReadMillis: Long @@ -20,17 +23,14 @@ internal class OtelConfigCrashFile { File(rootDir), FileStorageConfiguration .builder() - // NOTE: Only use such as small maxFileAgeForWrite for - // crashes, as we want to send them as soon as possible - // without having to wait too long for buffers. - .setMaxFileAgeForWriteMillis(2_000) + .setMaxFileAgeForWriteMillis(MAX_FILE_AGE_FOR_WRITE_MILLIS) .setMinFileAgeForReadMillis(minFileAgeForReadMillis) .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds) .build() ) fun create( - resource: Resource, + resource: io.opentelemetry.sdk.resources.Resource, rootDir: String, minFileAgeForReadMillis: Long, ): SdkLoggerProvider { @@ -43,7 +43,7 @@ internal class OtelConfigCrashFile { .setResource(resource) .addLogRecordProcessor( BatchLogRecordProcessor.builder(logToDiskExporter).build() - ).setLogLimits(LogLimitsConfig::logLimits) + ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) .build() } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt similarity index 65% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt rename to OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt index b6ffb30c53..ff4678296e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt @@ -1,18 +1,14 @@ -package com.onesignal.debug.internal.logging.otel.config +package com.onesignal.otel.config -import android.os.Build -import androidx.annotation.RequiresApi -import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.BASE_URL -import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter import io.opentelemetry.sdk.logs.SdkLoggerProvider import io.opentelemetry.sdk.logs.export.LogRecordExporter -import io.opentelemetry.sdk.resources.Resource import java.time.Duration internal class OtelConfigRemoteOneSignal { object LogRecordExporterConfig { - @RequiresApi(Build.VERSION_CODES.O) + private const val EXPORTER_TIMEOUT_SECONDS = 10L + fun otlpHttpLogRecordExporter( headers: Map, endpoint: String, @@ -21,18 +17,17 @@ internal class OtelConfigRemoteOneSignal { headers.forEach { builder.addHeader(it.key, it.value) } builder .setEndpoint(endpoint) - .setTimeout(Duration.ofSeconds(10)) + .setTimeout(Duration.ofSeconds(EXPORTER_TIMEOUT_SECONDS)) return builder.build() } } object SdkLoggerProviderConfig { - // TODO: Switch to https://sdklogs.onesignal.com:443/sdk/otel + // NOTE: Switch to https://sdklogs.onesignal.com:443/sdk/otel when ready const val BASE_URL = "https://api.honeycomb.io:443" - @RequiresApi(Build.VERSION_CODES.O) fun create( - resource: Resource, + resource: io.opentelemetry.sdk.resources.Resource, extraHttpHeaders: Map, ): SdkLoggerProvider = SdkLoggerProvider @@ -42,16 +37,15 @@ internal class OtelConfigRemoteOneSignal { OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor( HttpRecordBatchExporter.create(extraHttpHeaders) ) - ).setLogLimits(LogLimitsConfig::logLimits) + ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) .build() } object HttpRecordBatchExporter { - @RequiresApi(Build.VERSION_CODES.O) fun create(extraHttpHeaders: Map) = LogRecordExporterConfig.otlpHttpLogRecordExporter( extraHttpHeaders, - "${BASE_URL}/v1/logs" + "${SdkLoggerProviderConfig.BASE_URL}/v1/logs" ) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt similarity index 59% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt rename to OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt index a5b09ef149..f54b3d5590 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt @@ -1,7 +1,5 @@ -package com.onesignal.debug.internal.logging.otel.config +package com.onesignal.otel.config -import android.os.Build -import androidx.annotation.RequiresApi import io.opentelemetry.sdk.logs.LogLimits import io.opentelemetry.sdk.logs.LogRecordProcessor import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor @@ -28,25 +26,33 @@ internal class OtelConfigShared { } object LogRecordProcessorConfig { - @RequiresApi(Build.VERSION_CODES.O) + private const val MAX_QUEUE_SIZE = 100 + private const val MAX_EXPORT_BATCH_SIZE = 100 + private const val EXPORTER_TIMEOUT_SECONDS = 30L + private const val SCHEDULE_DELAY_SECONDS = 1L + fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor = BatchLogRecordProcessor .builder(logRecordExporter) - .setMaxQueueSize(100) - .setMaxExportBatchSize(100) - .setExporterTimeout(Duration.ofSeconds(30)) - .setScheduleDelay(Duration.ofSeconds(1)) + .setMaxQueueSize(MAX_QUEUE_SIZE) + .setMaxExportBatchSize(MAX_EXPORT_BATCH_SIZE) + .setExporterTimeout(Duration.ofSeconds(EXPORTER_TIMEOUT_SECONDS)) + .setScheduleDelay(Duration.ofSeconds(SCHEDULE_DELAY_SECONDS)) .build() } object LogLimitsConfig { + private const val MAX_NUMBER_OF_ATTRIBUTES = 128 + + // We want a high value max length as the exception.stacktrace + // value can be lengthly. + private const val MAX_ATTRIBUTE_VALUE_LENGTH = 32000 + fun logLimits(): LogLimits = LogLimits .builder() - .setMaxNumberOfAttributes(128) - // We want a high value max length as the exception.stacktrace - // value can be lengthly. - .setMaxAttributeValueLength(32000) + .setMaxNumberOfAttributes(MAX_NUMBER_OF_ATTRIBUTES) + .setMaxAttributeValueLength(MAX_ATTRIBUTE_VALUE_LENGTH) .build() } } 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 new file mode 100644 index 0000000000..5b261c13c0 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt @@ -0,0 +1,97 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import kotlinx.coroutines.runBlocking + +/** + * Purpose: Writes any crashes involving OneSignal to a file where they can + * later be send to OneSignal to help improve reliability. + * NOTE: For future refactors, code is written assuming this is a singleton + * + * This should be initialized as early as possible, before any other initialization + * that might crash. All fields must be pre-populated before initialization. + */ +internal class OtelCrashHandler( + private val crashReporter: IOtelCrashReporter, + private val logger: IOtelLogger, +) : Thread.UncaughtExceptionHandler, com.onesignal.otel.IOtelCrashHandler { + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() + private var initialized = false + + override fun initialize() { + if (initialized) { + logger.warn("OtelCrashHandler already initialized, skipping") + return + } + logger.info("OtelCrashHandler: Setting up uncaught exception handler...") + existingHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + initialized = true + logger.info("OtelCrashHandler: ✅ Successfully initialized and registered as default uncaught exception handler") + } + + 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. + synchronized(seenThrowables) { + if (seenThrowables.contains(throwable)) { + logger.warn("OtelCrashHandler: Ignoring duplicate throwable instance") + return + } + seenThrowables.add(throwable) + } + + logger.info("OtelCrashHandler: Uncaught exception detected - ${throwable.javaClass.simpleName}: ${throwable.message}") + + // NOTE: Future improvements: + // - Catch anything we may throw and print only to logcat + // - Send a stop command to OneSignalCrashUploader, give a bit of time to finish + // and then call existingHandler. This way the app doesn't have to open a 2nd + // time to get the crash report and should help prevent duplicated reports. + if (!isOneSignalAtFault(throwable)) { + logger.debug("OtelCrashHandler: Crash is not OneSignal-related, delegating to existing handler") + existingHandler?.uncaughtException(thread, throwable) + return + } + + logger.info("OtelCrashHandler: OneSignal-related crash detected, saving crash report...") + + /** + * NOTE: The order and running sequentially is important as: + * The existingHandler.uncaughtException can immediately terminate the + * process, either directly (if this is Android's + * KillApplicationHandler) OR the app's handler / 3rd party SDK (either + * directly or more likely, by it calling Android's + * KillApplicationHandler). + * Given this, we can't parallelize the existingHandler work with ours. + * The safest thing is to try to finish our work as fast as possible + * (including ensuring our logging write buffers are flushed) then call + * the existingHandler so any crash handlers the app also has gets the + * crash even too. + * + * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for + * Process.killProcess, which KillApplicationHandler calls. + */ + 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}") + } + logger.info("OtelCrashHandler: Delegating to existing crash handler") + existingHandler?.uncaughtException(thread, throwable) + } +} + +internal fun isOneSignalAtFault(throwable: Throwable): Boolean = + throwable.stackTrace.any { it.className.startsWith("com.onesignal") } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt new file mode 100644 index 0000000000..f8e83fa728 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt @@ -0,0 +1,60 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity + +internal class OtelCrashReporter( + private val openTelemetry: IOtelOpenTelemetryCrash, + private val logger: IOtelLogger, +) : com.onesignal.otel.IOtelCrashReporter { + companion object { + private const val OTEL_EXCEPTION_TYPE = "exception.type" + private const val OTEL_EXCEPTION_MESSAGE = "exception.message" + private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" + } + + override suspend fun saveCrash(thread: Thread, throwable: Throwable) { + try { + logger.info("OtelCrashReporter: Starting to save crash report for ${throwable.javaClass.simpleName}") + + val attributes = + Attributes + .builder() + .put(OTEL_EXCEPTION_MESSAGE, throwable.message ?: "") + .put(OTEL_EXCEPTION_STACKTRACE, throwable.stackTraceToString()) + .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name) + // This matches the top level thread.name today, but it may not + // always if things are refactored to use a different thread. + .put("ossdk.exception.thread.name", thread.name) + .build() + + logger.debug("OtelCrashReporter: Creating log record with attributes...") + openTelemetry + .getLogger() + .setAllAttributes(attributes) + .setSeverity(Severity.FATAL) + .emit() + + logger.debug("OtelCrashReporter: Flushing crash report to disk...") + openTelemetry.forceFlush() + + // Note: forceFlush() returns CompletableResultCode which is async + // We wait for it in the implementation, so if we get here, it succeeded + logger.info("OtelCrashReporter: ✅ Crash report saved and flushed successfully to disk") + } catch (e: RuntimeException) { + // If we fail to log the crash, at least try to log the failure + logger.error("OtelCrashReporter: Failed to save crash report: ${e.message} - ${e.javaClass.simpleName}") + throw e // Re-throw so caller knows it failed + } catch (e: java.io.IOException) { + // Handle IO errors specifically + logger.error("OtelCrashReporter: IO error saving crash report: ${e.message}") + throw e + } catch (e: IllegalStateException) { + // Handle illegal state errors + logger.error("OtelCrashReporter: Illegal state error saving crash report: ${e.message}") + throw e + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt similarity index 51% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt rename to OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt index f39d593d93..9e2e9686f7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt @@ -1,24 +1,38 @@ -package com.onesignal.debug.internal.logging.otel.crash +package com.onesignal.otel.crash -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.startup.IStartableService -import com.onesignal.debug.internal.logging.Logging -import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote -import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.IOtelPlatformProvider +import com.onesignal.otel.config.OtelConfigCrashFile import io.opentelemetry.sdk.logs.data.LogRecordData import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import java.util.concurrent.TimeUnit /** * Purpose: This reads a local crash report files created by OneSignal's * crash handler and sends them to OneSignal on the app's next start. + * + * This is fully platform-agnostic and can be used in KMP projects. + * All platform-specific values are injected through IOtelPlatformProvider. + * + * Dependencies (all platform-agnostic): + * - IOtelOpenTelemetryRemote: For network export (created via OtelFactory) + * - IOtelPlatformProvider: Injects all platform values (Android/iOS) + * - IOtelLogger: Platform logging interface (Android/iOS) + * + * Usage: + * ```kotlin + * val uploader = OtelFactory.createCrashUploader(platformProvider, logger) + * coroutineScope.launch { + * uploader.start() + * } + * ``` */ -internal class OneSignalCrashUploader( - private val _openTelemetryRemote: IOneSignalOpenTelemetryRemote, - private val _crashPathProvider: IOneSignalCrashConfigProvider, - _configModelStore: ConfigModelStore, -) : IStartableService { +class OtelCrashUploader( + private val openTelemetryRemote: IOtelOpenTelemetryRemote, + private val platformProvider: IOtelPlatformProvider, + private val logger: IOtelLogger, +) { companion object { const val SEND_TIMEOUT_SECONDS = 30L } @@ -26,19 +40,18 @@ internal class OneSignalCrashUploader( private fun getReports() = OtelConfigCrashFile.SdkLoggerProviderConfig .getFileLogRecordStorage( - _crashPathProvider.path, - _crashPathProvider.minFileAgeForReadMillis + platformProvider.crashStoragePath, + platformProvider.minFileAgeForReadMillis ).iterator() - private val enable = - _configModelStore.model.remoteLoggingParams.enable ?: false - - override fun start() { - Logging.info("OneSignalCrashUploader.enable: $enable") - if (!enable) { + suspend fun start() { + if (!platformProvider.remoteLoggingEnabled) { + logger.info("OtelCrashUploader: remote logging disabled") return } - runBlocking { internalStart() } + + logger.info("OtelCrashUploader: starting") + internalStart() } /** @@ -52,21 +65,21 @@ internal class OneSignalCrashUploader( */ suspend fun internalStart() { sendCrashReports(getReports()) - delay(_crashPathProvider.minFileAgeForReadMillis) + delay(platformProvider.minFileAgeForReadMillis) sendCrashReports(getReports()) } private fun sendCrashReports(reports: Iterator>) { - val networkExporter = _openTelemetryRemote.logExporter + val networkExporter = openTelemetryRemote.logExporter var failed = false // NOTE: next() will delete the previous report, so we only want to send // another one if there isn't an issue making network calls. while (reports.hasNext() && !failed) { val future = networkExporter.export(reports.next()) - Logging.debug("Sending OneSignal crash report") + logger.debug("Sending OneSignal crash report") val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) failed = !result.isSuccess - Logging.debug("Done OneSignal crash report, failed: $failed") + logger.debug("Done OneSignal crash report, failed: $failed") } } } 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 new file mode 100644 index 0000000000..0c0e54c14f --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt @@ -0,0 +1,65 @@ +package com.onesignal.otel + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk + +class OtelFactoryTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + + beforeEach { + // Setup default values + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0" + every { mockPlatformProvider.deviceManufacturer } returns "Test" + every { mockPlatformProvider.deviceModel } returns "TestDevice" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "10" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns null + every { mockPlatformProvider.sdkWrapperVersion } returns null + every { mockPlatformProvider.appId } returns null + every { mockPlatformProvider.onesignalId } returns null + every { mockPlatformProvider.pushSubscriptionId } returns null + every { mockPlatformProvider.appState } returns "foreground" + every { mockPlatformProvider.processUptime } returns 100.0 + every { mockPlatformProvider.currentThreadName } returns "main" + every { mockPlatformProvider.crashStoragePath } returns "/test/path" + every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L + every { mockPlatformProvider.remoteLoggingEnabled } returns true + every { mockPlatformProvider.appIdForHeaders } returns "test-app-id" + coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id" + } + + test("createCrashHandler should return IOtelCrashHandler") { + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + handler.shouldBeInstanceOf() + } + + test("createCrashHandler should create handler with correct dependencies") { + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + handler shouldNotBe null + // Handler should be initializable + handler.initialize() + } + + test("createCrashUploader should return OtelCrashUploader") { + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + + uploader shouldNotBe null + } + + test("createCrashUploader should create uploader with correct dependencies") { + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + + uploader shouldNotBe null + } +}) 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 new file mode 100644 index 0000000000..5d64c5ab2a --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt @@ -0,0 +1,67 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk + +class OtelFieldsPerEventTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + val fields = OtelFieldsPerEvent(mockPlatformProvider) + + test("getAttributes should include all per-event fields") { + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.onesignalId } returns "test-onesignal-id" + every { mockPlatformProvider.pushSubscriptionId } returns "test-subscription-id" + every { mockPlatformProvider.appState } returns "foreground" + every { mockPlatformProvider.processUptime } returns 100.5 + every { mockPlatformProvider.currentThreadName } returns "main-thread" + + val attributes = fields.getAttributes() + + attributes.keys shouldContain "log.record.uid" + attributes["log.record.uid"] shouldNotBe null + attributes["ossdk.app_id"] shouldBe "test-app-id" + attributes["ossdk.onesignal_id"] shouldBe "test-onesignal-id" + attributes["ossdk.push_subscription_id"] shouldBe "test-subscription-id" + attributes["android.app.state"] shouldBe "foreground" + attributes["process.uptime"] shouldBe "100.5" + attributes["thread.name"] shouldBe "main-thread" + } + + test("getAttributes should exclude null optional fields") { + every { mockPlatformProvider.appId } returns null + every { mockPlatformProvider.onesignalId } returns null + every { mockPlatformProvider.pushSubscriptionId } returns null + every { mockPlatformProvider.appState } returns "background" + every { mockPlatformProvider.processUptime } returns 50.0 + every { mockPlatformProvider.currentThreadName } returns "worker-thread" + + val attributes = fields.getAttributes() + + attributes.keys shouldNotContain "ossdk.app_id" + attributes.keys shouldNotContain "ossdk.onesignal_id" + attributes.keys shouldNotContain "ossdk.push_subscription_id" + attributes["android.app.state"] shouldBe "background" + attributes["process.uptime"] shouldBe "50.0" + attributes["thread.name"] shouldBe "worker-thread" + } + + test("getAttributes should generate unique record IDs") { + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.onesignalId } returns null + every { mockPlatformProvider.pushSubscriptionId } returns null + every { mockPlatformProvider.appState } returns "foreground" + every { mockPlatformProvider.processUptime } returns 100.0 + every { mockPlatformProvider.currentThreadName } returns "main" + + val attributes1 = fields.getAttributes() + val attributes2 = fields.getAttributes() + + attributes1["log.record.uid"] shouldNotBe attributes2["log.record.uid"] + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt new file mode 100644 index 0000000000..993bda4598 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt @@ -0,0 +1,69 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking + +class OtelFieldsTopLevelTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + val fields = OtelFieldsTopLevel(mockPlatformProvider) + + test("getAttributes should include all top-level fields") { + coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id" + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0" + every { mockPlatformProvider.deviceManufacturer } returns "TestManufacturer" + every { mockPlatformProvider.deviceModel } returns "TestModel" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "10" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns "unity" + every { mockPlatformProvider.sdkWrapperVersion } returns "2.0.0" + + runBlocking { + val attributes = fields.getAttributes() + + attributes["ossdk.install_id"] shouldBe "test-install-id" + attributes["ossdk.sdk_base"] shouldBe "android" + attributes["ossdk.sdk_base_version"] shouldBe "1.0.0" + attributes["ossdk.app_package_id"] shouldBe "com.test.app" + attributes["ossdk.app_version"] shouldBe "1.0" + attributes["device.manufacturer"] shouldBe "TestManufacturer" + attributes["device.model.identifier"] shouldBe "TestModel" + attributes["os.name"] shouldBe "Android" + attributes["os.version"] shouldBe "10" + attributes["os.build_id"] shouldBe "TEST123" + attributes["ossdk.sdk_wrapper"] shouldBe "unity" + attributes["ossdk.sdk_wrapper_version"] shouldBe "2.0.0" + } + } + + test("getAttributes should exclude null wrapper fields") { + coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id" + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0" + every { mockPlatformProvider.deviceManufacturer } returns "Test" + every { mockPlatformProvider.deviceModel } returns "TestDevice" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "10" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns null + every { mockPlatformProvider.sdkWrapperVersion } returns null + + runBlocking { + val attributes = fields.getAttributes() + + attributes.keys shouldNotContain "ossdk.sdk_wrapper" + attributes.keys shouldNotContain "ossdk.sdk_wrapper_version" + } + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt new file mode 100644 index 0000000000..6a5ea55c26 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt @@ -0,0 +1,124 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify + +class OtelCrashHandlerTest : FunSpec({ + val mockCrashReporter = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + val crashHandler = OtelCrashHandler(mockCrashReporter, mockLogger) + + test("initialize should set up uncaught exception handler") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + + crashHandler.initialize() + + Thread.getDefaultUncaughtExceptionHandler() shouldBe crashHandler + verify { mockLogger.debug("OtelCrashHandler initialized") } + + // Restore original handler + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("initialize should not initialize twice") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + crashHandler.initialize() + + crashHandler.initialize() + + verify(exactly = 1) { mockLogger.debug("OtelCrashHandler initialized") } + verify(exactly = 1) { mockLogger.warn("OtelCrashHandler already initialized, skipping") } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should not process non-OneSignal crashes") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val mockHandler = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(mockHandler) + crashHandler.initialize() + + val throwable = RuntimeException("Non-OneSignal crash") + val thread = Thread.currentThread() + + crashHandler.uncaughtException(thread, throwable) + + coVerify(exactly = 0) { mockCrashReporter.saveCrash(any(), any()) } + verify { mockHandler.uncaughtException(thread, throwable) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should process OneSignal crashes") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val mockHandler = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(mockHandler) + crashHandler.initialize() + + val throwable = RuntimeException("OneSignal crash").apply { + setStackTrace(arrayOf( + StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10) + )) + } + val thread = Thread.currentThread() + + coEvery { mockCrashReporter.saveCrash(any(), any()) } returns Unit + + crashHandler.uncaughtException(thread, throwable) + + coVerify(exactly = 1) { mockCrashReporter.saveCrash(thread, throwable) } + verify { mockHandler.uncaughtException(thread, throwable) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should not process same throwable twice") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + crashHandler.initialize() + + val throwable = RuntimeException("OneSignal crash").apply { + setStackTrace(arrayOf( + StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10) + )) + } + val thread = Thread.currentThread() + + coEvery { mockCrashReporter.saveCrash(any(), any()) } returns Unit + + crashHandler.uncaughtException(thread, throwable) + crashHandler.uncaughtException(thread, throwable) + + coVerify(exactly = 1) { mockCrashReporter.saveCrash(any(), any()) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should handle crash reporter failures gracefully") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val mockHandler = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(mockHandler) + crashHandler.initialize() + + val throwable = RuntimeException("OneSignal crash").apply { + setStackTrace(arrayOf( + StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10) + )) + } + val thread = Thread.currentThread() + + coEvery { mockCrashReporter.saveCrash(any(), any()) } throws RuntimeException("Reporter failed") + + crashHandler.uncaughtException(thread, throwable) + + verify { mockLogger.error("Failed to save crash: Reporter failed") } + verify { mockHandler.uncaughtException(thread, throwable) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt new file mode 100644 index 0000000000..1b76c04bca --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt @@ -0,0 +1,85 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +import io.kotest.core.spec.style.FunSpec +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.api.logs.Severity +import io.opentelemetry.sdk.common.CompletableResultCode +import kotlinx.coroutines.runBlocking + +class OtelCrashReporterTest : FunSpec({ + val mockOpenTelemetry = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + + test("saveCrash should log crash with correct attributes") { + val mockLogRecordBuilder = mockk(relaxed = true) + val mockCompletableResult = mockk(relaxed = true) + + coEvery { mockOpenTelemetry.getLogger() } returns mockLogRecordBuilder + coEvery { mockOpenTelemetry.forceFlush() } returns mockCompletableResult + every { mockLogRecordBuilder.setAttribute(any(), any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.setAttribute(any(), any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.setAttribute(any(), any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.setAttribute(any(), any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.setSeverity(any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.emit() } returns Unit + + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + coVerify(exactly = 1) { mockOpenTelemetry.getLogger() } + coVerify(exactly = 1) { mockOpenTelemetry.forceFlush() } + verify { mockLogRecordBuilder.setAttribute("exception.type", "java.lang.RuntimeException") } + verify { mockLogRecordBuilder.setAttribute("exception.message", "Test crash") } + verify { mockLogRecordBuilder.setAttribute("exception.stacktrace", any()) } + verify { mockLogRecordBuilder.setAttribute("ossdk.exception.thread.name", thread.name) } + verify { mockLogRecordBuilder.setSeverity(Severity.FATAL) } + verify { mockLogRecordBuilder.emit() } + } + + test("saveCrash should handle null exception message") { + val mockLogRecordBuilder = mockk(relaxed = true) + val mockCompletableResult = mockk(relaxed = true) + + coEvery { mockOpenTelemetry.getLogger() } returns mockLogRecordBuilder + coEvery { mockOpenTelemetry.forceFlush() } returns mockCompletableResult + every { mockLogRecordBuilder.setAttribute(any(), any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.setSeverity(any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.emit() } returns Unit + + val throwable = RuntimeException() + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + verify { mockLogRecordBuilder.setAttribute("exception.message", "") } + } + + test("saveCrash should handle failures gracefully") { + val mockLogRecordBuilder = mockk(relaxed = true) + + coEvery { mockOpenTelemetry.getLogger() } throws RuntimeException("OpenTelemetry failed") + + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + verify { mockLogger.error("Failed to save crash report: OpenTelemetry failed") } + } +}) diff --git a/OneSignalSDK/settings.gradle b/OneSignalSDK/settings.gradle index 726512a6e9..0533fc357c 100644 --- a/OneSignalSDK/settings.gradle +++ b/OneSignalSDK/settings.gradle @@ -30,3 +30,4 @@ include ':OneSignal:in-app-messages' include ':OneSignal:location' include ':OneSignal:notifications' include ':OneSignal:testhelpers' +include ':OneSignal:otel' From 24c7c331bf0caadbf76de0d453778a386a4abb7f Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Sun, 28 Dec 2025 15:32:12 -0500 Subject: [PATCH 13/19] WIP: Save current state before merging main --- OneSignalSDK/detekt/detekt-baseline-core.xml | 260 ++++++++++++++----- OneSignalSDK/detekt/detekt-config.yml | 6 +- 2 files changed, 192 insertions(+), 74 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index c2717cee33..20c78da602 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -1,28 +1,23 @@ - + - + ComplexCondition:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$path.startsWith(PropertiesModel::locationTimestamp.name) || path.startsWith(PropertiesModel::locationBackground.name) || path.startsWith(PropertiesModel::locationType.name) || path.startsWith(PropertiesModel::locationAccuracy.name) ComplexCondition:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$args.size == 4 && args[0] == Int::class.javaPrimitiveType && args[1] == String::class.java && args[2] == String::class.java && args[3] == Bundle::class.java && returnType == Bundle::class.java ComplexCondition:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$args.size == 4 && args[0] == Int::class.javaPrimitiveType && args[1] == String::class.java && args[2] == String::class.java && args[3] == String::class.java ComplexMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams() ComplexMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse - ComplexMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse - ComplexMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + ComplexMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse + ComplexMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse ComplexMethod:OSDatabase.kt$OSDatabase$@Synchronized private fun internalOnUpgrade( db: SQLiteDatabase, oldVersion: Int, newVersion: Int, ) ComplexMethod:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? - ComplexMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) - ComplexMethod:PreferencesService.kt$PreferencesService$private fun get( store: String, key: String, type: Class<*>, defValue: Any?, ): Any? + ComplexMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) + ComplexMethod:PreferencesService.kt$PreferencesService$private fun get( store: String, key: String, type: Class<*>, defValue: Any?, ): Any? ComplexMethod:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse - ComplexMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse - ComplexMethod:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse - ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _applicationService: IApplicationService - ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _configModelStore: ConfigModelStore - ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _identityModelStore: IdentityModelStore - ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _installIdService: IInstallIdService - ConstructorParameterNaming:AndroidOtelPlatformProvider.kt$AndroidOtelPlatformProvider$private val _time: ITime + ComplexMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse + ComplexMethod:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _applicationService: IApplicationService - ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _backgroundServices: List<(IBackgroundService)> + ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _backgroundServices: List<(IBackgroundService)> ConstructorParameterNaming:BackgroundManager.kt$BackgroundManager$private val _time: ITime ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _paramsBackendService: IParamsBackendService @@ -66,11 +61,6 @@ ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _time: ITime ConstructorParameterNaming:OSDatabase.kt$OSDatabase$private val _outcomeTableProvider: OutcomeTableProvider - ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _applicationService: IApplicationService - ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _configModelStore: ConfigModelStore - ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _identityModelStore: IdentityModelStore - ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _installIdService: IInstallIdService - ConstructorParameterNaming:OneSignalCrashUploaderWrapper.kt$OneSignalCrashUploaderWrapper$private val _time: ITime ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _newRecordState: NewRecordsState ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _operationModelStore: OperationModelStore @@ -119,7 +109,7 @@ ConstructorParameterNaming:SessionService.kt$SessionService$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:SessionService.kt$SessionService$private val _sessionModelStore: SessionModelStore ConstructorParameterNaming:SessionService.kt$SessionService$private val _time: ITime - ConstructorParameterNaming:SimpleModelStore.kt$SimpleModelStore$/** * Will be called whenever a new [TModel] needs to be instantiated. */ private val _create: () -> TModel + ConstructorParameterNaming:SimpleModelStore.kt$SimpleModelStore$/** * Will be called whenever a new [TModel] needs to be instantiated. */ private val _create: () -> TModel ConstructorParameterNaming:SimpleModelStore.kt$SimpleModelStore$_prefs: IPreferencesService? = null ConstructorParameterNaming:SubscriptionBackendService.kt$SubscriptionBackendService$private val _httpClient: IHttpClient ConstructorParameterNaming:SubscriptionList.kt$SubscriptionList$private val _fallbackPushSub: IPushSubscription @@ -176,38 +166,41 @@ ForbiddenComment:PermissionsViewModel.kt$PermissionsViewModel.Companion$// TODO this will be removed once the handler is deleted ForbiddenComment:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$// TODO: whenever the end-user changes users, we need to add the read-your-write token here, currently no code to handle the re-fetch IAMs ForbiddenComment:TrackGooglePurchase.kt$TrackGooglePurchase$// TODO: Handle very large list. Test for continuationToken != null then call getPurchases again - FunctionOnlyReturningConstant:AndroidUtils.kt$AndroidUtils$@Keep fun opaqueHasClass(_class: Class<*>): Boolean - FunctionParameterNaming:AndroidUtils.kt$AndroidUtils$_class: Class<*> + FunctionOnlyReturningConstant:AndroidUtils.kt$AndroidUtils$@Keep fun opaqueHasClass(_class: Class<*>): Boolean + FunctionParameterNaming:AndroidUtils.kt$AndroidUtils$_class: Class<*> FunctionParameterNaming:JSONUtils.kt$JSONUtils$`object`: Any InstanceOfCheckForException:HttpClient.kt$HttpClient$t is ConnectException InstanceOfCheckForException:HttpClient.kt$HttpClient$t is UnknownHostException LongMethod:ApplicationService.kt$ApplicationService$override suspend fun waitUntilSystemConditionsAvailable(): Boolean LongMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams() LongMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse - LongMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse - LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse - LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse - LongMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) - LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? - LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? - LongMethod:OutcomeEventsRepository.kt$OutcomeEventsRepository$override suspend fun getAllEventsToSend(): List<OutcomeEventParams> - LongMethod:OutcomeEventsRepository.kt$OutcomeEventsRepository$override suspend fun getNotCachedUniqueInfluencesForOutcome( name: String, influences: List<Influence>, ): List<Influence> + LongMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse + LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + LongMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) + LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? + LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? + LongMethod:OutcomeEventsRepository.kt$OutcomeEventsRepository$override suspend fun getAllEventsToSend(): List<OutcomeEventParams> + LongMethod:OutcomeEventsRepository.kt$OutcomeEventsRepository$override suspend fun getNotCachedUniqueInfluencesForOutcome( name: String, influences: List<Influence>, ): List<Influence> LongMethod:OutcomeEventsRepository.kt$OutcomeEventsRepository$override suspend fun saveOutcomeEvent(eventParams: OutcomeEventParams) LongMethod:ParamsBackendService.kt$ParamsBackendService$override suspend fun fetchParams( appId: String, subscriptionId: String?, ): ParamsObject LongMethod:PropertyOperationHelper.kt$PropertyOperationHelper$fun createPropertiesFromOperation( operation: SetPropertyOperation, propertiesObject: PropertiesObject, ): PropertiesObject LongMethod:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse - LongMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse - LongMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun updateSubscription( startingOperation: UpdateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse + LongMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse + LongMethod:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun updateSubscription( startingOperation: UpdateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse 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: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, ) + 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: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, ) + LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, ) LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, ) LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, ) LongParameterList:SubscriptionObject.kt$SubscriptionObject$( val id: String? = null, val type: SubscriptionObjectType? = null, val token: String? = null, val enabled: Boolean? = null, val notificationTypes: Int? = null, val sdk: String? = null, val deviceModel: String? = null, val deviceOS: String? = null, val rooted: Boolean? = null, val netType: Int? = null, val carrier: String? = null, val appVersion: String? = null, ) LongParameterList:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$( private val _subscriptionBackend: ISubscriptionBackendService, private val _deviceService: IDeviceService, private val _applicationService: IApplicationService, private val _subscriptionModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, ) - LongParameterList:UserSwitcher.kt$UserSwitcher$( private val preferencesService: IPreferencesService, private val operationRepo: IOperationRepo, private val services: ServiceProvider, private val idManager: IDManager = IDManager, private val identityModelStore: IdentityModelStore, private val propertiesModelStore: PropertiesModelStore, private val subscriptionModelStore: SubscriptionModelStore, private val configModel: ConfigModel, private val oneSignalUtils: OneSignalUtils = OneSignalUtils, private val carrierName: String? = null, private val deviceOS: String? = null, private val androidUtils: AndroidUtils = AndroidUtils, private val appContextProvider: () -> Context, ) - LoopWithTooManyJumpStatements:ModelStore.kt$ModelStore$for (index in jsonArray.length() - 1 downTo 0) { val newModel = create(jsonArray.getJSONObject(index)) ?: continue /* * NOTE: Migration fix for bug introduced in 5.1.12 * The following check is intended for the operation model store. * When the call to this method moved out of the operation model store's initializer, * duplicate operations could be cached. * See https://github.com/OneSignal/OneSignal-Android-SDK/pull/2099 */ val hasExisting = models.any { it.id == newModel.id } if (hasExisting) { Logging.debug("ModelStore<$name>: load - operation.id: ${newModel.id} already exists in the store.") continue } models.add(0, newModel) // listen for changes to this model newModel.subscribe(this) } + LongParameterList:UserSwitcher.kt$UserSwitcher$( private val preferencesService: IPreferencesService, private val operationRepo: IOperationRepo, private val services: ServiceProvider, private val idManager: IDManager = IDManager, private val identityModelStore: IdentityModelStore, private val propertiesModelStore: PropertiesModelStore, private val subscriptionModelStore: SubscriptionModelStore, private val configModel: ConfigModel, private val oneSignalUtils: OneSignalUtils = OneSignalUtils, private val carrierName: String? = null, private val deviceOS: String? = null, private val androidUtils: AndroidUtils = AndroidUtils, private val appContextProvider: () -> Context, ) + LoopWithTooManyJumpStatements:ModelStore.kt$ModelStore$for (index in jsonArray.length() - 1 downTo 0) { val newModel = create(jsonArray.getJSONObject(index)) ?: continue /* * NOTE: Migration fix for bug introduced in 5.1.12 * The following check is intended for the operation model store. * When the call to this method moved out of the operation model store's initializer, * duplicate operations could be cached. * See https://github.com/OneSignal/OneSignal-Android-SDK/pull/2099 */ val hasExisting = models.any { it.id == newModel.id } if (hasExisting) { Logging.debug("ModelStore<$name>: load - operation.id: ${newModel.id} already exists in the store.") continue } models.add(0, newModel) // listen for changes to this model newModel.subscribe(this) } MagicNumber:ApplicationService.kt$ApplicationService$50 MagicNumber:BackgroundManager.kt$BackgroundManager$5000 MagicNumber:ChannelTracker.kt$ChannelTracker$1000L @@ -260,21 +253,21 @@ MagicNumber:TimeUtils.kt$TimeUtils$1000 MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase$1000000 MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase$3 - MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$99 + MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$99 MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$3 MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$4 MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$99 MagicNumber:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$404 MemberNameEqualsClassName:OneSignal.kt$OneSignal$private val oneSignal: IOneSignal by lazy { OneSignalImp() } - NestedBlockDepth:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse + NestedBlockDepth:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse NestedBlockDepth:InfluenceManager.kt$InfluenceManager$private fun attemptSessionUpgrade( entryAction: AppEntryAction, directId: String? = null, ) NestedBlockDepth:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean - NestedBlockDepth:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + NestedBlockDepth:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse NestedBlockDepth:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$override fun resolve(provider: IServiceProvider): Any? - NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean - NestedBlockDepth:TrackGooglePurchase.kt$TrackGooglePurchase$private fun sendPurchases( skusToAdd: ArrayList<String>, newPurchaseTokens: ArrayList<String>, ) - NestedBlockDepth:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse + NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean + NestedBlockDepth:TrackGooglePurchase.kt$TrackGooglePurchase$private fun sendPurchases( skusToAdd: ArrayList<String>, newPurchaseTokens: ArrayList<String>, ) + NestedBlockDepth:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse PrintStackTrace:AndroidUtils.kt$AndroidUtils$e PrintStackTrace:ApplicationService.kt$ApplicationService$e PrintStackTrace:DeviceUtils.kt$DeviceUtils$t @@ -282,37 +275,36 @@ PrintStackTrace:OSDatabase.kt$OSDatabase$e PrintStackTrace:OutcomeTableProvider.kt$OutcomeTableProvider$e PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase$e - PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t + PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): 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 + ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean - ReturnCount:Logging.kt$Logging$private fun logToOtel( level: LogLevel, message: String, throwable: Throwable?, ) ReturnCount:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private suspend fun loginUser(loginUserOp: LoginUserFromSubscriptionOperation): ExecutionResponse - ReturnCount:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse - ReturnCount:Model.kt$Model$protected fun getOptBigDecimalProperty( name: String, create: (() -> BigDecimal?)? = null, ): BigDecimal? - ReturnCount:Model.kt$Model$protected fun getOptDoubleProperty( name: String, create: (() -> Double?)? = null, ): Double? - ReturnCount:Model.kt$Model$protected fun getOptFloatProperty( name: String, create: (() -> Float?)? = null, ): Float? - ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int? - ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long? - ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T? + ReturnCount:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + ReturnCount:Model.kt$Model$protected fun getOptBigDecimalProperty( name: String, create: (() -> BigDecimal?)? = null, ): BigDecimal? + ReturnCount:Model.kt$Model$protected fun getOptDoubleProperty( name: String, create: (() -> Double?)? = null, ): Double? + ReturnCount:Model.kt$Model$protected fun getOptFloatProperty( name: String, create: (() -> Float?)? = null, ): Float? + ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int? + ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long? + ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T? ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean - ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? - ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? + ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? + ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? ReturnCount:PermissionsViewModel.kt$PermissionsViewModel$private fun shouldShowSettings( permission: String, shouldShowRationaleAfter: Boolean, ): Boolean ReturnCount:PermissionsViewModel.kt$PermissionsViewModel$suspend fun initialize( activity: Activity, permissionType: String?, androidPermission: String?, ): Boolean ReturnCount:PreferenceStoreFix.kt$PreferenceStoreFix$fun ensureNoObfuscatedPrefStore(context: Context) - ReturnCount:PreferencesService.kt$PreferencesService$private fun get( store: String, key: String, type: Class<*>, defValue: Any?, ): Any? + ReturnCount:PreferencesService.kt$PreferencesService$private fun get( store: String, key: String, type: Class<*>, defValue: Any?, ): Any? ReturnCount:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$override fun getUpdateOperation( model: PropertiesModel, path: String, property: String, oldValue: Any?, newValue: Any?, ): Operation? ReturnCount:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse - ReturnCount:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean - ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse - ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun updateSubscription( startingOperation: UpdateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse - ReturnCount:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse + ReturnCount:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean + ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse + ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun updateSubscription( startingOperation: UpdateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse + ReturnCount:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse SpreadOperator:AndroidUtils.kt$AndroidUtils$(*packageInfo.requestedPermissions) SpreadOperator:ServiceRegistration.kt$ServiceRegistrationReflection$(*paramList.toTypedArray()) StringLiteralDuplication:OSDatabase.kt$OSDatabase$"Error closing transaction! " @@ -333,10 +325,8 @@ TooGenericExceptionCaught:HttpClient.kt$HttpClient$e: Throwable TooGenericExceptionCaught:HttpClient.kt$HttpClient$t: Throwable TooGenericExceptionCaught:JSONUtils.kt$JSONUtils$t: Throwable - TooGenericExceptionCaught:Logging.kt$Logging$e: Exception TooGenericExceptionCaught:Logging.kt$Logging$t: Throwable TooGenericExceptionCaught:OneSignalDispatchers.kt$OneSignalDispatchers$e: Exception - TooGenericExceptionCaught:OneSignalImp.kt$OneSignalImp$e: Exception TooGenericExceptionCaught:OperationRepo.kt$OperationRepo$e: Throwable TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable @@ -345,7 +335,7 @@ TooGenericExceptionCaught:ThreadUtils.kt$e: Exception TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$t: Throwable - TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t: Throwable + TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t: Throwable TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$t: Throwable TooGenericExceptionThrown:IdentityOperationExecutor.kt$IdentityOperationExecutor$throw Exception("Can't process SetAliasOperation and DeleteAliasOperation at the same time.") TooGenericExceptionThrown:IdentityOperationExecutor.kt$IdentityOperationExecutor$throw Exception("Unrecognized operation(s)! Attempted operations:\n$operations") @@ -382,7 +372,7 @@ TooManyFunctions:JSONObjectExtensions.kt$com.onesignal.common.JSONObjectExtensions.kt TooManyFunctions:Logging.kt$Logging$Logging TooManyFunctions:Model.kt$Model : IEventNotifier - TooManyFunctions:ModelStore.kt$ModelStore<TModel> : IEventNotifierIModelStoreIModelChangedHandler + TooManyFunctions:ModelStore.kt$ModelStore<TModel> : IEventNotifierIModelStoreIModelChangedHandler TooManyFunctions:OSDatabase.kt$OSDatabase : SQLiteOpenHelperIDatabase TooManyFunctions:OneSignal.kt$OneSignal$OneSignal TooManyFunctions:OneSignalImp.kt$OneSignalImp : IOneSignalIServiceProvider @@ -409,11 +399,9 @@ UndocumentedPublicClass:IIdentityBackendService.kt$IIdentityBackendService UndocumentedPublicClass:IIdentityBackendService.kt$IdentityConstants UndocumentedPublicClass:IInAppMessage.kt$IInAppMessage - UndocumentedPublicClass:IInAppMessageClickListener.kt$IInAppMessageClickListener UndocumentedPublicClass:IInfluenceManager.kt$IInfluenceManager UndocumentedPublicClass:IInstallIdService.kt$IInstallIdService UndocumentedPublicClass:ILanguageContext.kt$ILanguageContext - UndocumentedPublicClass:ILogListener.kt$ILogListener UndocumentedPublicClass:IModelStore.kt$ModelChangeTags UndocumentedPublicClass:INotification.kt$INotification UndocumentedPublicClass:IOneSignal.kt$IOneSignal @@ -424,7 +412,6 @@ UndocumentedPublicClass:IParamsBackendService.kt$IParamsBackendService UndocumentedPublicClass:IParamsBackendService.kt$InfluenceParamsObject UndocumentedPublicClass:IParamsBackendService.kt$ParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$RemoteLoggingParamsObject UndocumentedPublicClass:IPreferencesService.kt$PreferenceOneSignalKeys UndocumentedPublicClass:IPreferencesService.kt$PreferencePlayerPurchasesKeys UndocumentedPublicClass:IPreferencesService.kt$PreferenceStores @@ -441,7 +428,6 @@ UndocumentedPublicClass:InfluenceType.kt$InfluenceType UndocumentedPublicClass:JSONConverter.kt$JSONConverter UndocumentedPublicClass:JSONUtils.kt$JSONUtils - UndocumentedPublicClass:LogLevel.kt$LogLevel UndocumentedPublicClass:Logging.kt$Logging UndocumentedPublicClass:LoginHelper.kt$LoginHelper UndocumentedPublicClass:LogoutHelper.kt$LogoutHelper @@ -451,7 +437,6 @@ UndocumentedPublicClass:OneSignalDbContract.kt$OneSignalDbContract UndocumentedPublicClass:OneSignalDbContract.kt$OneSignalDbContract$InAppMessageTable : BaseColumns UndocumentedPublicClass:OneSignalDbContract.kt$OneSignalDbContract$NotificationTable : BaseColumns - UndocumentedPublicClass:OneSignalLogEvent.kt$OneSignalLogEvent UndocumentedPublicClass:OneSignalUtils.kt$OneSignalUtils UndocumentedPublicClass:OneSignalWrapper.kt$OneSignalWrapper UndocumentedPublicClass:Operation.kt$GroupComparisonType @@ -462,7 +447,6 @@ UndocumentedPublicClass:PropertiesModel.kt$PropertiesModel : Model UndocumentedPublicClass:PropertiesModelStore.kt$PropertiesModelStore : SingletonModelStore UndocumentedPublicClass:PropertiesObject.kt$PropertiesObject - UndocumentedPublicClass:PushSubscriptionChangedState.kt$PushSubscriptionChangedState UndocumentedPublicClass:RebuildUserService.kt$RebuildUserService : IRebuildUserService UndocumentedPublicClass:RootToolsInternalMethods.kt$RootToolsInternalMethods UndocumentedPublicClass:RywData.kt$RywData @@ -476,11 +460,143 @@ UndocumentedPublicClass:SubscriptionObjectType.kt$SubscriptionObjectType UndocumentedPublicClass:SyncJobService.kt$SyncJobService : JobService UndocumentedPublicClass:TimeUtils.kt$TimeUtils - UndocumentedPublicClass:UserChangedState.kt$UserChangedState UndocumentedPublicClass:UserRefreshService.kt$UserRefreshService : IStartableServiceISessionLifecycleHandler UndocumentedPublicClass:UserSwitcher.kt$UserSwitcher UndocumentedPublicClass:ViewUtils.kt$ViewUtils - UnusedPrivateMember:AndroidUtils.kt$AndroidUtils$_class: Class<*> + UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$fun show( activity: Activity, titlePrefix: String, previouslyDeniedPostfix: String, callback: Callback, ) + UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$fun show( activity: Activity, titlePrefix: String, previouslyDeniedPostfix: String, callback: Callback, dismissCallback: (() -> Unit)?, ) + UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings.Callback$fun onAccept() + UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings.Callback$fun onDecline() + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$@Keep fun opaqueHasClass(_class: Class<*>): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getAppVersion(context: Context): String? + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getManifestMeta( context: Context, metaName: String?, ): String? + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getManifestMetaBoolean( context: Context, metaName: String?, ): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getManifestMetaBundle(context: Context): Bundle? + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getRandomDelay( minDelay: Int, maxDelay: Int, ): Int + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getResourceString( context: Context, key: String?, defaultStr: String?, ): String? + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getRootCauseMessage(throwable: Throwable): String? + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getRootCauseThrowable(subjectThrowable: Throwable): Throwable + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun getTargetSdkVersion(context: Context): Int + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun hasConfigChangeFlag( activity: Activity, configChangeFlag: Int, ): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun hasNotificationManagerCompat(): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun isActivityFullyReady(activity: Activity): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun isRunningOnMainThread(): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun isStringNotEmpty(body: String?): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun isValidResourceName(name: String?): Boolean + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun openURLInBrowser( appContext: Context, uri: Uri, ) + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun openURLInBrowser( appContext: Context, url: String, ) + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun openURLInBrowserIntent(uri: Uri): Intent + UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils.SchemaType.Companion$fun fromString(text: String?): SchemaType? + UndocumentedPublicFunction:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution + UndocumentedPublicFunction:ApplicationService.kt$ApplicationService$fun decorViewReady( activity: Activity, runnable: Runnable, ) + UndocumentedPublicFunction:DateUtils.kt$DateUtils$fun iso8601Format(): SimpleDateFormat + UndocumentedPublicFunction:DeviceUtils.kt$DeviceUtils$fun getCarrierName(appContext: Context): String? + UndocumentedPublicFunction:DeviceUtils.kt$DeviceUtils$fun getNetType(appContext: Context): Int? + UndocumentedPublicFunction:IDeviceService.kt$IDeviceService$fun supportsGooglePush(): Boolean + UndocumentedPublicFunction:IMigrationRecovery.kt$IMigrationRecovery$fun isInBadState(): Boolean + UndocumentedPublicFunction:IMigrationRecovery.kt$IMigrationRecovery$fun recover() + UndocumentedPublicFunction:IMigrationRecovery.kt$IMigrationRecovery$fun recoveryMessage(): String + UndocumentedPublicFunction:IOneSignal.kt$IOneSignal$fun login(externalId: String) + UndocumentedPublicFunction:IOperationRepo.kt$IOperationRepo$fun forceExecuteOperations() + UndocumentedPublicFunction:IOperationRepo.kt$IOperationRepo$suspend fun awaitInitialized() + UndocumentedPublicFunction:IOperationRepo.kt$inline fun <reified T : Operation> IOperationRepo.containsInstanceOf(): Boolean + UndocumentedPublicFunction:IRequestPermissionService.kt$IRequestPermissionService.PermissionCallback$fun onAccept() + UndocumentedPublicFunction:IRequestPermissionService.kt$IRequestPermissionService.PermissionCallback$fun onReject(fallbackToSettings: Boolean) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionChangedHandler$fun onSubscriptionAdded(subscription: ISubscription) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionChangedHandler$fun onSubscriptionChanged( subscription: ISubscription, args: ModelChangedArgs, ) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionChangedHandler$fun onSubscriptionRemoved(subscription: ISubscription) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionManager$fun addEmailSubscription(email: String) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionManager$fun addOrUpdatePushSubscriptionToken( pushToken: String?, pushTokenStatus: SubscriptionStatus, ) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionManager$fun addSmsSubscription(sms: String) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionManager$fun removeEmailSubscription(email: String) + UndocumentedPublicFunction:ISubscriptionManager.kt$ISubscriptionManager$fun removeSmsSubscription(sms: String) + UndocumentedPublicFunction:InAppMessageActionUrlType.kt$InAppMessageActionUrlType.Companion$fun fromString(text: String?): InAppMessageActionUrlType? + UndocumentedPublicFunction:Influence.kt$Influence$@Throws(JSONException::class) fun toJSONString() + UndocumentedPublicFunction:Influence.kt$Influence$fun copy() + UndocumentedPublicFunction:InfluenceChannel.kt$InfluenceChannel$fun equalsName(otherName: String) + UndocumentedPublicFunction:InfluenceChannel.kt$InfluenceChannel.Companion$@JvmStatic fun fromString(value: String?) + UndocumentedPublicFunction:InfluenceType.kt$InfluenceType$fun isAttributed() + UndocumentedPublicFunction:InfluenceType.kt$InfluenceType$fun isDirect() + UndocumentedPublicFunction:InfluenceType.kt$InfluenceType$fun isDisabled() + UndocumentedPublicFunction:InfluenceType.kt$InfluenceType$fun isIndirect() + UndocumentedPublicFunction:InfluenceType.kt$InfluenceType$fun isUnattributed() + UndocumentedPublicFunction:InfluenceType.kt$InfluenceType.Companion$@JvmStatic fun fromString(value: String?) + UndocumentedPublicFunction:JSONConverter.kt$JSONConverter$fun convertToCreateUserResponse(jsonObject: JSONObject): CreateUserResponse + UndocumentedPublicFunction:JSONConverter.kt$JSONConverter$fun convertToJSON(properties: PropertiesObject): JSONObject + UndocumentedPublicFunction:JSONConverter.kt$JSONConverter$fun convertToJSON(propertiesDeltas: PropertiesDeltasObject): JSONObject + UndocumentedPublicFunction:JSONConverter.kt$JSONConverter$fun convertToJSON(subscription: SubscriptionObject): JSONObject + UndocumentedPublicFunction:JSONConverter.kt$JSONConverter$fun convertToJSON(subscriptions: List<SubscriptionObject>): JSONArray + UndocumentedPublicFunction:JSONObjectExtensions.kt$fun <T> JSONObject.expandJSONArray( name: String, into: (childObject: JSONObject) -> T?, ): List<T> + UndocumentedPublicFunction:JSONUtils.kt$JSONUtils$fun bundleAsJSONObject(bundle: Bundle): JSONObject + UndocumentedPublicFunction:JSONUtils.kt$JSONUtils$fun jsonStringToBundle(data: String): Bundle? + UndocumentedPublicFunction:JSONUtils.kt$JSONUtils$fun newStringMapFromJSONObject(jsonObject: JSONObject): Map<String, String> + UndocumentedPublicFunction:JSONUtils.kt$JSONUtils$fun newStringSetFromJSONArray(jsonArray: JSONArray): Set<String> + UndocumentedPublicFunction:JSONUtils.kt$JSONUtils$fun normalizeType(`object`: Any): Any? + UndocumentedPublicFunction:LogLevel.kt$LogLevel.Companion$@JvmStatic fun fromInt(value: Int): LogLevel + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun atLogLevel(level: LogLevel): Boolean + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun debug( message: String, throwable: Throwable? = null, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun error( message: String, throwable: Throwable? = null, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun fatal( message: String, throwable: Throwable? = null, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun info( message: String, throwable: Throwable? = null, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun log( level: LogLevel, message: String, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun log( level: LogLevel, message: String, throwable: Throwable?, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun verbose( message: String, throwable: Throwable? = null, ) + UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun warn( message: String, throwable: Throwable? = null, ) + UndocumentedPublicFunction:Logging.kt$Logging$fun addListener(listener: ILogListener) + UndocumentedPublicFunction:Logging.kt$Logging$fun removeListener(listener: ILogListener) + UndocumentedPublicFunction:LoginHelper.kt$LoginHelper$suspend fun login( externalId: String, jwtBearerToken: String? = null, ) + UndocumentedPublicFunction:LogoutHelper.kt$LogoutHelper$fun logout() + UndocumentedPublicFunction:Model.kt$Model$fun <T> setListProperty( name: String, value: List<T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun <T> setMapModelProperty( name: String, value: MapModel<T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun <T> setOptListProperty( name: String, value: List<T>?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun <T> setOptMapModelProperty( name: String, value: MapModel<T>?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setAnyProperty( name: String, value: Any, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setBigDecimalProperty( name: String, value: BigDecimal, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setBooleanProperty( name: String, value: Boolean, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setDoubleProperty( name: String, value: Double, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setFloatProperty( name: String, value: Float, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setIntProperty( name: String, value: Int, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setLongProperty( name: String, value: Long, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptAnyProperty( name: String, value: Any?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptBigDecimalProperty( name: String, value: BigDecimal?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptBooleanProperty( name: String, value: Boolean?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptDoubleProperty( name: String, value: Double?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptFloatProperty( name: String, value: Float?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptIntProperty( name: String, value: Int?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptLongProperty( name: String, value: Long?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setOptStringProperty( name: String, value: String?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$fun setStringProperty( name: String, value: String, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$inline fun <reified T : Enum<T>> setEnumProperty( name: String, value: T, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:Model.kt$Model$inline fun <reified T : Enum<T>> setOptEnumProperty( name: String, value: T?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) + UndocumentedPublicFunction:NetworkUtils.kt$NetworkUtils$fun getResponseStatusType(statusCode: Int): ResponseStatusType + UndocumentedPublicFunction:NewRecordsState.kt$NewRecordsState$fun add(key: String) + UndocumentedPublicFunction:NewRecordsState.kt$NewRecordsState$fun canAccess(key: String): Boolean + UndocumentedPublicFunction:NewRecordsState.kt$NewRecordsState$fun isInMissingRetryWindow(key: String): Boolean + UndocumentedPublicFunction:OneSignalDispatchers.kt$OneSignalDispatchers$fun launchOnDefault(block: suspend () -> Unit): Job + UndocumentedPublicFunction:OneSignalDispatchers.kt$OneSignalDispatchers$fun launchOnIO(block: suspend () -> Unit): Job + UndocumentedPublicFunction:OneSignalUtils.kt$OneSignalUtils$fun isValidEmail(email: String): Boolean + UndocumentedPublicFunction:OneSignalUtils.kt$OneSignalUtils$fun isValidPhoneNumber(number: String): Boolean + UndocumentedPublicFunction:PushSubscriptionChangedState.kt$PushSubscriptionChangedState$fun toJSONObject(): JSONObject + UndocumentedPublicFunction:PushSubscriptionState.kt$PushSubscriptionState$fun toJSONObject(): JSONObject + UndocumentedPublicFunction:RemoveInvalidSessionTimeRecords.kt$RemoveInvalidSessionTimeRecords$fun run(databaseProvider: IDatabaseProvider) + UndocumentedPublicFunction:ServiceRegistration.kt$ServiceRegistration$abstract fun resolve(provider: IServiceProvider): Any? + UndocumentedPublicFunction:ServiceRegistration.kt$ServiceRegistration$inline fun <reified TService : Any> provides(): ServiceRegistration<T> + UndocumentedPublicFunction:SubscriptionModel.kt$SubscriptionStatus.Companion$fun fromInt(value: Int): SubscriptionStatus? + UndocumentedPublicFunction:SubscriptionObjectType.kt$SubscriptionObjectType.Companion$fun fromDeviceType(type: IDeviceService.DeviceType): SubscriptionObjectType + UndocumentedPublicFunction:SubscriptionObjectType.kt$SubscriptionObjectType.Companion$fun fromString(type: String): SubscriptionObjectType? + UndocumentedPublicFunction:TimeUtils.kt$TimeUtils$fun getTimeZoneId(): String + UndocumentedPublicFunction:TimeUtils.kt$TimeUtils$fun getTimeZoneOffset(): Int + UndocumentedPublicFunction:UserChangedState.kt$UserChangedState$fun toJSONObject(): JSONObject + UndocumentedPublicFunction:UserState.kt$UserState$fun toJSONObject(): JSONObject + UndocumentedPublicFunction:UserSwitcher.kt$UserSwitcher$fun createAndSwitchToNewUser( suppressBackendOperation: Boolean = false, modify: ((identityModel: IdentityModel, propertiesModel: PropertiesModel) -> Unit)? = null, ) + UndocumentedPublicFunction:UserSwitcher.kt$UserSwitcher$fun createPushSubscriptionFromLegacySync( legacyPlayerId: String, legacyUserSyncJSON: JSONObject, configModel: ConfigModel, subscriptionModelStore: SubscriptionModelStore, appContext: Context, ): Boolean + UndocumentedPublicFunction:UserSwitcher.kt$UserSwitcher$fun initUser(forceCreateUser: Boolean) + UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun dpToPx(dp: Int): Int + UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun getCutoutAndStatusBarInsets(activity: Activity): IntArray + UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun getFullbleedWindowWidth(activity: Activity): Int + UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun getWindowHeight(activity: Activity): Int + UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun getWindowWidth(activity: Activity): Int + UnusedPrivateMember:AndroidUtils.kt$AndroidUtils$_class: Class<*> UnusedPrivateMember:AndroidUtils.kt$AndroidUtils$var requestPermission: String? = null UnusedPrivateMember:ApplicationService.kt$ApplicationService$val listenerKey = "decorViewReady:$runnable" UnusedPrivateMember:JSONUtils.kt$JSONUtils$`object`: Any diff --git a/OneSignalSDK/detekt/detekt-config.yml b/OneSignalSDK/detekt/detekt-config.yml index eda87c5be4..f36fd3b166 100644 --- a/OneSignalSDK/detekt/detekt-config.yml +++ b/OneSignalSDK/detekt/detekt-config.yml @@ -86,10 +86,12 @@ complexity: comments: UndocumentedPublicClass: active: true - excludes: ['**/R.class', '**/BuildConfig.*'] + excludes: ['**/R.class', '**/BuildConfig.*', '**/test/**', '**/androidTest/**', '**/testhelpers/**'] UndocumentedPublicFunction: - active: false + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/testhelpers/**'] + EndOfSentenceFormat: active: false endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$) From 90be915352755659de1ff9e47a0b8e06b854f750 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Sun, 28 Dec 2025 15:51:42 -0500 Subject: [PATCH 14/19] detekt fixes and gradle cleanup --- OneSignalSDK/build.gradle | 3 -- .../internal/backend/IParamsBackendService.kt | 10 +++--- .../crash/OneSignalCrashUploaderWrapper.kt | 20 ++++++------ .../debug/internal/logging/Logging.kt | 1 + .../android/AndroidOtelPlatformProvider.kt | 32 +++++++++---------- .../com/onesignal/internal/OneSignalImp.kt | 2 ++ .../onesignal/debug/internal/LoggingTests.kt | 12 +++---- .../crash/OneSignalCrashHandlerFactoryTest.kt | 8 ++--- 8 files changed, 44 insertions(+), 44 deletions(-) diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index c3f7a0210b..dd8ac05d51 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -29,9 +29,6 @@ buildscript { opentelemetryBomVersion = '1.55.0' opentelemetrySemconvVersion = '1.37.0' opentelemetryDiskBufferingVersion = '1.51.0-alpha' - ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10) - spotlessVersion = '6.25.0' - tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP sharedRepos = { google() 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 316583eae9..7c74520d97 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 @@ -2,7 +2,7 @@ package com.onesignal.core.internal.backend import org.json.JSONArray -interface IParamsBackendService { +internal interface IParamsBackendService { /** * Retrieve the configuration parameters for the [appId] and optional [subscriptionId]. * @@ -21,7 +21,7 @@ interface IParamsBackendService { } @Suppress("LongParameterList") -class ParamsObject( +internal class ParamsObject( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, @@ -40,7 +40,7 @@ class ParamsObject( val remoteLoggingParams: RemoteLoggingParamsObject, ) -class InfluenceParamsObject( +internal class InfluenceParamsObject( val indirectNotificationAttributionWindow: Int? = null, val notificationLimit: Int? = null, val indirectIAMAttributionWindow: Int? = null, @@ -50,12 +50,12 @@ class InfluenceParamsObject( val isUnattributedEnabled: Boolean? = null, ) -class FCMParamsObject( +internal class FCMParamsObject( val projectId: String? = null, val appId: String? = null, val apiKey: String? = null, ) -class RemoteLoggingParamsObject( +internal class RemoteLoggingParamsObject( val enable: Boolean? = 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 f505704764..8a596c5c13 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 @@ -34,20 +34,20 @@ import kotlinx.coroutines.runBlocking * ``` */ internal class OneSignalCrashUploaderWrapper( - private val _applicationService: IApplicationService, - private val _installIdService: IInstallIdService, - private val _configModelStore: ConfigModelStore, - private val _identityModelStore: IdentityModelStore, - private val _time: ITime, + private val applicationService: IApplicationService, + private val installIdService: IInstallIdService, + private val configModelStore: ConfigModelStore, + private val identityModelStore: IdentityModelStore, + private val time: ITime, ) : IStartableService { private val uploader: OtelCrashUploader by lazy { // Create Android-specific platform provider (injects Android values) val platformProvider = AndroidOtelPlatformProvider( - _applicationService, - _installIdService, - _configModelStore, - _identityModelStore, - _time + applicationService, + installIdService, + configModelStore, + identityModelStore, + time ) // Create Android-specific logger (delegates to Android Logging) val logger = AndroidOtelLogger() 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 141f0a427f..ba561962c3 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 @@ -203,6 +203,7 @@ object Logging { * Logs to Otel remote telemetry if enabled. * This is non-blocking and runs asynchronously. */ + @Suppress("TooGenericExceptionCaught", "ReturnCount") private fun logToOtel( level: LogLevel, message: String, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt index 6b0e936d0a..33dcc92228 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt @@ -18,25 +18,25 @@ import java.io.File * This injects all Android-specific values into the platform-agnostic otel module. */ internal class AndroidOtelPlatformProvider( - private val _applicationService: IApplicationService, - private val _installIdService: IInstallIdService, - private val _configModelStore: ConfigModelStore, - private val _identityModelStore: IdentityModelStore, - private val _time: ITime, + private val applicationService: IApplicationService, + private val installIdService: IInstallIdService, + private val configModelStore: ConfigModelStore, + private val identityModelStore: IdentityModelStore, + private val time: ITime, ) : IOtelPlatformProvider { // Top-level attributes (static, calculated once) override suspend fun getInstallId(): String = - _installIdService.getId().toString() + installIdService.getId().toString() override val sdkBase: String = "android" override val sdkBaseVersion: String = OneSignalUtils.sdkVersion override val appPackageId: String - get() = _applicationService.appContext.packageName + get() = applicationService.appContext.packageName override val appVersion: String - get() = AndroidUtils.getAppVersion(_applicationService.appContext) ?: "unknown" + get() = AndroidUtils.getAppVersion(applicationService.appContext) ?: "unknown" override val deviceManufacturer: String = Build.MANUFACTURER @@ -55,7 +55,7 @@ internal class AndroidOtelPlatformProvider( // Per-event attributes (dynamic, calculated per event) override val appId: String? get() = try { - _configModelStore.model.appId + configModelStore.model.appId } catch (_: NullPointerException) { Logging.warn("app_id not available to add to crash log") null @@ -63,7 +63,7 @@ internal class AndroidOtelPlatformProvider( override val onesignalId: String? get() = try { - val onesignalId = _identityModelStore.model.onesignalId + val onesignalId = identityModelStore.model.onesignalId if (com.onesignal.common.IDManager.isLocalId(onesignalId)) { null } else { @@ -76,7 +76,7 @@ internal class AndroidOtelPlatformProvider( override val pushSubscriptionId: String? get() = try { - val pushSubscriptionId = _configModelStore.model.pushSubscriptionId + val pushSubscriptionId = configModelStore.model.pushSubscriptionId if (pushSubscriptionId == null || com.onesignal.common.IDManager.isLocalId(pushSubscriptionId) ) { @@ -91,11 +91,11 @@ internal class AndroidOtelPlatformProvider( // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ override val appState: String - get() = if (_applicationService.isInForeground) "foreground" else "background" + get() = if (applicationService.isInForeground) "foreground" else "background" // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime override val processUptime: Double - get() = _time.processUptimeMillis / 1_000.0 + get() = time.processUptimeMillis / 1_000.0 // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes override val currentThreadName: String @@ -104,7 +104,7 @@ internal class AndroidOtelPlatformProvider( // Crash-specific configuration override val crashStoragePath: String get() { - val path = _applicationService.appContext.cacheDir.path + File.separator + + val path = applicationService.appContext.cacheDir.path + File.separator + "onesignal" + File.separator + "otel" + File.separator + "crashes" @@ -118,14 +118,14 @@ internal class AndroidOtelPlatformProvider( // Remote logging configuration override val remoteLoggingEnabled: Boolean get() = try { - _configModelStore.model.remoteLoggingParams.enable ?: true + configModelStore.model.remoteLoggingParams.enable ?: true } catch (_: NullPointerException) { false } override val appIdForHeaders: String get() = try { - _configModelStore.model.appId + configModelStore.model.appId } catch (_: NullPointerException) { Logging.error("Auth missing for crash log reporting!") "" 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 f5063eb4c1..290e5feb78 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 @@ -235,6 +235,7 @@ internal class OneSignalImp( initializeOtelLogging(applicationService) } + @Suppress("TooGenericExceptionCaught") private fun initializeCrashHandlerEarly(applicationService: IApplicationService) { try { Logging.info("OneSignal: Initializing crash handler early...") @@ -269,6 +270,7 @@ internal class OneSignalImp( } } + @Suppress("TooGenericExceptionCaught") private fun initializeOtelLogging(applicationService: IApplicationService) { try { // Get dependencies needed for Otel logging diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt index 6ca33e840a..4f9c377348 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt @@ -95,12 +95,12 @@ class LoggingTests : FunSpec({ test("removeListener nested") { // Given val calls = ArrayList() - var listener: ILogListener? = null - listener = - ILogListener { - calls += it.entry - listener?.let { listener -> Logging.removeListener(listener) } - } + lateinit var listener: ILogListener + listener = ILogListener { logEvent -> + calls += logEvent.entry + // Remove self from listeners + Logging.removeListener(listener) + } Logging.addListener(listener) // When 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 92e88fc524..00ac24462a 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 @@ -1,6 +1,5 @@ package com.onesignal.debug.internal.crash -import android.os.Build import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService @@ -12,7 +11,6 @@ import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.every import io.mockk.mockk -import org.robolectric.annotation.Config class OneSignalCrashHandlerFactoryTest : FunSpec({ val mockApplicationService = mockk(relaxed = true) @@ -37,8 +35,9 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ handler.shouldBeInstanceOf() } - @Config(sdk = [Build.VERSION_CODES.O]) test("createCrashHandler should create Otel handler for SDK 26+") { + // Note: SDK version check is handled at runtime by the factory + // This test verifies the handler can be created and initialized val handler = OneSignalCrashHandlerFactory.createCrashHandler( mockApplicationService, mockInstallIdService, @@ -52,8 +51,9 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ handler.initialize() } - @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) test("createCrashHandler should return no-op handler for SDK < 26") { + // Note: SDK version check is handled at runtime by the factory + // This test verifies the handler can be created and initialized val handler = OneSignalCrashHandlerFactory.createCrashHandler( mockApplicationService, mockInstallIdService, From f2eb2c0ed6b3e90fd4a64719e5406851c975431c Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Sun, 28 Dec 2025 16:06:52 -0500 Subject: [PATCH 15/19] incrementing kotling version --- OneSignalSDK/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index dd8ac05d51..c02338492e 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -14,8 +14,8 @@ buildscript { huaweiAgconnectVersion = '1.9.1.304' huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' - kotlinVersion = '1.9.25' - dokkaVersion = '1.9.10' // Dokka version compatible with Kotlin 1.9.25 + kotlinVersion = '2.2.0' + dokkaVersion = '1.9.10' coroutinesVersion = '1.7.3' kotestVersion = '5.8.0' ioMockVersion = '1.13.2' From c9a738035954658f9898a2efa340c07e45c28d04 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Sun, 28 Dec 2025 16:12:19 -0500 Subject: [PATCH 16/19] added documentation --- .../java/com/onesignal/otel/IOtelLogger.kt | 23 +++++++++++++++++++ .../com/onesignal/otel/IOtelOpenTelemetry.kt | 13 +++++++++++ .../onesignal/otel/IOtelPlatformProvider.kt | 6 +++++ .../onesignal/otel/crash/OtelCrashUploader.kt | 5 ++++ 4 files changed, 47 insertions(+) diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt index c5154ae775..510ffab2eb 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt @@ -5,8 +5,31 @@ package com.onesignal.otel * Implementations should be provided by the platform (Android/iOS). */ interface IOtelLogger { + /** + * Logs an error message. + * + * @param message The error message to log + */ fun error(message: String) + + /** + * Logs a warning message. + * + * @param message The warning message to log + */ fun warn(message: String) + + /** + * Logs an informational message. + * + * @param message The info message to log + */ fun info(message: String) + + /** + * Logs a debug message. + * + * @param message The debug message to log + */ fun debug(message: String) } 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 c0579b960c..6a1843d722 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 @@ -8,7 +8,20 @@ import io.opentelemetry.sdk.logs.export.LogRecordExporter * Platform-agnostic OpenTelemetry interface. */ interface IOtelOpenTelemetry { + /** + * Gets a LogRecordBuilder for creating log records. + * This is a suspend function as it may need to initialize the SDK on first call. + * + * @return A LogRecordBuilder instance for building log records + */ suspend fun getLogger(): LogRecordBuilder + + /** + * Forces a flush of all pending log records. + * This ensures all buffered logs are exported immediately. + * + * @return A CompletableResultCode indicating the flush operation result + */ suspend fun forceFlush(): CompletableResultCode } 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 b53196032f..4b4345d169 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 @@ -6,6 +6,12 @@ package com.onesignal.otel */ interface IOtelPlatformProvider { // Top-level attributes (static, calculated once) + /** + * Gets the installation ID for this device. + * This is an async operation as it may need to generate a new ID if one doesn't exist. + * + * @return The installation ID as a string + */ suspend fun getInstallId(): String val sdkBase: String val sdkBaseVersion: String diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt index 9e2e9686f7..aab0c4dd58 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt @@ -44,6 +44,11 @@ class OtelCrashUploader( platformProvider.minFileAgeForReadMillis ).iterator() + /** + * Starts the crash uploader process. + * This will periodically check for crash reports on disk and upload them to OneSignal. + * If remote logging is disabled, this function returns immediately without doing anything. + */ suspend fun start() { if (!platformProvider.remoteLoggingEnabled) { logger.info("OtelCrashUploader: remote logging disabled") From 41f0e7af1b79351cd221ac143d52f2a4f7a66155 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 29 Dec 2025 13:56:13 -0500 Subject: [PATCH 17/19] offload init background. lambda based --- .../internal/backend/IParamsBackendService.kt | 2 +- .../backend/impl/ParamsBackendService.kt | 37 ++- .../core/internal/config/ConfigModel.kt | 12 +- .../config/impl/ConfigModelStoreListener.kt | 2 +- .../crash/OneSignalCrashHandlerFactory.kt | 9 +- .../crash/OneSignalCrashUploaderWrapper.kt | 8 +- .../debug/internal/logging/Logging.kt | 18 +- .../android/AndroidOtelPlatformProvider.kt | 240 ++++++++++++---- .../internal/OneSignalCrashLogInit.kt | 272 ++++++++++++++++++ .../com/onesignal/internal/OneSignalImp.kt | 94 +----- .../crash/OneSignalCrashHandlerFactoryTest.kt | 16 +- .../internal/crash/OtelIntegrationTest.kt | 39 +-- .../debug/internal/logging/LoggingOtelTest.kt | 26 +- .../onesignal/otel/IOtelPlatformProvider.kt | 8 +- .../onesignal/otel/OneSignalOpenTelemetry.kt | 5 +- .../otel/config/OtelConfigRemoteOneSignal.kt | 9 +- .../onesignal/otel/crash/OtelCrashUploader.kt | 7 +- .../com/onesignal/otel/OtelFactoryTest.kt | 2 +- 18 files changed, 577 insertions(+), 229 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt 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 7c74520d97..3d68350970 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 @@ -57,5 +57,5 @@ internal class FCMParamsObject( ) internal class RemoteLoggingParamsObject( - val enable: Boolean? = null, + val logLevel: com.onesignal.debug.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 be7dbc4e44..ccf575cf5c 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,9 +61,10 @@ internal class ParamsBackendService( // Process Remote Logging params var remoteLoggingParams: RemoteLoggingParamsObject? = null responseJson.expandJSONObject("remote_logging") { + val logLevel = parseLogLevel(it) remoteLoggingParams = RemoteLoggingParamsObject( - enable = it.safeBool("enable"), + logLevel = logLevel, ) } @@ -133,4 +134,38 @@ internal class ParamsBackendService( isUnattributedEnabled, ) } + + /** + * Parse LogLevel from JSON. Supports both string (enum name) and int (ordinal) formats. + */ + @Suppress("ReturnCount", "TooGenericExceptionCaught", "SwallowedException") + private fun parseLogLevel(json: JSONObject): LogLevel? { + // Try string format first (e.g., "ERROR", "WARN", "NONE") + val logLevelString = json.safeString("log_level") ?: json.safeString("logLevel") + if (logLevelString != null) { + try { + return LogLevel.valueOf(logLevelString.uppercase()) + } catch (e: IllegalArgumentException) { + Logging.warn("Invalid log level string: $logLevelString") + } + } + + // Try int format (ordinal: 0=NONE, 1=FATAL, 2=ERROR, etc.) + val logLevelInt = json.safeInt("log_level") ?: json.safeInt("logLevel") + if (logLevelInt != null) { + try { + return LogLevel.fromInt(logLevelInt) + } catch (e: Exception) { + Logging.warn("Invalid log level int: $logLevelInt") + } + } + + // Backward compatibility: support old "enable" boolean field + val enable = json.safeBool("enable") + if (enable != null) { + return if (enable) LogLevel.ERROR else LogLevel.NONE + } + + return null + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index 9b7a9b490c..86c0417db0 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 @@ -443,11 +443,15 @@ class RemoteLoggingConfigModel( parentProperty: String, ) : Model(parentModel, parentProperty) { /** - * Do we send OneSignal related logs to OneSignal's server. + * The minimum log level to send to OneSignal's server. + * If null, defaults to ERROR level for client-side logging. + * If NONE, no logs (including errors) will be sent remotely. + * + * Log levels: NONE < FATAL < ERROR < WARN < INFO < DEBUG < VERBOSE */ - var enable: Boolean? - get() = getOptBooleanProperty(::enable.name) { null } + var logLevel: com.onesignal.debug.LogLevel? + get() = getOptEnumProperty(::logLevel.name) set(value) { - setOptBooleanProperty(::enable.name, value) + setOptEnumProperty(::logLevel.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 2f3c415619..9f5277f1e3 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 @@ -103,7 +103,7 @@ internal class ConfigModelStoreListener( params.influenceParams.isIndirectEnabled?.let { config.influenceParams.isIndirectEnabled = it } params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it } - params.remoteLoggingParams.enable?.let { config.remoteLoggingParams.enable = it } + params.remoteLoggingParams.logLevel?.let { config.remoteLoggingParams.logLevel = it } _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 ea208c27cb..533471471b 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 @@ -4,9 +4,8 @@ import android.os.Build import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService -import com.onesignal.core.internal.time.ITime import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.IOtelCrashHandler import com.onesignal.otel.OtelFactory import com.onesignal.user.internal.identity.IdentityModelStore @@ -27,7 +26,6 @@ internal object OneSignalCrashHandlerFactory { installIdService: IInstallIdService, configModelStore: ConfigModelStore, identityModelStore: IdentityModelStore, - time: ITime, ): IOtelCrashHandler { // Otel requires SDK 26+, use no-op for older versions if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -36,12 +34,11 @@ internal object OneSignalCrashHandlerFactory { } com.onesignal.debug.internal.logging.Logging.info("OneSignal: Creating Otel crash handler (SDK ${Build.VERSION.SDK_INT} >= 26)") - val platformProvider = AndroidOtelPlatformProvider( + val platformProvider = createAndroidOtelPlatformProvider( applicationService, installIdService, configModelStore, - identityModelStore, - time + identityModelStore ) val logger = AndroidOtelLogger() 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 8a596c5c13..6cea9b9eca 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 @@ -4,9 +4,7 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService import com.onesignal.core.internal.startup.IStartableService -import com.onesignal.core.internal.time.ITime import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider import com.onesignal.otel.OtelFactory import com.onesignal.otel.crash.OtelCrashUploader import com.onesignal.user.internal.identity.IdentityModelStore @@ -38,16 +36,14 @@ internal class OneSignalCrashUploaderWrapper( private val installIdService: IInstallIdService, private val configModelStore: ConfigModelStore, private val identityModelStore: IdentityModelStore, - private val time: ITime, ) : IStartableService { private val uploader: OtelCrashUploader by lazy { // Create Android-specific platform provider (injects Android values) - val platformProvider = AndroidOtelPlatformProvider( + val platformProvider = com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider( applicationService, installIdService, configModelStore, - identityModelStore, - time + identityModelStore ) // Create Android-specific logger (delegates to Android Logging) val logger = AndroidOtelLogger() 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 ba561962c3..d305fe64e2 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 @@ -31,25 +31,25 @@ object Logging { private var otelRemoteTelemetry: IOtelOpenTelemetryRemote? = null /** - * Function to check if remote logging is enabled. - * Set this to dynamically check remote logging configuration. + * Function to check if a specific log level should be sent remotely. + * Set this to dynamically check remote logging configuration based on log level. */ @Volatile - private var isRemoteLoggingEnabled: () -> Boolean = { false } + private var shouldSendLogLevel: (LogLevel) -> Boolean = { false } /** - * Sets the Otel remote telemetry instance and remote logging check function. + * Sets the Otel remote telemetry instance and log level check function. * This should be called when remote logging is enabled. * * @param telemetry The Otel remote telemetry instance - * @param isEnabled Function that returns true if remote logging is currently enabled + * @param shouldSend Function that returns true if a log level should be sent remotely */ fun setOtelTelemetry( telemetry: IOtelOpenTelemetryRemote?, - isEnabled: () -> Boolean = { false }, + shouldSend: (LogLevel) -> Boolean = { false }, ) { otelRemoteTelemetry = telemetry - isRemoteLoggingEnabled = isEnabled + shouldSendLogLevel = shouldSend } // Coroutine scope for async Otel logging (non-blocking) @@ -210,11 +210,13 @@ object Logging { throwable: Throwable?, ) { val telemetry = otelRemoteTelemetry ?: return - if (!isRemoteLoggingEnabled()) return // Skip NONE level if (level == LogLevel.NONE) return + // Check if this log level should be sent remotely + if (!shouldSendLogLevel(level)) return + // Log asynchronously (non-blocking) otelLoggingScope.launch { try { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt index 33dcc92228..ebfb7bdd88 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt @@ -1,43 +1,68 @@ package com.onesignal.debug.internal.logging.otel.android +import android.app.ActivityManager +import android.content.Context import android.os.Build -import com.onesignal.common.AndroidUtils +import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils import com.onesignal.common.OneSignalWrapper -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService -import com.onesignal.core.internal.time.ITime +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging import com.onesignal.otel.IOtelPlatformProvider -import com.onesignal.user.internal.identity.IdentityModelStore -import java.io.File + +/** + * Configuration for AndroidOtelPlatformProvider. + * Groups parameters to avoid LongParameterList detekt issue. + */ +internal data class AndroidOtelPlatformProviderConfig( + val crashStoragePath: String, + val appPackageId: String, + val appVersion: String, + val getInstallIdProvider: suspend () -> String, + val context: Context? = null, + val getAppId: (() -> String?)? = null, + val getOnesignalId: (() -> String?)? = null, + val getPushSubscriptionId: (() -> String?)? = null, + val getIsInForeground: (() -> Boolean?)? = null, + val getRemoteLogLevel: (() -> com.onesignal.debug.LogLevel?)? = null, +) /** * Android-specific implementation of IOtelPlatformProvider. - * This injects all Android-specific values into the platform-agnostic otel module. + * This provider can work with or without full service dependencies, making it flexible for both + * early crash handler initialization and full remote logging scenarios. + * + * If Context is provided, it will attempt to retrieve additional metadata from SharedPreferences + * and system services, falling back to null/defaults if unavailable. + * + * Optional service getters can be provided to retrieve values from services if available: + * - getAppId: () -> String? - Get appId from ConfigModelStore if available + * - getOnesignalId: () -> String? - Get onesignalId from IdentityModelStore if available + * - getPushSubscriptionId: () -> String? - Get pushSubscriptionId from ConfigModelStore if available + * - getIsInForeground: () -> Boolean? - Get foreground state from ApplicationService if available + * - getRemoteLogLevel: () -> LogLevel? - Get remote logging level from ConfigModelStore if available */ internal class AndroidOtelPlatformProvider( - private val applicationService: IApplicationService, - private val installIdService: IInstallIdService, - private val configModelStore: ConfigModelStore, - private val identityModelStore: IdentityModelStore, - private val time: ITime, + config: AndroidOtelPlatformProviderConfig, ) : IOtelPlatformProvider { + override val appPackageId: String = config.appPackageId + override val appVersion: String = config.appVersion + private val getInstallIdProvider: suspend () -> String = config.getInstallIdProvider + private val context: Context? = config.context + private val getAppId: (() -> String?)? = config.getAppId + private val getOnesignalId: (() -> String?)? = config.getOnesignalId + private val getPushSubscriptionId: (() -> String?)? = config.getPushSubscriptionId + private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground + private val getRemoteLogLevel: (() -> com.onesignal.debug.LogLevel?)? = config.getRemoteLogLevel + // Top-level attributes (static, calculated once) - override suspend fun getInstallId(): String = - installIdService.getId().toString() + override suspend fun getInstallId(): String = getInstallIdProvider() override val sdkBase: String = "android" override val sdkBaseVersion: String = OneSignalUtils.sdkVersion - override val appPackageId: String - get() = applicationService.appContext.packageName - - override val appVersion: String - get() = AndroidUtils.getAppVersion(applicationService.appContext) ?: "unknown" - override val deviceManufacturer: String = Build.MANUFACTURER override val deviceModel: String = Build.MODEL @@ -53,81 +78,184 @@ internal class AndroidOtelPlatformProvider( override val sdkWrapperVersion: String? = OneSignalWrapper.sdkVersion // Per-event attributes (dynamic, calculated per event) + // Try to retrieve from services or SharedPreferences if available, fall back to null override val appId: String? + @Suppress("TooGenericExceptionCaught", "SwallowedException") get() = try { - configModelStore.model.appId - } catch (_: NullPointerException) { - Logging.warn("app_id not available to add to crash log") + // First try to get from service if available + getAppId?.invoke() ?: run { + // Fall back to SharedPreferences and pick up at least Legacy Id if it exists + context?.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) + } + } catch (e: Exception) { null } override val onesignalId: String? + @Suppress("TooGenericExceptionCaught", "SwallowedException") get() = try { - val onesignalId = identityModelStore.model.onesignalId - if (com.onesignal.common.IDManager.isLocalId(onesignalId)) { - null - } else { - onesignalId + // First try to get from service if available + getOnesignalId?.invoke()?.takeIf { !IDManager.isLocalId(it) } ?: run { + // Fall back to SharedPreferences and pick up at least Legacy Id if it exists + context?.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, null) + ?.takeIf { !IDManager.isLocalId(it) } } - } catch (_: NullPointerException) { - Logging.warn("onesignalId not available to add to crash log") + } catch (e: Exception) { null } override val pushSubscriptionId: String? + @Suppress("TooGenericExceptionCaught", "SwallowedException") get() = try { - val pushSubscriptionId = configModelStore.model.pushSubscriptionId - if (pushSubscriptionId == null || - com.onesignal.common.IDManager.isLocalId(pushSubscriptionId) - ) { - null - } else { - pushSubscriptionId - } - } catch (_: NullPointerException) { - Logging.warn("subscriptionId not available to add to crash log") + getPushSubscriptionId?.invoke()?.takeIf { !IDManager.isLocalId(it) } + } catch (e: Exception) { null } // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ override val appState: String - get() = if (applicationService.isInForeground) "foreground" else "background" + @Suppress("TooGenericExceptionCaught", "SwallowedException") + get() = try { + // Try to get from ApplicationService if available + getIsInForeground?.invoke()?.let { isForeground -> + if (isForeground) "foreground" else "background" + } ?: run { + // Fall back to ActivityManager if Context is available + context?.let { ctx -> + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val activityManager = ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val runningAppProcesses = activityManager?.runningAppProcesses + val currentProcess = runningAppProcesses?.find { it.pid == android.os.Process.myPid() } + when (currentProcess?.importance) { + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE -> "foreground" + else -> "background" + } + } catch (e: Exception) { + "unknown" + } + } ?: "unknown" + } + } catch (e: Exception) { + "unknown" + } // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime override val processUptime: Double - get() = time.processUptimeMillis / 1_000.0 + get() = android.os.SystemClock.uptimeMillis() / 1_000.0 // Use SystemClock directly // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes override val currentThreadName: String get() = Thread.currentThread().name // Crash-specific configuration + // Store crashStoragePath privately since we need a custom getter that logs + private val _crashStoragePath: String = config.crashStoragePath + override val crashStoragePath: String get() { - val path = applicationService.appContext.cacheDir.path + File.separator + - "onesignal" + File.separator + - "otel" + File.separator + - "crashes" // Log the path on first access so developers know where to find crash logs - Logging.info("OneSignal: Crash logs stored at: $path") - return path + Logging.info("OneSignal: Crash logs stored at: $_crashStoragePath") + return _crashStoragePath } override val minFileAgeForReadMillis: Long = 5_000 // Remote logging configuration - override val remoteLoggingEnabled: Boolean + /** + * 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" (bare minimum for client-side) + * - If remote config is explicitly NONE: return "NONE" (no logs including errors) + */ + override val remoteLogLevel: String? + @Suppress("TooGenericExceptionCaught", "SwallowedException") get() = try { - configModelStore.model.remoteLoggingParams.enable ?: true - } catch (_: NullPointerException) { - false + val configLevel = getRemoteLogLevel?.invoke() + 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 as bare minimum + else -> "ERROR" + } + } catch (e: Exception) { + // If there's an error accessing config, default to ERROR as bare minimum + "ERROR" } override val appIdForHeaders: String + @Suppress("TooGenericExceptionCaught", "SwallowedException") get() = try { - configModelStore.model.appId - } catch (_: NullPointerException) { - Logging.error("Auth missing for crash log reporting!") + // Try to get appId for headers (same logic as appId property) + appId ?: "" + } catch (e: Exception) { "" } } + +/** + * Factory function to create AndroidOtelPlatformProvider with full service dependencies. + * This is a convenience function that creates the provider with service getters. + */ +internal fun createAndroidOtelPlatformProvider( + applicationService: com.onesignal.core.internal.application.IApplicationService, + installIdService: com.onesignal.core.internal.device.IInstallIdService, + configModelStore: com.onesignal.core.internal.config.ConfigModelStore, + identityModelStore: com.onesignal.user.internal.identity.IdentityModelStore, +): AndroidOtelPlatformProvider { + val context = applicationService.appContext + val crashStoragePath = context.cacheDir.path + java.io.File.separator + + "onesignal" + java.io.File.separator + + "otel" + java.io.File.separator + + "crashes" + + return AndroidOtelPlatformProvider( + AndroidOtelPlatformProviderConfig( + crashStoragePath = crashStoragePath, + appPackageId = context.packageName, + appVersion = com.onesignal.common.AndroidUtils.getAppVersion(context) ?: "unknown", + getInstallIdProvider = { installIdService.getId().toString() }, + context = context, + getAppId = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + configModelStore.model.appId + } catch (e: Exception) { + null + } + }, + getOnesignalId = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val onesignalId = identityModelStore.model.onesignalId + onesignalId.takeIf { !IDManager.isLocalId(it) } + } catch (e: Exception) { + null + } + }, + getPushSubscriptionId = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val pushSubscriptionId = configModelStore.model.pushSubscriptionId + pushSubscriptionId?.takeIf { !IDManager.isLocalId(pushSubscriptionId) } + } catch (e: Exception) { + null + } + }, + getIsInForeground = { applicationService.isInForeground }, + getRemoteLogLevel = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + configModelStore.model.remoteLoggingParams.logLevel + } catch (e: Exception) { + null + } + }, + ) + ) +} 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 new file mode 100644 index 0000000000..d4ce0220cb --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt @@ -0,0 +1,272 @@ +package com.onesignal.internal + +import android.content.Context +import com.onesignal.common.AndroidUtils +import com.onesignal.common.IDManager +import com.onesignal.common.services.IServiceProvider +import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +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.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProviderConfig +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.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.identity.IdentityModelStore +import org.json.JSONArray + +/** + * Helper object for OneSignal initialization tasks. + * Extracted from OneSignalImp to reduce class size and improve maintainability. + */ +internal object OneSignalCrashLogInit { + @Suppress("TooGenericExceptionCaught") + fun initializeCrashHandler( + context: Context, + services: IServiceProvider, + ) { + try { + Logging.info("OneSignal: Initializing crash handler early...") + // Use minimal dependencies - only primitive values and installId provider + // This makes crash handler completely independent of service architecture + // Context is passed directly from initEssentials, not through ApplicationService + val crashStoragePath = context.cacheDir.path + java.io.File.separator + + "onesignal" + java.io.File.separator + + "otel" + java.io.File.separator + + "crashes" + val appPackageId = context.packageName + val appVersion = try { + AndroidUtils.getAppVersion(context) ?: "unknown" + } catch (e: Exception) { + Logging.warn("OneSignal: Failed to get app version for crash handler: ${e.message}, using 'unknown'") + "unknown" + } + + Logging.info("OneSignal: Creating crash handler with minimal dependencies...") + + // Initialize crash handler immediately with minimal setup (non-blocking) + // Service getters check services dynamically when called - if services are available + // at crash time, they'll be used; otherwise falls back to SharedPreferences + // This ensures fast initialization while still using services when available + + // Helper to get ConfigModelStore (used by multiple getters, but called lazily at crash time) + val getConfigModelStore: () -> ConfigModelStore? = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + services.getServiceOrNull(ConfigModelStore::class.java) + } catch (e: Exception) { + null + } + } + + // Create platform provider with lambda getters that check services dynamically + // If services are available at crash time, use them; otherwise fall back to SharedPreferences + val platformProvider = AndroidOtelPlatformProvider( + AndroidOtelPlatformProviderConfig( + crashStoragePath = crashStoragePath, + appPackageId = appPackageId, + appVersion = appVersion, + getInstallIdProvider = { + @Suppress("TooGenericExceptionCaught") + try { + val installIdService = services.getServiceOrNull(IInstallIdService::class.java) + installIdService?.getId()?.toString() ?: "" + } catch (e: Exception) { + Logging.warn("OneSignal: Failed to get installId for crash handler: ${e.message}, using empty string") + "" + } + }, + context = context, + // Dynamic service getters - check services when called (at crash time) + // If services are available, use them; otherwise fall back to SharedPreferences + getAppId = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + getConfigModelStore()?.model?.appId ?: run { + // Fall back to SharedPreferences - try MODEL_STORE_config first, then legacy + val prefs = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val configStoreJson = prefs?.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "config", null) + if (configStoreJson != null) { + @Suppress("SwallowedException") + try { + val jsonArray = JSONArray(configStoreJson) + if (jsonArray.length() > 0) { + val configModelJson = jsonArray.getJSONObject(0) + configModelJson.optString("appId", null)?.takeIf { it.isNotEmpty() } + } else { + null + } + } catch (e: Exception) { + null + } + } else { + null + } + } ?: run { + // Final fallback to legacy app ID + context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) + } + } catch (e: Exception) { + null + } + }, + getOnesignalId = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val identityModelStore = services.getServiceOrNull(IdentityModelStore::class.java) + val onesignalId = identityModelStore?.model?.onesignalId + onesignalId?.takeIf { !IDManager.isLocalId(it) } ?: run { + // Fall back to SharedPreferences - try MODEL_STORE_identity first, then legacy + val prefs = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val identityStoreJson = prefs?.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "identity", null) + if (identityStoreJson != null) { + @Suppress("SwallowedException") + try { + val jsonArray = JSONArray(identityStoreJson) + if (jsonArray.length() > 0) { + val identityModelJson = jsonArray.getJSONObject(0) + val onesignalIdFromPrefs = identityModelJson.optString(IdentityConstants.ONESIGNAL_ID, null) + onesignalIdFromPrefs?.takeIf { it.isNotEmpty() && !IDManager.isLocalId(it) } + } else { + null + } + } catch (e: Exception) { + null + } + } else { + null + } + } ?: run { + // Final fallback to legacy player ID + context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, null) + ?.takeIf { !IDManager.isLocalId(it) } + } + } catch (e: Exception) { + null + } + }, + getPushSubscriptionId = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val pushSubscriptionId = getConfigModelStore()?.model?.pushSubscriptionId + pushSubscriptionId?.takeIf { !IDManager.isLocalId(pushSubscriptionId) } + } catch (e: Exception) { + null + } + }, + getIsInForeground = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val applicationService = services.getServiceOrNull(IApplicationService::class.java) + applicationService?.isInForeground + } catch (e: Exception) { + null + } + }, + getRemoteLogLevel = { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + getConfigModelStore()?.model?.remoteLoggingParams?.logLevel + } catch (e: Exception) { + null + } + }, + ) + ) + + // Create crash handler directly (non-blocking, doesn't require services upfront) + val logger = AndroidOtelLogger() + val crashHandler: IOtelCrashHandler = OtelFactory.createCrashHandler(platformProvider, logger) + + Logging.info("OneSignal: Crash handler created, initializing...") + crashHandler.initialize() + + // Log crash storage location for debugging + Logging.info("OneSignal: ✅ Crash handler initialized successfully and ready to capture crashes") + Logging.info("OneSignal: 📁 Crash logs will be stored at: $crashStoragePath") + Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as ${context.packageName} ls -la $crashStoragePath") + } 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( + applicationService: IApplicationService, + services: IServiceProvider, + ) { + // 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 + suspendifyOnIO { + try { + // Get dependencies needed for Otel logging (non-blocking, uses getServiceOrNull) + val installIdService = services.getServiceOrNull(IInstallIdService::class.java) + val configModelStore = services.getServiceOrNull(ConfigModelStore::class.java) + val identityModelStore = services.getServiceOrNull(IdentityModelStore::class.java) + + // If services aren't available yet, skip initialization (will be retried later if needed) + if (installIdService == null || configModelStore == null || identityModelStore == null) { + Logging.debug("OneSignal: Services not yet available for Otel logging, skipping initialization") + return@suspendifyOnIO + } + + val platformProvider = createAndroidOtelPlatformProvider( + applicationService, + installIdService, + configModelStore, + identityModelStore + ) + + // 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) + val isRemoteLoggingEnabled = remoteLogLevelStr != null && remoteLogLevelStr != "NONE" + + if (isRemoteLoggingEnabled) { + Logging.info("OneSignal: Remote logging enabled at level $remoteLogLevelStr, 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 = try { + LogLevel.valueOf(remoteLogLevelStr) + } 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 $remoteLogLevelStr and above will be sent to remote server") + } else { + Logging.debug("OneSignal: Remote logging disabled (level: $remoteLogLevelStr), skipping Otel logging integration") + } + } catch (e: Exception) { + // If Otel logging initialization fails, log it but don't crash + Logging.warn("OneSignal: Failed to initialize Otel logging: ${e.message}", e) + } + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 290e5feb78..e1e75cb24a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -16,23 +16,17 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceStoreFix import com.onesignal.core.internal.startup.StartupService -import com.onesignal.core.internal.time.ITime import com.onesignal.debug.IDebugManager import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.DebugManager -import com.onesignal.debug.internal.crash.OneSignalCrashHandlerFactory import com.onesignal.debug.internal.logging.Logging -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.location.ILocationManager import com.onesignal.notifications.INotificationsManager -import com.onesignal.otel.IOtelOpenTelemetryRemote -import com.onesignal.otel.OtelFactory import com.onesignal.session.ISessionManager import com.onesignal.session.SessionModule import com.onesignal.user.IUserManager @@ -213,6 +207,14 @@ internal class OneSignalImp( } private fun initEssentials(context: 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. + // Pass Context directly instead of going through ApplicationService + OneSignalCrashLogInit.initializeCrashHandler(context, services) + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) // start the application service. This is called explicitly first because we want @@ -224,86 +226,8 @@ internal class OneSignalImp( // Give the logging singleton access to the application service to support visual logging. Logging.applicationService = applicationService - // 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. - initializeCrashHandlerEarly(applicationService) - // Initialize Otel logging integration after services are available - initializeOtelLogging(applicationService) - } - - @Suppress("TooGenericExceptionCaught") - private fun initializeCrashHandlerEarly(applicationService: IApplicationService) { - try { - Logging.info("OneSignal: Initializing crash handler early...") - // Get minimal dependencies needed for crash handler - val installIdService = services.getService() - val configModelStore = services.getService() - val identityModelStore = services.getService() - val time = services.getService() - - Logging.info("OneSignal: Creating crash handler...") - val crashHandler = OneSignalCrashHandlerFactory.createCrashHandler( - applicationService, - installIdService, - configModelStore, - identityModelStore, - time - ) - Logging.info("OneSignal: Crash handler created, initializing...") - crashHandler.initialize() - - // Log crash storage location for debugging - val crashPath = (applicationService.appContext.cacheDir.path + - java.io.File.separator + "onesignal" + - java.io.File.separator + "otel" + - java.io.File.separator + "crashes") - Logging.info("OneSignal: ✅ Crash handler initialized successfully and ready to capture crashes") - Logging.info("OneSignal: 📁 Crash logs will be stored at: $crashPath") - Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as com.onesignal.sdktest ls -la $crashPath") - } 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") - private fun initializeOtelLogging(applicationService: IApplicationService) { - try { - // Get dependencies needed for Otel logging - val installIdService = services.getService() - val configModelStore = services.getService() - val identityModelStore = services.getService() - val time = services.getService() - - val platformProvider = AndroidOtelPlatformProvider( - applicationService, - installIdService, - configModelStore, - identityModelStore, - time - ) - - // Check if remote logging is enabled - val isRemoteLoggingEnabled = { platformProvider.remoteLoggingEnabled } - - if (isRemoteLoggingEnabled()) { - Logging.info("OneSignal: Remote logging enabled, initializing Otel logging integration...") - val remoteTelemetry: IOtelOpenTelemetryRemote = OtelFactory.createRemoteTelemetry(platformProvider) - - // Inject Otel telemetry into Logging class - Logging.setOtelTelemetry(remoteTelemetry, isRemoteLoggingEnabled) - Logging.info("OneSignal: ✅ Otel logging integration initialized - logs will be sent to remote server") - } else { - Logging.debug("OneSignal: Remote logging disabled, 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) - } + OneSignalCrashLogInit.initializeOtelLogging(applicationService, services) } private fun updateConfig() { 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 00ac24462a..e99148b1f9 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 @@ -3,13 +3,11 @@ package com.onesignal.debug.internal.crash import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService -import com.onesignal.core.internal.time.ITime import com.onesignal.otel.IOtelCrashHandler import com.onesignal.user.internal.identity.IdentityModelStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.every import io.mockk.mockk class OneSignalCrashHandlerFactoryTest : FunSpec({ @@ -17,19 +15,13 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ val mockInstallIdService = mockk(relaxed = true) val mockConfigModelStore = mockk(relaxed = true) val mockIdentityModelStore = mockk(relaxed = true) - val mockTime = mockk(relaxed = true) - - beforeEach { - every { mockTime.processUptimeMillis } returns 100000L - } test("createCrashHandler should return IOtelCrashHandler") { val handler = OneSignalCrashHandlerFactory.createCrashHandler( mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) handler.shouldBeInstanceOf() @@ -42,8 +34,7 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) handler shouldNotBe null @@ -58,8 +49,7 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) handler shouldNotBe null 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 165d7ca821..61e15a9fe2 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 @@ -6,9 +6,8 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IInstallIdService -import com.onesignal.core.internal.time.ITime import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.IOtelCrashHandler import com.onesignal.otel.IOtelPlatformProvider import com.onesignal.otel.OtelFactory @@ -34,7 +33,6 @@ class OtelIntegrationTest : FunSpec({ val mockInstallIdService = mockk(relaxed = true) val mockConfigModelStore = mockk(relaxed = true) val mockIdentityModelStore = mockk(relaxed = true) - val mockTime = mockk(relaxed = true) val mockConfigModel = mockk(relaxed = true) val mockIdentityModel = mockk(relaxed = true) @@ -48,22 +46,20 @@ class OtelIntegrationTest : FunSpec({ coEvery { mockInstallIdService.getId() } returns UUID.randomUUID() every { mockConfigModelStore.model } returns mockConfigModel every { mockIdentityModelStore.model } returns mockIdentityModel - every { mockTime.processUptimeMillis } returns 100000L every { mockConfigModel.appId } returns "test-app-id" every { mockConfigModel.remoteLoggingParams } returns mockk(relaxed = true) { - every { enable } returns true + every { logLevel } returns com.onesignal.debug.LogLevel.ERROR } every { mockIdentityModel.onesignalId } returns "test-onesignal-id" every { mockConfigModel.pushSubscriptionId } returns "test-subscription-id" } test("AndroidOtelPlatformProvider should provide correct Android values") { - val provider = AndroidOtelPlatformProvider( + val provider = createAndroidOtelPlatformProvider( mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) provider.shouldBeInstanceOf() @@ -81,12 +77,11 @@ class OtelIntegrationTest : FunSpec({ } test("AndroidOtelPlatformProvider should provide per-event values") { - val provider = AndroidOtelPlatformProvider( + val provider = createAndroidOtelPlatformProvider( mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) provider.appId shouldBe "test-app-id" @@ -109,12 +104,11 @@ class OtelIntegrationTest : FunSpec({ } test("OtelFactory should create crash handler with Android provider") { - val provider = AndroidOtelPlatformProvider( + val provider = createAndroidOtelPlatformProvider( mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) val logger = AndroidOtelLogger() @@ -130,8 +124,7 @@ class OtelIntegrationTest : FunSpec({ mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) handler shouldNotBe null @@ -140,12 +133,11 @@ class OtelIntegrationTest : FunSpec({ } test("AndroidOtelPlatformProvider should provide crash storage path") { - val provider = AndroidOtelPlatformProvider( + val provider = createAndroidOtelPlatformProvider( mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) provider.crashStoragePath.contains("onesignal") shouldBe true @@ -156,18 +148,17 @@ class OtelIntegrationTest : FunSpec({ test("AndroidOtelPlatformProvider should handle remote logging config") { every { mockConfigModel.remoteLoggingParams } returns mockk(relaxed = true) { - every { enable } returns true + every { logLevel } returns com.onesignal.debug.LogLevel.ERROR } - val provider = AndroidOtelPlatformProvider( + val provider = createAndroidOtelPlatformProvider( mockApplicationService, mockInstallIdService, mockConfigModelStore, - mockIdentityModelStore, - mockTime + mockIdentityModelStore ) - provider.remoteLoggingEnabled shouldBe true + provider.remoteLogLevel shouldBe "ERROR" provider.appIdForHeaders shouldBe "test-app-id" } }) 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 5e321fad9e..6bde1defb8 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 @@ -26,10 +26,10 @@ class LoggingOtelTest : FunSpec({ test("setOtelTelemetry should store telemetry and enabled check function") { // Given - val isEnabled = { true } + val shouldSend = { _: LogLevel -> true } // When - Logging.setOtelTelemetry(mockTelemetry, isEnabled) + Logging.setOtelTelemetry(mockTelemetry, shouldSend) // Then - verify it's set (we'll test it works by logging) Logging.info("test") @@ -45,7 +45,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should work when remote logging is enabled") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) // When Logging.info("test message") @@ -61,7 +61,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should NOT crash when remote logging is disabled") { // Given - Logging.setOtelTelemetry(mockTelemetry, { false }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> false }) // When Logging.info("test message") @@ -76,7 +76,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should NOT crash when telemetry is null") { // Given - Logging.setOtelTelemetry(null, { true }) + Logging.setOtelTelemetry(null, { _: LogLevel -> true }) // When Logging.info("test message") @@ -91,7 +91,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should handle all log levels without crashing") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) // When Logging.verbose("verbose message") @@ -111,7 +111,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should NOT log NONE level") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) // When Logging.log(LogLevel.NONE, "none message") @@ -126,7 +126,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should handle exceptions in logs") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) val exception = RuntimeException("test exception") // When @@ -142,7 +142,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should handle null exception message") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) val exception = RuntimeException() // When @@ -158,7 +158,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should handle Otel errors gracefully") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + 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 @@ -176,7 +176,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should use dynamic remote logging check") { // Given var isEnabled = false - Logging.setOtelTelemetry(mockTelemetry, { isEnabled }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> isEnabled }) // When - initially disabled Logging.info("message 1") @@ -197,7 +197,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should handle multiple rapid log calls") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) // When - rapid fire logging repeat(10) { @@ -214,7 +214,7 @@ class LoggingOtelTest : FunSpec({ test("logToOtel should work with different message formats") { // Given - Logging.setOtelTelemetry(mockTelemetry, { true }) + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) // When Logging.info("simple message") 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 4b4345d169..83dfdb3357 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 @@ -38,6 +38,12 @@ interface IOtelPlatformProvider { val minFileAgeForReadMillis: Long // Remote logging configuration - val remoteLoggingEnabled: Boolean + /** + * 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. + * Valid values: "NONE", "FATAL", "ERROR", "WARN", "INFO", "DEBUG", "VERBOSE" + */ + val remoteLogLevel: String? val appIdForHeaders: 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 7009b30e1d..6592e6be59 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 @@ -97,7 +97,7 @@ internal class OneSignalOpenTelemetryRemote( } override val logExporter by lazy { - OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders) + OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders, appId) } override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = @@ -106,7 +106,8 @@ internal class OneSignalOpenTelemetryRemote( .setLoggerProvider( OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( OtelConfigShared.ResourceConfig.create(attributes), - extraHttpHeaders + extraHttpHeaders, + appId ) ).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 ff4678296e..2e46d87cef 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 @@ -24,28 +24,29 @@ internal class OtelConfigRemoteOneSignal { object SdkLoggerProviderConfig { // NOTE: Switch to https://sdklogs.onesignal.com:443/sdk/otel when ready - const val BASE_URL = "https://api.honeycomb.io:443" + const val BASE_URL = "https://api.staging.onesignal.com/sdk/otel" fun create( resource: io.opentelemetry.sdk.resources.Resource, extraHttpHeaders: Map, + appId: String, ): SdkLoggerProvider = SdkLoggerProvider .builder() .setResource(resource) .addLogRecordProcessor( OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor( - HttpRecordBatchExporter.create(extraHttpHeaders) + HttpRecordBatchExporter.create(extraHttpHeaders, appId) ) ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) .build() } object HttpRecordBatchExporter { - fun create(extraHttpHeaders: Map) = + fun create(extraHttpHeaders: Map, appId: String) = LogRecordExporterConfig.otlpHttpLogRecordExporter( extraHttpHeaders, - "${SdkLoggerProviderConfig.BASE_URL}/v1/logs" + "${SdkLoggerProviderConfig.BASE_URL}/v1/logs?app_id=$appId" ) } } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt index aab0c4dd58..d9091b8a32 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt @@ -47,11 +47,12 @@ class OtelCrashUploader( /** * Starts the crash uploader process. * This will periodically check for crash reports on disk and upload them to OneSignal. - * If remote logging is disabled, this function returns immediately without doing anything. + * If remote logging is disabled (NONE level), this function returns immediately without doing anything. */ suspend fun start() { - if (!platformProvider.remoteLoggingEnabled) { - logger.info("OtelCrashUploader: remote logging disabled") + val remoteLogLevel = platformProvider.remoteLogLevel + if (remoteLogLevel == null || remoteLogLevel == "NONE") { + logger.info("OtelCrashUploader: remote logging disabled (level: $remoteLogLevel)") return } 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 0c0e54c14f..f4aa6a9a00 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 @@ -32,7 +32,7 @@ class OtelFactoryTest : FunSpec({ every { mockPlatformProvider.currentThreadName } returns "main" every { mockPlatformProvider.crashStoragePath } returns "/test/path" every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L - every { mockPlatformProvider.remoteLoggingEnabled } returns true + every { mockPlatformProvider.remoteLogLevel } returns "ERROR" every { mockPlatformProvider.appIdForHeaders } returns "test-app-id" coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id" } From 353ac95b870a412dadbc3f3e2eac1cd7d0be5f08 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 29 Dec 2025 17:40:12 -0500 Subject: [PATCH 18/19] reusing lambdas --- ...ormProvider.kt => OtelPlatformProvider.kt} | 22 +++++------ .../internal/OneSignalCrashLogInit.kt | 39 +++++++------------ .../com/onesignal/otel/OtelLoggingHelper.kt | 2 + .../onesignal/otel/crash/OtelCrashReporter.kt | 2 + 4 files changed, 28 insertions(+), 37 deletions(-) rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/{AndroidOtelPlatformProvider.kt => OtelPlatformProvider.kt} (93%) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt similarity index 93% rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt index ebfb7bdd88..cbfe1f1664 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelPlatformProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt @@ -15,7 +15,7 @@ import com.onesignal.otel.IOtelPlatformProvider * Configuration for AndroidOtelPlatformProvider. * Groups parameters to avoid LongParameterList detekt issue. */ -internal data class AndroidOtelPlatformProviderConfig( +internal data class OtelPlatformProviderConfig( val crashStoragePath: String, val appPackageId: String, val appVersion: String, @@ -43,8 +43,8 @@ internal data class AndroidOtelPlatformProviderConfig( * - getIsInForeground: () -> Boolean? - Get foreground state from ApplicationService if available * - getRemoteLogLevel: () -> LogLevel? - Get remote logging level from ConfigModelStore if available */ -internal class AndroidOtelPlatformProvider( - config: AndroidOtelPlatformProviderConfig, +internal class OtelPlatformProvider( + config: OtelPlatformProviderConfig, ) : IOtelPlatformProvider { override val appPackageId: String = config.appPackageId override val appVersion: String = config.appVersion @@ -78,16 +78,12 @@ internal class AndroidOtelPlatformProvider( override val sdkWrapperVersion: String? = OneSignalWrapper.sdkVersion // Per-event attributes (dynamic, calculated per event) - // Try to retrieve from services or SharedPreferences if available, fall back to null + // The getAppId lambda already contains all fallback logic (service -> app SharedPreferences -> legacy -> "unknown") + // So we just invoke it here without duplicating the logic override val appId: String? @Suppress("TooGenericExceptionCaught", "SwallowedException") get() = try { - // First try to get from service if available - getAppId?.invoke() ?: run { - // Fall back to SharedPreferences and pick up at least Legacy Id if it exists - context?.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) - } + getAppId?.invoke() } catch (e: Exception) { null } @@ -207,15 +203,15 @@ internal fun createAndroidOtelPlatformProvider( installIdService: com.onesignal.core.internal.device.IInstallIdService, configModelStore: com.onesignal.core.internal.config.ConfigModelStore, identityModelStore: com.onesignal.user.internal.identity.IdentityModelStore, -): AndroidOtelPlatformProvider { +): OtelPlatformProvider { val context = applicationService.appContext val crashStoragePath = context.cacheDir.path + java.io.File.separator + "onesignal" + java.io.File.separator + "otel" + java.io.File.separator + "crashes" - return AndroidOtelPlatformProvider( - AndroidOtelPlatformProviderConfig( + return OtelPlatformProvider( + OtelPlatformProviderConfig( crashStoragePath = crashStoragePath, appPackageId = context.packageName, appVersion = com.onesignal.common.AndroidUtils.getAppVersion(context) ?: "unknown", 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 d4ce0220cb..19dfb827df 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 @@ -13,8 +13,8 @@ import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProvider -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelPlatformProviderConfig +import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider +import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProviderConfig import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.IOtelCrashHandler import com.onesignal.otel.IOtelOpenTelemetryRemote @@ -69,8 +69,8 @@ internal object OneSignalCrashLogInit { // Create platform provider with lambda getters that check services dynamically // If services are available at crash time, use them; otherwise fall back to SharedPreferences - val platformProvider = AndroidOtelPlatformProvider( - AndroidOtelPlatformProviderConfig( + val platformProvider = OtelPlatformProvider( + OtelPlatformProviderConfig( crashStoragePath = crashStoragePath, appPackageId = appPackageId, appVersion = appVersion, @@ -90,33 +90,24 @@ internal object OneSignalCrashLogInit { getAppId = { @Suppress("TooGenericExceptionCaught", "SwallowedException") try { + // First: try to get from service (ConfigModelStore) if available getConfigModelStore()?.model?.appId ?: run { - // Fall back to SharedPreferences - try MODEL_STORE_config first, then legacy - val prefs = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - val configStoreJson = prefs?.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "config", null) - if (configStoreJson != null) { - @Suppress("SwallowedException") - try { - val jsonArray = JSONArray(configStoreJson) - if (jsonArray.length() > 0) { - val configModelJson = jsonArray.getJSONObject(0) - configModelJson.optString("appId", null)?.takeIf { it.isNotEmpty() } - } else { - null - } - } catch (e: Exception) { - null - } - } else { + // Second: try to get from app's SharedPreferences (like SharedPreferenceUtil.getOneSignalAppId) + // This reads from the app's own SharedPreferences where the app ID might be cached + try { + val appPrefs = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) + appPrefs.getString("OS_APP_ID_SHARED_PREF", null)?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { null } } ?: run { - // Final fallback to legacy app ID + // Third: fall back to legacy OneSignal SharedPreferences context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) - } + ?.takeIf { it.isNotEmpty() } + } ?: "unknown" // Fourth: if all checks are empty, return "unknown" } catch (e: Exception) { - null + "unknown" // If there's an error, return "unknown" } }, getOnesignalId = { diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt index 7b83e0b714..8b1c85c7b0 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt @@ -2,6 +2,7 @@ package com.onesignal.otel import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity +import java.time.Instant /** * Helper class for logging to Otel from the Logging class. @@ -58,6 +59,7 @@ object OtelLoggingHelper { logRecordBuilder.setAllAttributes(attributes) logRecordBuilder.setSeverity(severity) logRecordBuilder.setBody(message) + logRecordBuilder.setTimestamp(Instant.now()) logRecordBuilder.emit() } } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt index f8e83fa728..57331bf6d0 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt @@ -4,6 +4,7 @@ import com.onesignal.otel.IOtelLogger import com.onesignal.otel.IOtelOpenTelemetryCrash import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity +import java.time.Instant internal class OtelCrashReporter( private val openTelemetry: IOtelOpenTelemetryCrash, @@ -35,6 +36,7 @@ internal class OtelCrashReporter( .getLogger() .setAllAttributes(attributes) .setSeverity(Severity.FATAL) + .setTimestamp(Instant.now()) .emit() logger.debug("OtelCrashReporter: Flushing crash report to disk...") From 68d572c4ef158bbc5c80150d947d92cdb49062f9 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 6 Jan 2026 17:31:16 -0500 Subject: [PATCH 19/19] wrote more tests, refactored the code to make it more testable --- .../sdktest/application/MainApplicationKT.kt | 36 +- OneSignalSDK/detekt/detekt-config.yml | 4 +- .../core/internal/config/ConfigModelStore.kt | 3 +- .../debug/internal/crash/AnrConstants.kt | 19 + .../crash/OneSignalCrashHandlerFactory.kt | 37 +- .../crash/OneSignalCrashUploaderWrapper.kt | 14 +- .../debug/internal/crash/OtelAnrDetector.kt | 218 +++++ .../logging/otel/android/OtelIdResolver.kt | 233 +++++ .../otel/android/OtelPlatformProvider.kt | 142 +-- .../internal/OneSignalCrashLogInit.kt | 230 +---- .../com/onesignal/internal/OneSignalImp.kt | 14 +- .../internal/identity/IdentityModelStore.kt | 4 +- .../internal/crash/CrashReportUploadTest.kt | 336 +++++++ .../crash/OneSignalCrashHandlerFactoryTest.kt | 41 +- .../OneSignalCrashUploaderWrapperTest.kt | 100 +++ .../internal/crash/OtelIntegrationTest.kt | 149 ++-- .../otel/android/OtelIdResolverTest.kt | 833 ++++++++++++++++++ .../otel/android/OtelPlatformProviderTest.kt | 678 ++++++++++++++ .../internal/OneSignalCrashLogInitTest.kt | 348 ++++++++ .../com/onesignal/otel/IOtelCrashReporter.kt | 2 +- .../java/com/onesignal/otel/OtelFactory.kt | 50 +- .../onesignal/otel/crash/IOtelAnrDetector.kt | 21 + .../onesignal/otel/crash/OtelCrashHandler.kt | 28 +- .../onesignal/otel/crash/OtelCrashReporter.kt | 3 +- .../com/onesignal/otel/OtelFactoryTest.kt | 138 +++ 25 files changed, 3233 insertions(+), 448 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/CrashReportUploadTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt index 2674f92db3..66f6a34574 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt @@ -43,6 +43,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class MainApplicationKT : MultiDexApplication() { @@ -83,8 +86,39 @@ class MainApplicationKT : MultiDexApplication() { Log.d(Tag.LOG_TAG, Text.ONESIGNAL_SDK_INIT) delay(3000) - //throw RuntimeException("test crash 2025-11-04 18") } +// crashApp() +// forceANR() + } + + private fun forceANR() { + try { + android.os.Handler(android.os.Looper.getMainLooper()).post { + Log.d(Tag.LOG_TAG, "Starting infinite loop on main thread to trigger ANR.") + // This will block the main thread indefinitely, triggering an ANR + // The ANR detector will detect it after 5 seconds if OneSignal code is in the stack trace + while (true) { + Log.d(Tag.LOG_TAG, "Blocking main thread - ANR test") + // Small sleep to prevent excessive CPU usage, but still blocks the thread + Thread.sleep(100) + } + } + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + + private fun crashApp() { + val sdf = SimpleDateFormat( + "MMM dd, yyyy HH:mm:ss", + Locale.getDefault() + ) + + crashApp() + val currentTimeMillis = System.currentTimeMillis() + val date = Date(currentTimeMillis) + val formattedDate = sdf.format(date) + throw RuntimeException("test crash from AR $formattedDate") } private fun setupOneSignalListeners() { diff --git a/OneSignalSDK/detekt/detekt-config.yml b/OneSignalSDK/detekt/detekt-config.yml index f36fd3b166..de24a4b2b2 100644 --- a/OneSignalSDK/detekt/detekt-config.yml +++ b/OneSignalSDK/detekt/detekt-config.yml @@ -66,7 +66,7 @@ complexity: LongParameterList: active: true - functionThreshold: 7 + functionThreshold: 6 constructorThreshold: 8 ComplexCondition: @@ -91,7 +91,7 @@ comments: UndocumentedPublicFunction: active: true excludes: ['**/test/**', '**/androidTest/**', '**/testhelpers/**'] - + EndOfSentenceFormat: active: false endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt index 687a8547b0..801a85e903 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt @@ -3,7 +3,8 @@ package com.onesignal.core.internal.config import com.onesignal.common.modeling.SimpleModelStore import com.onesignal.common.modeling.SingletonModelStore import com.onesignal.core.internal.preferences.IPreferencesService +const val CONFIG_NAME_SPACE = "config" open class ConfigModelStore(prefs: IPreferencesService) : SingletonModelStore( - SimpleModelStore({ ConfigModel() }, "config", prefs), + SimpleModelStore({ ConfigModel() }, CONFIG_NAME_SPACE, prefs), ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt new file mode 100644 index 0000000000..3f0e115eb2 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt @@ -0,0 +1,19 @@ +package com.onesignal.debug.internal.crash + +/** + * Constants for ANR (Application Not Responding) detection configuration. + */ +internal object AnrConstants { + /** + * Default ANR threshold in milliseconds. + * Android's default ANR threshold is 5 seconds (5000ms). + * An ANR is reported when the main thread is unresponsive for this duration. + */ + const val DEFAULT_ANR_THRESHOLD_MS: Long = 5_000L + + /** + * Default check interval in milliseconds. + * The ANR detector checks the main thread responsiveness every 2 seconds. + */ + const val DEFAULT_CHECK_INTERVAL_MS: Long = 2_000L +} 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 533471471b..162a4f7e08 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,47 +1,42 @@ package com.onesignal.debug.internal.crash +import android.content.Context import android.os.Build -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService -import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.Logging import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelLogger import com.onesignal.otel.OtelFactory -import com.onesignal.user.internal.identity.IdentityModelStore /** * 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. + * + * 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. - * All dependencies must be pre-populated. + * + * @param context Android context for creating platform provider + * @param logger Logger instance (can be shared with other components) */ fun createCrashHandler( - applicationService: IApplicationService, - installIdService: IInstallIdService, - configModelStore: ConfigModelStore, - identityModelStore: IdentityModelStore, + context: Context, + logger: IOtelLogger, ): IOtelCrashHandler { // Otel requires SDK 26+, use no-op for older versions if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - com.onesignal.debug.internal.logging.Logging.info("OneSignal: Creating no-op crash handler (SDK ${Build.VERSION.SDK_INT} < 26)") + Logging.info("OneSignal: Creating no-op crash handler (SDK ${Build.VERSION.SDK_INT} < 26)") return NoOpCrashHandler() } - com.onesignal.debug.internal.logging.Logging.info("OneSignal: Creating Otel crash handler (SDK ${Build.VERSION.SDK_INT} >= 26)") - val platformProvider = createAndroidOtelPlatformProvider( - applicationService, - installIdService, - configModelStore, - identityModelStore - ) - val logger = AndroidOtelLogger() - + Logging.info("OneSignal: Creating Otel crash handler (SDK ${Build.VERSION.SDK_INT} >= 26)") + // Create platform provider - uses OtelIdResolver internally + val platformProvider = createAndroidOtelPlatformProvider(context) return OtelFactory.createCrashHandler(platformProvider, logger) } } @@ -51,6 +46,6 @@ internal object OneSignalCrashHandlerFactory { */ private class NoOpCrashHandler : IOtelCrashHandler { override fun initialize() { - com.onesignal.debug.internal.logging.Logging.info("OneSignal: No-op crash handler initialized (SDK < 26, Otel not supported)") + 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 6cea9b9eca..e9d620d09d 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,13 +1,11 @@ package com.onesignal.debug.internal.crash import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService 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 com.onesignal.user.internal.identity.IdentityModelStore import kotlinx.coroutines.runBlocking /** @@ -33,17 +31,11 @@ import kotlinx.coroutines.runBlocking */ internal class OneSignalCrashUploaderWrapper( private val applicationService: IApplicationService, - private val installIdService: IInstallIdService, - private val configModelStore: ConfigModelStore, - private val identityModelStore: IdentityModelStore, ) : IStartableService { private val uploader: OtelCrashUploader by lazy { // Create Android-specific platform provider (injects Android values) - val platformProvider = com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider( - applicationService, - installIdService, - configModelStore, - identityModelStore + val platformProvider = createAndroidOtelPlatformProvider( + applicationService.appContext ) // Create Android-specific logger (delegates to Android Logging) val logger = AndroidOtelLogger() 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 new file mode 100644 index 0000000000..ceeeee52dc --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt @@ -0,0 +1,218 @@ +package com.onesignal.debug.internal.crash + +import android.os.Handler +import android.os.Looper +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +import com.onesignal.otel.OtelFactory +import com.onesignal.otel.crash.IOtelAnrDetector +import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Android-specific implementation of ANR detection. + * + * Uses a watchdog pattern to monitor the main thread: + * - Posts a message to the main thread every check interval + * - If the main thread doesn't respond within the ANR threshold, reports an ANR + * - Captures the main thread's stack trace when ANR is detected + * + * This is a standalone component that can be initialized independently of the crash handler. + * It creates its own crash reporter to save ANR reports. + */ +internal class OtelAnrDetector( + private val openTelemetryCrash: IOtelOpenTelemetryCrash, + private val logger: IOtelLogger, + private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, +) : IOtelAnrDetector { + private val crashReporter: IOtelCrashReporter = OtelFactory.createCrashReporter(openTelemetryCrash, logger) + private val mainHandler = Handler(Looper.getMainLooper()) + private val isMonitoring = AtomicBoolean(false) + private val lastResponseTime = AtomicLong(System.currentTimeMillis()) + private val lastAnrReportTime = AtomicLong(0L) + private var watchdogThread: Thread? = null + private var watchdogRunnable: Runnable? = null + private var mainThreadRunnable: Runnable? = null + + companion object { + private const val TAG = "OtelAnrDetector" + + // Minimum time between ANR reports (to avoid duplicate reports for the same ANR) + private const val MIN_TIME_BETWEEN_ANR_REPORTS_MS = 30_000L // 30 seconds + } + + override fun start() { + if (isMonitoring.getAndSet(true)) { + logger.warn("$TAG: Already monitoring for ANRs, skipping start") + return + } + + logger.info("$TAG: Starting ANR detection (threshold: ${anrThresholdMs}ms, check interval: ${checkIntervalMs}ms)") + + setupRunnables() + startWatchdogThread() + + logger.info("$TAG: ✅ ANR detection started successfully") + } + + private fun setupRunnables() { + // Runnable that runs on the main thread to indicate it's responsive + mainThreadRunnable = Runnable { + lastResponseTime.set(System.currentTimeMillis()) + } + + // Runnable that runs on the watchdog thread to check for ANRs + watchdogRunnable = Runnable { + while (isMonitoring.get()) { + try { + checkForAnr() + } catch (e: InterruptedException) { + // 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 + } + } + } + } + + private fun checkForAnr() { + // Post a message to the main thread + mainHandler.post(mainThreadRunnable!!) + + // Wait for the check interval + Thread.sleep(checkIntervalMs) + + // Check if main thread responded + val timeSinceLastResponse = System.currentTimeMillis() - lastResponseTime.get() + if (timeSinceLastResponse > anrThresholdMs) { + handleAnrDetected(timeSinceLastResponse) + } else { + handleMainThreadResponsive() + } + } + + private fun handleAnrDetected(timeSinceLastResponse: Long) { + // Main thread hasn't responded - ANR detected! + val now = System.currentTimeMillis() + val timeSinceLastReport = now - lastAnrReportTime.get() + + // Only report if enough time has passed since last report (avoid duplicates) + if (timeSinceLastReport > MIN_TIME_BETWEEN_ANR_REPORTS_MS) { + logger.warn("$TAG: ⚠️ ANR detected! Main thread unresponsive for ${timeSinceLastResponse}ms") + lastAnrReportTime.set(now) + reportAnr(timeSinceLastResponse) + } else { + logger.debug("$TAG: ANR still ongoing (${timeSinceLastResponse}ms), but already reported recently (${timeSinceLastReport}ms ago)") + } + } + + private fun handleMainThreadResponsive() { + // Main thread is responsive - reset ANR report time so we can detect new ANRs + if (lastAnrReportTime.get() > 0) { + lastAnrReportTime.set(0L) + logger.debug("$TAG: Main thread recovered, ready to detect new ANRs") + } + } + + private fun startWatchdogThread() { + // Start the watchdog thread + watchdogThread = Thread(watchdogRunnable, "OneSignal-ANR-Watchdog") + watchdogThread?.isDaemon = true + watchdogThread?.start() + } + + override fun stop() { + if (!isMonitoring.getAndSet(false)) { + logger.warn("$TAG: Not monitoring, skipping stop") + return + } + + logger.info("$TAG: Stopping ANR detection...") + + // Interrupt the watchdog thread to stop it + watchdogThread?.interrupt() + watchdogThread = null + watchdogRunnable = null + mainThreadRunnable = null + + logger.info("$TAG: ✅ ANR detection stopped") + } + + private fun reportAnr(unresponsiveDurationMs: Long) { + try { + logger.info("$TAG: Checking if ANR is OneSignal-related (unresponsive for ${unresponsiveDurationMs}ms)") + + // Get the main thread's stack trace + val mainThread = Looper.getMainLooper().thread + val stackTrace = mainThread.stackTrace + + // Only report if OneSignal is at fault (uses centralized utility from otel module) + val isOneSignalAtFault = com.onesignal.otel.crash.isOneSignalAtFault(stackTrace) + + if (!isOneSignalAtFault) { + logger.debug("$TAG: ANR is not OneSignal-related, skipping report") + return + } + + logger.info("$TAG: OneSignal-related ANR detected, reporting...") + + // Create an ANR exception with the stack trace + val anrException = ApplicationNotRespondingException( + "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms", + stackTrace + ) + + // Report it as a crash (but mark it as ANR) + runBlocking { + crashReporter.saveCrash(mainThread, anrException) + } + + logger.info("$TAG: ✅ ANR report saved successfully") + } catch (e: RuntimeException) { + logger.error("$TAG: Failed to report ANR: ${e.message} - ${e.javaClass.simpleName}: ${e.stackTraceToString()}") + } + } + + /** + * Custom exception type for ANRs. + * This allows us to distinguish ANRs from regular crashes in the crash reporting system. + */ + private class ApplicationNotRespondingException( + message: String, + stackTrace: Array + ) : RuntimeException(message) { + init { + this.stackTrace = stackTrace + } + } +} + +// Use the centralized isOneSignalAtFault from otel module + +/** + * Factory function to create an ANR detector for Android. + * This is in the core module since it needs to access Android-specific classes. + */ + +internal fun createAnrDetector( + platformProvider: com.onesignal.otel.IOtelPlatformProvider, + logger: IOtelLogger, + anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, +): IOtelAnrDetector { + // Use the factory to create crash local instance (keeps implementation details internal) + val crashLocal = OtelFactory.createCrashLocalTelemetry(platformProvider) + + return OtelAnrDetector( + crashLocal, + logger, + anrThresholdMs, + checkIntervalMs + ) +} 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 new file mode 100644 index 0000000000..480f36d082 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt @@ -0,0 +1,233 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.content.Context +import com.onesignal.common.IDManager +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.user.internal.backend.IdentityConstants +import org.json.JSONArray +import org.json.JSONObject + +/** + * Resolves OneSignal IDs from SharedPreferences with fallback strategies. + * This class encapsulates all the logic for reading IDs from ConfigModelStore and legacy SharedPreferences, + * making it easier to maintain and test. + * + * Note: Data is read fresh from SharedPreferences each time (not cached) to ensure test reliability + * and correctness. The performance impact is minimal since these methods are not called frequently. + */ +@Suppress("TooManyFunctions") // This class intentionally groups related ID resolution functions +internal class OtelIdResolver( + private val context: Context?, +) { + companion object { + /** + * Default error appId prefix when appId cannot be resolved. + */ + private const val ERROR_APP_ID_PREFIX = "8123-1231-4343-2323-error-" + } + + // Get SharedPreferences instance (fresh each time to avoid caching issues in tests) + private fun getSharedPreferences(): android.content.SharedPreferences? { + return context?.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + + // Read ConfigModelStore JSON (fresh read each time for testability) + // In production, this is called multiple times per resolver instance, but the performance impact is minimal + // and this ensures test reliability + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun readConfigModel(): JSONObject? { + return try { + val configStoreJson = getSharedPreferences()?.getString( + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + com.onesignal.core.internal.config.CONFIG_NAME_SPACE, + null + ) + + if (configStoreJson != null && configStoreJson.isNotEmpty()) { + val jsonArray = JSONArray(configStoreJson) + if (jsonArray.length() > 0) { + jsonArray.getJSONObject(0) + } else { + null + } + } else { + null + } + } catch (e: Exception) { + null + } + } + + // Check if ConfigModelStore exists but is empty (to distinguish from "not found") + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun hasEmptyConfigStore(): Boolean { + return try { + val configStoreJson = getSharedPreferences()?.getString( + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + com.onesignal.core.internal.config.CONFIG_NAME_SPACE, + null + ) + if (configStoreJson != null && configStoreJson.isNotEmpty()) { + val jsonArray = JSONArray(configStoreJson) + jsonArray.length() == 0 + } else { + false + } + } catch (e: Exception) { + false + } + } + + /** + * Resolves appId with the following fallback chain: + * 1. Try ConfigModelStore in SharedPreferences (MODEL_STORE_config) + * 2. Try legacy OneSignal SharedPreferences + * 3. Return error appId with affix if all fail + */ + @Suppress("TooGenericExceptionCaught") + fun resolveAppId(): String { + return try { + val configModel = readConfigModel() + val appIdFromConfig = extractAppIdFromConfig(configModel) + appIdFromConfig ?: resolveAppIdFromLegacy(configModel) + } catch (e: Exception) { + "$ERROR_APP_ID_PREFIX${e.javaClass.simpleName}" + } + } + + private fun extractAppIdFromConfig(configModel: JSONObject?): String? { + if (configModel == null) return null + val appIdProperty = ConfigModel::appId + return if (configModel.has(appIdProperty.name)) { + val appId = configModel.getString(appIdProperty.name) + appId.ifEmpty { null } + } else { + null + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException", "NestedBlockDepth") + private fun resolveAppIdFromLegacy(configModel: JSONObject?): String { + // Second: fall back to legacy OneSignal SharedPreferences + val legacyAppId = try { + getSharedPreferences()?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) + ?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + + return legacyAppId ?: run { + // Third: return error appId with affix + val errorAffix = when { + context == null -> "no-context" + hasEmptyConfigStore() -> "no-appid-in-config" // Store exists but is empty array + configModel == null -> "config-store-not-found" // Store doesn't exist + !configModel.has("appId") -> "no-appid-in-config" // Store exists but no appId field + else -> "unknown" + } + "$ERROR_APP_ID_PREFIX$errorAffix" + } + } + + /** + * Resolves onesignalId with the following fallback chain: + * 1. Try IdentityModelStore in SharedPreferences (MODEL_STORE_identity) + * 2. Return null if all fail + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException", "NestedBlockDepth") + fun resolveOnesignalId(): String? { + return try { + val identityStoreJson = getSharedPreferences()?.getString( + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE, + null + ) + + if (identityStoreJson != null && identityStoreJson.isNotEmpty()) { + extractOnesignalIdFromJson(identityStoreJson) + } else { + null + } + } catch (e: Exception) { + null + } + } + + private fun extractOnesignalIdFromJson(identityStoreJson: String): String? { + val jsonArray = JSONArray(identityStoreJson) + if (jsonArray.length() > 0) { + val identityModel = jsonArray.getJSONObject(0) + if (identityModel.has(IdentityConstants.ONESIGNAL_ID)) { + val onesignalId = identityModel.getString(IdentityConstants.ONESIGNAL_ID) + return onesignalId.takeIf { it.isNotEmpty() && !IDManager.isLocalId(it) } + } + } + return null + } + + /** + * Resolves pushSubscriptionId from cached ConfigModelStore. + * Returns null if not found or if it's a local ID. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun resolvePushSubscriptionId(): String? { + return try { + val configModel = readConfigModel() + val pushSubscriptionIdProperty = ConfigModel::pushSubscriptionId + if (configModel != null && configModel.has(pushSubscriptionIdProperty.name)) { + val pushSubscriptionId = configModel.getString(pushSubscriptionIdProperty.name) + pushSubscriptionId.takeIf { it.isNotEmpty() && !IDManager.isLocalId(pushSubscriptionId) } + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Resolves remote log level from cached ConfigModelStore. + * Returns null if not found or if there's an error. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException", "NestedBlockDepth") + fun resolveRemoteLogLevel(): com.onesignal.debug.LogLevel? { + return try { + val configModel = readConfigModel() + val remoteLoggingParamsProperty = ConfigModel::remoteLoggingParams + if (configModel != null && configModel.has(remoteLoggingParamsProperty.name)) { + extractLogLevelFromParams(configModel.getJSONObject(remoteLoggingParamsProperty.name)) + } else { + null + } + } catch (e: Exception) { + null + } + } + + @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 + } + } + + /** + * Resolves install ID from SharedPreferences. + * Returns "InstallId-Null" if not found, "InstallId-NotFound" if there's an error. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun resolveInstallId(): String { + return try { + val installIdString = getSharedPreferences()?.getString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "InstallId-Null") + installIdString ?: "InstallId-Null" + } catch (e: Exception) { + "InstallId-NotFound" + } + } +} 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 cbfe1f1664..84b48ecfcf 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,61 +3,41 @@ package com.onesignal.debug.internal.logging.otel.android import android.app.ActivityManager import android.content.Context import android.os.Build -import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils import com.onesignal.common.OneSignalWrapper -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys -import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging import com.onesignal.otel.IOtelPlatformProvider /** * Configuration for AndroidOtelPlatformProvider. - * Groups parameters to avoid LongParameterList detekt issue. */ internal data class OtelPlatformProviderConfig( val crashStoragePath: String, val appPackageId: String, val appVersion: String, - val getInstallIdProvider: suspend () -> String, val context: Context? = null, - val getAppId: (() -> String?)? = null, - val getOnesignalId: (() -> String?)? = null, - val getPushSubscriptionId: (() -> String?)? = null, val getIsInForeground: (() -> Boolean?)? = null, - val getRemoteLogLevel: (() -> com.onesignal.debug.LogLevel?)? = null, ) /** * Android-specific implementation of IOtelPlatformProvider. - * This provider can work with or without full service dependencies, making it flexible for both - * early crash handler initialization and full remote logging scenarios. + * Reads all values directly from SharedPreferences and system services. + * No SDK service dependencies required. * - * If Context is provided, it will attempt to retrieve additional metadata from SharedPreferences - * and system services, falling back to null/defaults if unavailable. - * - * Optional service getters can be provided to retrieve values from services if available: - * - getAppId: () -> String? - Get appId from ConfigModelStore if available - * - getOnesignalId: () -> String? - Get onesignalId from IdentityModelStore if available - * - getPushSubscriptionId: () -> String? - Get pushSubscriptionId from ConfigModelStore if available - * - getIsInForeground: () -> Boolean? - Get foreground state from ApplicationService if available - * - getRemoteLogLevel: () -> LogLevel? - Get remote logging level from ConfigModelStore if available + * All IDs (appId, onesignalId, pushSubscriptionId) are resolved from SharedPreferences via OtelIdResolver. + * Remote log level defaults to ERROR if not found in config. */ internal class OtelPlatformProvider( config: OtelPlatformProviderConfig, ) : IOtelPlatformProvider { override val appPackageId: String = config.appPackageId override val appVersion: String = config.appVersion - private val getInstallIdProvider: suspend () -> String = config.getInstallIdProvider private val context: Context? = config.context - private val getAppId: (() -> String?)? = config.getAppId - private val getOnesignalId: (() -> String?)? = config.getOnesignalId - private val getPushSubscriptionId: (() -> String?)? = config.getPushSubscriptionId private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground - private val getRemoteLogLevel: (() -> com.onesignal.debug.LogLevel?)? = config.getRemoteLogLevel + private val idResolver = OtelIdResolver(context) // Top-level attributes (static, calculated once) - override suspend fun getInstallId(): String = getInstallIdProvider() + override suspend fun getInstallId(): String = idResolver.resolveInstallId() override val sdkBase: String = "android" @@ -77,38 +57,18 @@ internal class OtelPlatformProvider( override val sdkWrapperVersion: String? = OneSignalWrapper.sdkVersion - // Per-event attributes (dynamic, calculated per event) - // The getAppId lambda already contains all fallback logic (service -> app SharedPreferences -> legacy -> "unknown") - // So we just invoke it here without duplicating the logic - override val appId: String? - @Suppress("TooGenericExceptionCaught", "SwallowedException") - get() = try { - getAppId?.invoke() - } catch (e: Exception) { - null - } + // Per-event attributes - IDs are cached (calculated once), appState is dynamic (calculated per access) + override val appId: String? by lazy { + idResolver.resolveAppId() + } - override val onesignalId: String? - @Suppress("TooGenericExceptionCaught", "SwallowedException") - get() = try { - // First try to get from service if available - getOnesignalId?.invoke()?.takeIf { !IDManager.isLocalId(it) } ?: run { - // Fall back to SharedPreferences and pick up at least Legacy Id if it exists - context?.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, null) - ?.takeIf { !IDManager.isLocalId(it) } - } - } catch (e: Exception) { - null - } + override val onesignalId: String? by lazy { + idResolver.resolveOnesignalId() + } - override val pushSubscriptionId: String? - @Suppress("TooGenericExceptionCaught", "SwallowedException") - get() = try { - getPushSubscriptionId?.invoke()?.takeIf { !IDManager.isLocalId(it) } - } catch (e: Exception) { - null - } + override val pushSubscriptionId: String? by lazy { + idResolver.resolvePushSubscriptionId() + } // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ override val appState: String @@ -164,47 +124,39 @@ internal class OtelPlatformProvider( /** * 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" (bare minimum for client-side) + * - 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) */ - override val remoteLogLevel: String? - @Suppress("TooGenericExceptionCaught", "SwallowedException") - get() = try { - val configLevel = getRemoteLogLevel?.invoke() + @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 as bare minimum + // Remote config not available - default to ERROR (always log errors) else -> "ERROR" } } catch (e: Exception) { - // If there's an error accessing config, default to ERROR as bare minimum + // If there's an error accessing config, default to ERROR (always log errors) + // Exception is intentionally swallowed to avoid recursion in logging "ERROR" } + } override val appIdForHeaders: String - @Suppress("TooGenericExceptionCaught", "SwallowedException") - get() = try { - // Try to get appId for headers (same logic as appId property) - appId ?: "" - } catch (e: Exception) { - "" - } + get() = appId ?: "" } /** - * Factory function to create AndroidOtelPlatformProvider with full service dependencies. - * This is a convenience function that creates the provider with service getters. + * Factory function to create AndroidOtelPlatformProvider without service dependencies. + * Reads all values directly from SharedPreferences and system services. */ internal fun createAndroidOtelPlatformProvider( - applicationService: com.onesignal.core.internal.application.IApplicationService, - installIdService: com.onesignal.core.internal.device.IInstallIdService, - configModelStore: com.onesignal.core.internal.config.ConfigModelStore, - identityModelStore: com.onesignal.user.internal.identity.IdentityModelStore, + context: Context, ): OtelPlatformProvider { - val context = applicationService.appContext val crashStoragePath = context.cacheDir.path + java.io.File.separator + "onesignal" + java.io.File.separator + "otel" + java.io.File.separator + @@ -215,43 +167,7 @@ internal fun createAndroidOtelPlatformProvider( crashStoragePath = crashStoragePath, appPackageId = context.packageName, appVersion = com.onesignal.common.AndroidUtils.getAppVersion(context) ?: "unknown", - getInstallIdProvider = { installIdService.getId().toString() }, context = context, - getAppId = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - configModelStore.model.appId - } catch (e: Exception) { - null - } - }, - getOnesignalId = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - val onesignalId = identityModelStore.model.onesignalId - onesignalId.takeIf { !IDManager.isLocalId(it) } - } catch (e: Exception) { - null - } - }, - getPushSubscriptionId = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - val pushSubscriptionId = configModelStore.model.pushSubscriptionId - pushSubscriptionId?.takeIf { !IDManager.isLocalId(pushSubscriptionId) } - } catch (e: Exception) { - null - } - }, - getIsInForeground = { applicationService.isInForeground }, - getRemoteLogLevel = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - configModelStore.model.remoteLoggingParams.logLevel - } catch (e: Exception) { - null - } - }, ) ) } 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 19dfb827df..d69b25fd2f 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 @@ -1,180 +1,38 @@ package com.onesignal.internal import android.content.Context -import com.onesignal.common.AndroidUtils -import com.onesignal.common.IDManager -import com.onesignal.common.services.IServiceProvider import com.onesignal.common.threading.suspendifyOnIO -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys -import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.crash.createAnrDetector import com.onesignal.debug.internal.logging.Logging import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider -import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProviderConfig 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.user.internal.backend.IdentityConstants -import com.onesignal.user.internal.identity.IdentityModelStore -import org.json.JSONArray +import com.onesignal.otel.crash.IOtelAnrDetector /** - * Helper object for OneSignal initialization tasks. + * Helper class for OneSignal initialization tasks. * Extracted from OneSignalImp to reduce class size and improve maintainability. + * + * Creates and reuses a single OtelPlatformProvider instance for both crash handler and logging. */ -internal object OneSignalCrashLogInit { +internal class OneSignalCrashLogInit( + private val context: Context, +) { + // Platform provider - created once and reused for both crash handler and logging + private val platformProvider: OtelPlatformProvider by lazy { + createAndroidOtelPlatformProvider(context) + } + @Suppress("TooGenericExceptionCaught") - fun initializeCrashHandler( - context: Context, - services: IServiceProvider, - ) { + fun initializeCrashHandler() { try { Logging.info("OneSignal: Initializing crash handler early...") - // Use minimal dependencies - only primitive values and installId provider - // This makes crash handler completely independent of service architecture - // Context is passed directly from initEssentials, not through ApplicationService - val crashStoragePath = context.cacheDir.path + java.io.File.separator + - "onesignal" + java.io.File.separator + - "otel" + java.io.File.separator + - "crashes" - val appPackageId = context.packageName - val appVersion = try { - AndroidUtils.getAppVersion(context) ?: "unknown" - } catch (e: Exception) { - Logging.warn("OneSignal: Failed to get app version for crash handler: ${e.message}, using 'unknown'") - "unknown" - } - Logging.info("OneSignal: Creating crash handler with minimal dependencies...") - // Initialize crash handler immediately with minimal setup (non-blocking) - // Service getters check services dynamically when called - if services are available - // at crash time, they'll be used; otherwise falls back to SharedPreferences - // This ensures fast initialization while still using services when available - - // Helper to get ConfigModelStore (used by multiple getters, but called lazily at crash time) - val getConfigModelStore: () -> ConfigModelStore? = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - services.getServiceOrNull(ConfigModelStore::class.java) - } catch (e: Exception) { - null - } - } - - // Create platform provider with lambda getters that check services dynamically - // If services are available at crash time, use them; otherwise fall back to SharedPreferences - val platformProvider = OtelPlatformProvider( - OtelPlatformProviderConfig( - crashStoragePath = crashStoragePath, - appPackageId = appPackageId, - appVersion = appVersion, - getInstallIdProvider = { - @Suppress("TooGenericExceptionCaught") - try { - val installIdService = services.getServiceOrNull(IInstallIdService::class.java) - installIdService?.getId()?.toString() ?: "" - } catch (e: Exception) { - Logging.warn("OneSignal: Failed to get installId for crash handler: ${e.message}, using empty string") - "" - } - }, - context = context, - // Dynamic service getters - check services when called (at crash time) - // If services are available, use them; otherwise fall back to SharedPreferences - getAppId = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - // First: try to get from service (ConfigModelStore) if available - getConfigModelStore()?.model?.appId ?: run { - // Second: try to get from app's SharedPreferences (like SharedPreferenceUtil.getOneSignalAppId) - // This reads from the app's own SharedPreferences where the app ID might be cached - try { - val appPrefs = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) - appPrefs.getString("OS_APP_ID_SHARED_PREF", null)?.takeIf { it.isNotEmpty() } - } catch (e: Exception) { - null - } - } ?: run { - // Third: fall back to legacy OneSignal SharedPreferences - context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) - ?.takeIf { it.isNotEmpty() } - } ?: "unknown" // Fourth: if all checks are empty, return "unknown" - } catch (e: Exception) { - "unknown" // If there's an error, return "unknown" - } - }, - getOnesignalId = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - val identityModelStore = services.getServiceOrNull(IdentityModelStore::class.java) - val onesignalId = identityModelStore?.model?.onesignalId - onesignalId?.takeIf { !IDManager.isLocalId(it) } ?: run { - // Fall back to SharedPreferences - try MODEL_STORE_identity first, then legacy - val prefs = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - val identityStoreJson = prefs?.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "identity", null) - if (identityStoreJson != null) { - @Suppress("SwallowedException") - try { - val jsonArray = JSONArray(identityStoreJson) - if (jsonArray.length() > 0) { - val identityModelJson = jsonArray.getJSONObject(0) - val onesignalIdFromPrefs = identityModelJson.optString(IdentityConstants.ONESIGNAL_ID, null) - onesignalIdFromPrefs?.takeIf { it.isNotEmpty() && !IDManager.isLocalId(it) } - } else { - null - } - } catch (e: Exception) { - null - } - } else { - null - } - } ?: run { - // Final fallback to legacy player ID - context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - ?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_PLAYER_ID, null) - ?.takeIf { !IDManager.isLocalId(it) } - } - } catch (e: Exception) { - null - } - }, - getPushSubscriptionId = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - val pushSubscriptionId = getConfigModelStore()?.model?.pushSubscriptionId - pushSubscriptionId?.takeIf { !IDManager.isLocalId(pushSubscriptionId) } - } catch (e: Exception) { - null - } - }, - getIsInForeground = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - val applicationService = services.getServiceOrNull(IApplicationService::class.java) - applicationService?.isInForeground - } catch (e: Exception) { - null - } - }, - getRemoteLogLevel = { - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - getConfigModelStore()?.model?.remoteLoggingParams?.logLevel - } catch (e: Exception) { - null - } - }, - ) - ) - // Create crash handler directly (non-blocking, doesn't require services upfront) val logger = AndroidOtelLogger() val crashHandler: IOtelCrashHandler = OtelFactory.createCrashHandler(platformProvider, logger) @@ -184,8 +42,24 @@ internal object OneSignalCrashLogInit { // 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: $crashStoragePath") - Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as ${context.packageName} ls -la $crashStoragePath") + Logging.info("OneSignal: 📁 Crash logs will be stored at: ${platformProvider.crashStoragePath}") + Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as ${platformProvider.appPackageId} ls -la ${platformProvider.crashStoragePath}") + + // Initialize ANR detector (standalone, monitors main thread for ANRs) + try { + Logging.info("OneSignal: Initializing ANR detector...") + val anrDetector: IOtelAnrDetector = createAnrDetector( + platformProvider, + logger, + anrThresholdMs = com.onesignal.debug.internal.crash.AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + checkIntervalMs = com.onesignal.debug.internal.crash.AnrConstants.DEFAULT_CHECK_INTERVAL_MS + ) + anrDetector.start() + Logging.info("OneSignal: ✅ ANR detector initialized and started") + } catch (e: Exception) { + // If ANR detector initialization fails, log it but don't crash + Logging.error("OneSignal: Failed to initialize ANR detector: ${e.message}", e) + } } catch (e: Exception) { // If crash handler initialization fails, log it but don't crash Logging.error("OneSignal: Failed to initialize crash handler: ${e.message}", e) @@ -193,46 +67,28 @@ internal object OneSignalCrashLogInit { } @Suppress("TooGenericExceptionCaught") - fun initializeOtelLogging( - applicationService: IApplicationService, - services: IServiceProvider, - ) { + fun initializeOtelLogging() { // Initialize Otel logging asynchronously to avoid blocking initialization // Remote logging is not critical for crashes, so it's safe to do this in the background + // Uses OtelIdResolver internally which reads directly from SharedPreferences + // No service dependencies required - fully decoupled from service architecture suspendifyOnIO { try { - // Get dependencies needed for Otel logging (non-blocking, uses getServiceOrNull) - val installIdService = services.getServiceOrNull(IInstallIdService::class.java) - val configModelStore = services.getServiceOrNull(ConfigModelStore::class.java) - val identityModelStore = services.getServiceOrNull(IdentityModelStore::class.java) - - // If services aren't available yet, skip initialization (will be retried later if needed) - if (installIdService == null || configModelStore == null || identityModelStore == null) { - Logging.debug("OneSignal: Services not yet available for Otel logging, skipping initialization") - return@suspendifyOnIO - } - - val platformProvider = createAndroidOtelPlatformProvider( - applicationService, - installIdService, - configModelStore, - identityModelStore - ) - + // 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) - val isRemoteLoggingEnabled = remoteLogLevelStr != null && remoteLogLevelStr != "NONE" - - if (isRemoteLoggingEnabled) { - Logging.info("OneSignal: Remote logging enabled at level $remoteLogLevelStr, initializing Otel logging integration...") + 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 = try { - LogLevel.valueOf(remoteLogLevelStr) + val remoteLogLevel: LogLevel = try { + LogLevel.valueOf(logLevelStr) } catch (e: Exception) { LogLevel.ERROR // Default to ERROR on parse error } @@ -250,7 +106,7 @@ internal object OneSignalCrashLogInit { // Inject Otel telemetry into Logging class Logging.setOtelTelemetry(remoteTelemetry, shouldSendLogLevel) - Logging.info("OneSignal: ✅ Otel logging integration initialized - logs at level $remoteLogLevelStr and above will be sent to remote server") + 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") } 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 e1e75cb24a..4de2a4a73b 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,13 +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) + // 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. - // Pass Context directly instead of going through ApplicationService - OneSignalCrashLogInit.initializeCrashHandler(context, services) + crashLogInit.initializeCrashHandler() + + // Initialize Otel logging integration - reuses the same platform provider created in initializeCrashHandler + // No service dependencies required, reads directly from SharedPreferences + crashLogInit.initializeOtelLogging() PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) @@ -225,9 +232,6 @@ internal class OneSignalImp( // Give the logging singleton access to the application service to support visual logging. Logging.applicationService = applicationService - - // Initialize Otel logging integration after services are available - OneSignalCrashLogInit.initializeOtelLogging(applicationService, services) } private fun updateConfig() { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt index 9a9355647a..6e9d535611 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt @@ -5,8 +5,10 @@ import com.onesignal.common.modeling.SingletonModelStore import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.user.internal.backend.IdentityConstants +const val IDENTITY_NAME_SPACE = "identity" + open class IdentityModelStore(prefs: IPreferencesService) : SingletonModelStore( - SimpleModelStore({ IdentityModel() }, "identity", prefs), + SimpleModelStore({ IdentityModel() }, IDENTITY_NAME_SPACE, prefs), ) /** diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/CrashReportUploadTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/CrashReportUploadTest.kt new file mode 100644 index 0000000000..9327dd9f7b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/CrashReportUploadTest.kt @@ -0,0 +1,336 @@ +package com.onesignal.debug.internal.crash + +import android.content.Context +import android.content.SharedPreferences +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.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.OtelFactory +import com.onesignal.otel.OtelLoggingHelper +import com.onesignal.user.internal.backend.IdentityConstants +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +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 java.util.UUID +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +/** + * Integration test that uploads a sample crash report to the OneSignal API. + * + * This test sends a real HTTP request to the API endpoint configured in OtelConfigRemoteOneSignal. + * + * To use this test: + * 1. Set a valid app ID in the test (replace "YOUR_APP_ID_HERE") + * 2. Ensure the API endpoint is accessible (check OtelConfigRemoteOneSignal.BASE_URL) + * 3. Run the test and verify the crash report appears in your backend + * + * Note: This test requires network access and will make a real HTTP request. + * + * Android Studio Note: If tests fail in Android Studio but work on command line: + * - File → Invalidate Caches → Invalidate and Restart + * - File → Sync Project with Gradle Files + * - Ensure you're running as "Unit Test" (not "Instrumented Test") + * - Try running from command line: ./gradlew :onesignal:core:testDebugUnitTest --tests "CrashReportUploadTest" + */ +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class CrashReportUploadTest : FunSpec({ + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + // TODO: Replace with your actual app ID for testing + val testAppId = "YOUR_APP_ID_HERE" + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + } + + beforeSpec { + // Enable debug logging to see what's being sent + Logging.logLevel = LogLevel.DEBUG + Logging.info("🔍 Debug logging enabled for CrashReportUploadTest") + println("🔍 Debug logging enabled") + } + + beforeEach { + // Ensure sharedPreferences is initialized + if (sharedPreferences == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // Clear and set up SharedPreferences with test data + sharedPreferences!!.edit().clear().commit() + + // Set up ConfigModelStore data + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, testAppId) + put(ConfigModel::pushSubscriptionId.name, "test-subscription-id-${UUID.randomUUID()}") + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "ERROR") + } + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + + // Set up IdentityModelStore data + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id-${UUID.randomUUID()}") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, UUID.randomUUID().toString()) + .commit() + } + + afterEach { + sharedPreferences!!.edit().clear().commit() + } + + test("should upload sample crash report to API") { + // Skip if app ID is not configured + if (testAppId == "YOUR_APP_ID_HERE") { + println("\n⚠️ Skipping test: Please set testAppId to a valid app ID") + println(" To run this test, edit the test file and set testAppId to your OneSignal App ID") + return@test + } + + runBlocking { + // Create platform provider with test data from SharedPreferences + val platformProvider = createAndroidOtelPlatformProvider(appContext!!) + + // Verify app ID is set correctly + platformProvider.appId shouldBe testAppId + platformProvider.appIdForHeaders shouldBe testAppId + + // Log platform provider details + val platformDetails = """ + |📋 Platform Provider Details: + | App ID: ${platformProvider.appId} + | App ID for Headers: ${platformProvider.appIdForHeaders} + | SDK Base: ${platformProvider.sdkBase} + | SDK Version: ${platformProvider.sdkBaseVersion} + | App Package: ${platformProvider.appPackageId} + | App Version: ${platformProvider.appVersion} + | Device: ${platformProvider.deviceManufacturer} ${platformProvider.deviceModel} + | OS: ${platformProvider.osName} ${platformProvider.osVersion} + | OneSignal ID: ${platformProvider.onesignalId} + | Push Subscription ID: ${platformProvider.pushSubscriptionId} + | App State: ${platformProvider.appState} + | Remote Log Level: ${platformProvider.remoteLogLevel} + | Install ID: ${runBlocking { platformProvider.getInstallId() }} + """.trimMargin() + println(platformDetails) + Logging.info(platformDetails) + + // Create remote telemetry instance + println("\n🔧 Creating remote telemetry instance...") + val remoteTelemetry: IOtelOpenTelemetryRemote = OtelFactory.createRemoteTelemetry(platformProvider) + remoteTelemetry.shouldBeInstanceOf() + println(" ✅ Remote telemetry created") + + // Create a sample crash report + val sampleException = RuntimeException("Test crash report from integration test") + sampleException.stackTrace = arrayOf( + StackTraceElement("TestClass", "testMethod", "TestFile.kt", 42), + StackTraceElement("TestClass", "anotherMethod", "TestFile.kt", 30), + StackTraceElement("Main", "main", "Main.kt", 10) + ) + + val crashReportInfo = """ + |📤 Uploading crash report to API... + | App ID: ${platformProvider.appId} + | Exception Type: ${sampleException.javaClass.name} + | Exception Message: ${sampleException.message} + | Stack Trace Length: ${sampleException.stackTraceToString().length} chars + | + |📦 Crash Report Payload: + | Level: FATAL + | Message: Sample crash report for API testing + | Exception Type: ${sampleException.javaClass.name} + | Exception Message: ${sampleException.message} + | Stack Trace Preview: ${sampleException.stackTraceToString().take(200)}... + """.trimMargin() + println(crashReportInfo) + Logging.info(crashReportInfo) + + // Use OtelLoggingHelper to send the crash report (this handles all OpenTelemetry internals) + println("\n🚀 Calling OtelLoggingHelper.logToOtel()...") + Logging.info("🚀 Calling OtelLoggingHelper.logToOtel()...") + try { + OtelLoggingHelper.logToOtel( + telemetry = remoteTelemetry, + level = "FATAL", + message = "Sample crash report for API testing", + exceptionType = sampleException.javaClass.name, + exceptionMessage = sampleException.message, + exceptionStacktrace = sampleException.stackTraceToString() + ) + val successMsg = " ✅ logToOtel() completed successfully" + println(successMsg) + Logging.info(successMsg) + } catch (e: Exception) { + val errorMsg = " ❌ Error calling logToOtel(): ${e.message}" + println(errorMsg) + Logging.error(errorMsg, e) + e.printStackTrace() + throw e + } + + // Note: forceFlush() returns CompletableResultCode which is not accessible from core module + // OpenTelemetry will automatically batch and send the logs, so we just wait a bit + println("\n🔄 Waiting for telemetry to be sent (OpenTelemetry batches automatically)...") + println(" Batch delay: 1 second (configured in OtelConfigShared)") + println(" Waiting 5 seconds to ensure batch is sent...") + for (i in 1..5) { + delay(1000) + println(" ⏳ Waited $i second(s)...") + } + + // Note: CompletableResultCode is not directly accessible from core module + // We just wait and assume success if no exception was thrown + println("\n✅ Crash report upload process completed!") + println(" Check your backend dashboard to verify the crash report was received") + println(" Note: OpenTelemetry batches requests, so it may take a moment to appear") + println(" Expected endpoint: https://api.staging.onesignal.com/sdk/otel/v1/logs?app_id=${platformProvider.appId}") + } + } + + test("should upload crash report using OtelLoggingHelper") { + // Skip if app ID is not configured + if (testAppId == "YOUR_APP_ID_HERE") { + println("⚠️ Skipping test: Please set testAppId to a valid app ID") + return@test + } + + runBlocking { + // Create platform provider + val platformProvider = createAndroidOtelPlatformProvider(appContext!!) + + // Create remote telemetry + val remoteTelemetry: IOtelOpenTelemetryRemote = OtelFactory.createRemoteTelemetry(platformProvider) + + // Create sample exception + val sampleException = IllegalStateException("Test exception from OtelLoggingHelper test") + + println("📤 Uploading crash report via OtelLoggingHelper...") + println(" App ID: ${platformProvider.appId}") + + // Use OtelLoggingHelper to send the crash report + OtelLoggingHelper.logToOtel( + telemetry = remoteTelemetry, + level = "FATAL", + message = "Sample crash report via OtelLoggingHelper", + exceptionType = sampleException.javaClass.name, + exceptionMessage = sampleException.message, + exceptionStacktrace = sampleException.stackTraceToString() + ) + + // Note: forceFlush() returns CompletableResultCode which is not accessible from core module + // OpenTelemetry will automatically batch and send the logs, so we just wait a bit + println("🔄 Waiting for telemetry to be sent (OpenTelemetry batches automatically)...") + delay(3000) // Wait 3 seconds for automatic batching to send + + println("✅ Crash report sent via OtelLoggingHelper!") + println(" Check your backend dashboard to verify the crash report was received") + println(" Note: OpenTelemetry batches requests, so it may take a moment to appear") + } + } + + test("should verify platform provider has all required fields for crash report") { + println("\n🔍 Testing Platform Provider Configuration...") + + val platformProvider = createAndroidOtelPlatformProvider(appContext!!) + + println("\n📋 Platform Provider Fields:") + println(" App ID: ${platformProvider.appId}") + println(" App ID for Headers: ${platformProvider.appIdForHeaders}") + println(" SDK Base: ${platformProvider.sdkBase}") + println(" SDK Version: ${platformProvider.sdkBaseVersion}") + println(" App Package: ${platformProvider.appPackageId}") + println(" App Version: ${platformProvider.appVersion}") + println(" Device: ${platformProvider.deviceManufacturer} ${platformProvider.deviceModel}") + println(" OS: ${platformProvider.osName} ${platformProvider.osVersion} (Build: ${platformProvider.osBuildId})") + println(" OneSignal ID: ${platformProvider.onesignalId}") + println(" Push Subscription ID: ${platformProvider.pushSubscriptionId}") + println(" App State: ${platformProvider.appState}") + println(" Process Uptime: ${platformProvider.processUptime}s") + println(" Thread Name: ${platformProvider.currentThreadName}") + println(" Remote Log Level: ${platformProvider.remoteLogLevel}") + + runBlocking { + val installId = platformProvider.getInstallId() + println(" Install ID: $installId") + installId shouldNotBe null + installId.isNotEmpty() shouldBe true + } + + // Verify all required fields are present + platformProvider.appId shouldNotBe null + platformProvider.appIdForHeaders shouldNotBe null + platformProvider.sdkBase shouldBe "android" + platformProvider.sdkBaseVersion shouldNotBe null + platformProvider.appPackageId shouldBe appContext!!.packageName // Use actual package name from context + platformProvider.appVersion shouldNotBe null + platformProvider.deviceManufacturer shouldNotBe null + platformProvider.deviceModel shouldNotBe null + platformProvider.osName shouldBe "Android" + platformProvider.osVersion shouldNotBe null + platformProvider.osBuildId shouldNotBe null + + println("\n✅ All platform provider fields verified!") + + // Show what would be sent in a crash report + println("\n📦 Sample Crash Report Attributes (what would be sent):") + println(" Top-Level (Resource):") + println(" - service.name: OneSignalDeviceSDK") + println(" - ossdk.install_id: ${runBlocking { platformProvider.getInstallId() }}") + println(" - ossdk.sdk_base: ${platformProvider.sdkBase}") + println(" - ossdk.sdk_base_version: ${platformProvider.sdkBaseVersion}") + println(" - ossdk.app_package_id: ${platformProvider.appPackageId}") + println(" - ossdk.app_version: ${platformProvider.appVersion}") + println(" - device.manufacturer: ${platformProvider.deviceManufacturer}") + println(" - device.model.identifier: ${platformProvider.deviceModel}") + println(" - os.name: ${platformProvider.osName}") + println(" - os.version: ${platformProvider.osVersion}") + println(" - os.build_id: ${platformProvider.osBuildId}") + println(" Per-Event:") + println(" - log.record.uid: ") + println(" - ossdk.app_id: ${platformProvider.appId}") + println(" - ossdk.onesignal_id: ${platformProvider.onesignalId}") + println(" - ossdk.push_subscription_id: ${platformProvider.pushSubscriptionId}") + println(" - app.state: ${platformProvider.appState}") + println(" - process.uptime: ${platformProvider.processUptime}") + println(" - thread.name: ${platformProvider.currentThreadName}") + println(" Log-Specific:") + println(" - log.message: ") + println(" - log.level: FATAL") + println(" - exception.type: ") + println(" - exception.message: ") + println(" - exception.stacktrace: ") + println("\n Expected Endpoint: https://api.staging.onesignal.com/sdk/otel/v1/logs?app_id=${platformProvider.appId}") + } +}) 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 e99148b1f9..3b71b4d05b 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 @@ -1,27 +1,30 @@ package com.onesignal.debug.internal.crash -import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService +import android.content.Context +import androidx.test.core.app.ApplicationProvider +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.user.internal.identity.IdentityModelStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.mockk +@RobolectricTest class OneSignalCrashHandlerFactoryTest : FunSpec({ - val mockApplicationService = mockk(relaxed = true) - val mockInstallIdService = mockk(relaxed = true) - val mockConfigModelStore = mockk(relaxed = true) - val mockIdentityModelStore = mockk(relaxed = true) + var appContext: Context? = null + var logger: AndroidOtelLogger? = null + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + logger = AndroidOtelLogger() + } + } test("createCrashHandler should return IOtelCrashHandler") { val handler = OneSignalCrashHandlerFactory.createCrashHandler( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore + appContext!!, + logger!! ) handler.shouldBeInstanceOf() @@ -31,10 +34,8 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ // Note: SDK version check is handled at runtime by the factory // This test verifies the handler can be created and initialized val handler = OneSignalCrashHandlerFactory.createCrashHandler( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore + appContext!!, + logger!! ) handler shouldNotBe null @@ -46,10 +47,8 @@ class OneSignalCrashHandlerFactoryTest : FunSpec({ // Note: SDK version check is handled at runtime by the factory // This test verifies the handler can be created and initialized val handler = OneSignalCrashHandlerFactory.createCrashHandler( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore + appContext!!, + logger!! ) handler shouldNotBe null diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt new file mode 100644 index 0000000000..848d4c9953 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt @@ -0,0 +1,100 @@ +package com.onesignal.debug.internal.crash + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.startup.IStartableService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking + +@RobolectricTest +class OneSignalCrashUploaderWrapperTest : FunSpec({ + + var appContext: Context? = null + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + } + } + + test("should implement IStartableService") { + // Given + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext!! + + // When + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // Then + wrapper.shouldBeInstanceOf() + } + + test("should create uploader lazily when start is called") { + // Given + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext!! + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // When + runBlocking { + wrapper.start() + } + + // Then - should not throw, uploader should be created + // We can't directly verify the uploader was created, but if start() completes without error, + // it means the uploader was created and started successfully + } + + test("should call uploader.start() when start() is called") { + // Given + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext!! + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // When + runBlocking { + wrapper.start() + } + + // Then - start() should complete without throwing + // The actual uploader.start() is called internally via runBlocking + // If it throws, this test would fail + } + + test("should handle errors gracefully when uploader fails") { + // Given + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext!! + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // When/Then - start() should handle errors gracefully + // If remote logging is disabled, it should return early without error + // If there are no crash reports, it should complete without error + runBlocking { + // This should not throw even if there are no crash reports or if remote logging is disabled + wrapper.start() + } + } + + test("should create wrapper with applicationService dependency") { + // Given + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext!! + + // When + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // Then + wrapper shouldNotBe null + wrapper.shouldBeInstanceOf() + } +}) 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 61e15a9fe2..bcce464249 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 @@ -1,70 +1,98 @@ package com.onesignal.debug.internal.crash +import android.content.Context +import android.content.SharedPreferences import android.os.Build +import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest -import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.config.ConfigModelStore -import com.onesignal.core.internal.device.IInstallIdService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider import com.onesignal.otel.IOtelCrashHandler import com.onesignal.otel.IOtelPlatformProvider import com.onesignal.otel.OtelFactory -import com.onesignal.user.internal.identity.IdentityModel -import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.backend.IdentityConstants import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject import org.robolectric.annotation.Config -import java.util.UUID -import android.content.Context as AndroidContext +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +// Helper extension for shouldBeOneOf +private infix fun T.shouldBeOneOf(expected: List) { + val isInList = expected.contains(this) + if (!isInList) { + throw AssertionError("Expected $this to be one of $expected") + } +} @RobolectricTest @Config(sdk = [Build.VERSION_CODES.O]) class OtelIntegrationTest : FunSpec({ - val mockContext = mockk(relaxed = true) - val mockApplicationService = mockk(relaxed = true) - val mockInstallIdService = mockk(relaxed = true) - val mockConfigModelStore = mockk(relaxed = true) - val mockIdentityModelStore = mockk(relaxed = true) - val mockConfigModel = mockk(relaxed = true) - val mockIdentityModel = mockk(relaxed = true) + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + } beforeEach { - every { mockContext.packageName } returns "com.test.app" - every { mockContext.cacheDir } returns mockk(relaxed = true) { - every { path } returns "/test/cache" + // Ensure sharedPreferences is initialized + if (sharedPreferences == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // Clear and set up SharedPreferences with test data + sharedPreferences!!.edit().clear().commit() + + // Set up ConfigModelStore data + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::pushSubscriptionId.name, "test-subscription-id") + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "ERROR") + } + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + + // Set up IdentityModelStore data + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id") } - every { mockApplicationService.appContext } returns mockContext - every { mockApplicationService.isInForeground } returns true - coEvery { mockInstallIdService.getId() } returns UUID.randomUUID() - every { mockConfigModelStore.model } returns mockConfigModel - every { mockIdentityModelStore.model } returns mockIdentityModel - every { mockConfigModel.appId } returns "test-app-id" - every { mockConfigModel.remoteLoggingParams } returns mockk(relaxed = true) { - every { logLevel } returns com.onesignal.debug.LogLevel.ERROR + val identityArray = JSONArray().apply { + put(identityModel) } - every { mockIdentityModel.onesignalId } returns "test-onesignal-id" - every { mockConfigModel.pushSubscriptionId } returns "test-subscription-id" + + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id") + .commit() + } + + afterEach { + sharedPreferences!!.edit().clear().commit() } test("AndroidOtelPlatformProvider should provide correct Android values") { - val provider = createAndroidOtelPlatformProvider( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore - ) + val provider = createAndroidOtelPlatformProvider(appContext!!) provider.shouldBeInstanceOf() provider.sdkBase shouldBe "android" - provider.appPackageId shouldBe "com.test.app" + provider.appPackageId shouldBe appContext!!.packageName // Use actual package name from context provider.osName shouldBe "Android" provider.deviceManufacturer shouldBe Build.MANUFACTURER provider.deviceModel shouldBe Build.MODEL @@ -77,18 +105,13 @@ class OtelIntegrationTest : FunSpec({ } test("AndroidOtelPlatformProvider should provide per-event values") { - val provider = createAndroidOtelPlatformProvider( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore - ) + val provider = createAndroidOtelPlatformProvider(appContext!!) provider.appId shouldBe "test-app-id" provider.onesignalId shouldBe "test-onesignal-id" provider.pushSubscriptionId shouldBe "test-subscription-id" - provider.appState shouldBe "foreground" - provider.processUptime shouldBe 100.0 + provider.appState shouldBeOneOf listOf("foreground", "background", "unknown") + (provider.processUptime > 0.0) shouldBe true provider.currentThreadName shouldBe Thread.currentThread().name } @@ -104,12 +127,7 @@ class OtelIntegrationTest : FunSpec({ } test("OtelFactory should create crash handler with Android provider") { - val provider = createAndroidOtelPlatformProvider( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore - ) + val provider = createAndroidOtelPlatformProvider(appContext!!) val logger = AndroidOtelLogger() val handler = OtelFactory.createCrashHandler(provider, logger) @@ -120,12 +138,11 @@ class OtelIntegrationTest : FunSpec({ } test("OneSignalCrashHandlerFactory should create working crash handler") { - val handler = OneSignalCrashHandlerFactory.createCrashHandler( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore - ) + // Note: OneSignalCrashHandlerFactory may need to be updated to use the new approach + // For now, we'll test the direct creation + val provider = createAndroidOtelPlatformProvider(appContext!!) + val logger = AndroidOtelLogger() + val handler = OtelFactory.createCrashHandler(provider, logger) handler shouldNotBe null handler.shouldBeInstanceOf() @@ -133,12 +150,7 @@ class OtelIntegrationTest : FunSpec({ } test("AndroidOtelPlatformProvider should provide crash storage path") { - val provider = createAndroidOtelPlatformProvider( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore - ) + val provider = createAndroidOtelPlatformProvider(appContext!!) provider.crashStoragePath.contains("onesignal") shouldBe true provider.crashStoragePath.contains("otel") shouldBe true @@ -147,16 +159,7 @@ class OtelIntegrationTest : FunSpec({ } test("AndroidOtelPlatformProvider should handle remote logging config") { - every { mockConfigModel.remoteLoggingParams } returns mockk(relaxed = true) { - every { logLevel } returns com.onesignal.debug.LogLevel.ERROR - } - - val provider = createAndroidOtelPlatformProvider( - mockApplicationService, - mockInstallIdService, - mockConfigModelStore, - mockIdentityModelStore - ) + val provider = createAndroidOtelPlatformProvider(appContext!!) provider.remoteLogLevel shouldBe "ERROR" provider.appIdForHeaders shouldBe "test-app-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 new file mode 100644 index 0000000000..517cce7a19 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt @@ -0,0 +1,833 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.IDManager.LOCAL_PREFIX +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.user.internal.backend.IdentityConstants +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.json.JSONArray +import org.json.JSONObject +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +@RobolectricTest +class OtelIdResolverTest : FunSpec({ + + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + // Helper function to ensure SharedPreferences data is written and verified + fun writeAndVerifyConfigData(configArray: JSONArray) { + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + } + + // Helper function to ensure SharedPreferences identity data is written and verified + fun writeAndVerifyIdentityData(identityArray: JSONArray) { + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + } + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // AGGRESSIVE CLEANUP: Clear ALL SharedPreferences before any test runs + // This ensures clean state even if previous test classes left data behind + sharedPreferences!!.edit().clear().commit() + try { + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + + beforeEach { + // Ensure appContext is initialized + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + } + + // Get a FRESH SharedPreferences instance for each test to avoid caching issues + // This ensures we're not reading stale data from previous tests + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + + // AGGRESSIVE CLEANUP: Clear ALL SharedPreferences to ensure complete isolation + sharedPreferences!!.edit().clear().commit() + + // Also clear any other potential SharedPreferences files + try { + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + + afterEach { + // Clean up after each test + sharedPreferences!!.edit().clear().commit() + + // Also clear any other potential SharedPreferences files + try { + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + + afterSpec { + // Final cleanup after all tests in this spec + if (appContext != null) { + try { + val prefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + prefs.edit().clear().commit() + + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + } + + // ===== resolveAppId Tests ===== + + test("resolveAppId returns appId from ConfigModelStore when available") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "test-app-id-123" + } + + test("resolveAppId returns empty string appId as null and falls back to legacy") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "") + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, "legacy-app-id") + .commit() + + // Ensure commit is complete before creating resolver + Thread.sleep(10) + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "legacy-app-id" + } + + test("resolveAppId falls back to legacy SharedPreferences when ConfigModelStore has no appId") { + // Given + val configModel = JSONObject() // No appId field + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, "legacy-app-id") + .commit() + + // Ensure commit is complete before creating resolver + Thread.sleep(10) + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "legacy-app-id" + } + + test("resolveAppId returns error appId when ConfigModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "8123-1231-4343-2323-error-config-store-not-found" + } + + test("resolveAppId returns error appId when ConfigModelStore is empty array") { + // Given + val configArray = JSONArray() // Empty array + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "8123-1231-4343-2323-error-no-appid-in-config" + } + + test("resolveAppId returns error appId when context is null") { + // Given + val resolver = OtelIdResolver(null) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "8123-1231-4343-2323-error-no-context" + } + + test("resolveAppId handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then - should return error appId with exception name + result shouldBe "8123-1231-4343-2323-error-config-store-not-found" + } + + // ===== resolveOnesignalId Tests ===== + + test("resolveOnesignalId returns onesignalId from IdentityModelStore when available") { + // Given + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id-123") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe "test-onesignal-id-123" + } + + test("resolveOnesignalId returns null when onesignalId is empty string") { + // Given + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when onesignalId is a local ID") { + // Given + val localId = "${LOCAL_PREFIX}test-id" + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, localId) + } + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when IdentityModelStore has no onesignalId field") { + // Given + val identityModel = JSONObject() // No ONESIGNAL_ID field + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when IdentityModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when IdentityModelStore is empty array") { + // Given + val identityArray = JSONArray() // Empty array + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + // ===== resolvePushSubscriptionId Tests ===== + + test("resolvePushSubscriptionId returns pushSubscriptionId from ConfigModelStore when available") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, "test-push-sub-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe "test-push-sub-id-123" + } + + test("resolvePushSubscriptionId returns null when pushSubscriptionId is empty string") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, "") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId returns null when pushSubscriptionId is a local ID") { + // Given + val localId = "${LOCAL_PREFIX}test-id" + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, localId) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId returns null when ConfigModelStore has no pushSubscriptionId field") { + // Given + val configModel = JSONObject() // No pushSubscriptionId field + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId returns null when ConfigModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + // ===== resolveRemoteLogLevel Tests ===== + + test("resolveRemoteLogLevel returns LogLevel from ConfigModelStore when available") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "ERROR") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe LogLevel.ERROR + } + + test("resolveRemoteLogLevel returns LogLevel case-insensitively") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "warn") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe LogLevel.WARN + } + + test("resolveRemoteLogLevel returns null when logLevel field is missing") { + // Given + val remoteLoggingParams = JSONObject() // No logLevel field + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel returns null when remoteLoggingParams field is missing") { + // Given + val configModel = JSONObject() // No remoteLoggingParams field + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel returns null when logLevel is invalid") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "INVALID_LEVEL") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel returns null when ConfigModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + // ===== resolveInstallId Tests ===== + + test("resolveInstallId returns installId from SharedPreferences when available") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveInstallId() + + // Then + result shouldBe "test-install-id-123" + } + + test("resolveInstallId returns default InstallId-Null when not found") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveInstallId() + + // Then + result shouldBe "InstallId-Null" + } + + test("resolveInstallId returns InstallId-NotFound when exception occurs") { + // Given + val mockContext = mockk(relaxed = true) + val mockSharedPreferences = mockk(relaxed = true) + every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") + + val resolver = OtelIdResolver(mockContext) + + // When + val result = resolver.resolveInstallId() + + // Then + result shouldBe "InstallId-NotFound" + } + + // ===== Caching Tests ===== + + test("cachedConfigModel is reused across multiple resolve calls") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::pushSubscriptionId.name, "test-push-id") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When - resolve multiple IDs + val appId1 = resolver.resolveAppId() + val pushId1 = resolver.resolvePushSubscriptionId() + val appId2 = resolver.resolveAppId() + val pushId2 = resolver.resolvePushSubscriptionId() + + // Then - should return same values (cached) + appId1 shouldBe "test-app-id" + pushId1 shouldBe "test-push-id" + appId2 shouldBe "test-app-id" + pushId2 shouldBe "test-push-id" + } +}) 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 new file mode 100644 index 0000000000..1aea4be886 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt @@ -0,0 +1,678 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +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.user.internal.backend.IdentityConstants +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +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 +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelPlatformProviderTest : FunSpec({ + + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + } + + beforeEach { + // Ensure sharedPreferences is initialized + if (sharedPreferences == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // Clear SharedPreferences and reset wrapper + sharedPreferences!!.edit().clear().commit() + OneSignalWrapper.sdkType = null + OneSignalWrapper.sdkVersion = null + Logging.logLevel = LogLevel.NONE + } + + afterEach { + // Clean up + sharedPreferences!!.edit().clear().commit() + OneSignalWrapper.sdkType = null + OneSignalWrapper.sdkVersion = null + } + + // ===== Static Properties Tests ===== + + test("sdkBase returns android") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkBase + + // Then + result shouldBe "android" + } + + test("sdkBaseVersion returns OneSignalUtils.sdkVersion") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkBaseVersion + + // Then + result shouldBe OneSignalUtils.sdkVersion + } + + test("appPackageId returns context.packageName") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appPackageId + + // Then + result shouldBe appContext!!.packageName + } + + test("appVersion returns AndroidUtils.getAppVersion") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appVersion + + // Then + result shouldNotBe null + result shouldNotBe "" + } + + test("deviceManufacturer returns Build.MANUFACTURER") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.deviceManufacturer + + // Then + result shouldBe Build.MANUFACTURER + } + + test("deviceModel returns Build.MODEL") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.deviceModel + + // Then + result shouldBe Build.MODEL + } + + test("osName returns Android") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.osName + + // Then + result shouldBe "Android" + } + + test("osVersion returns Build.VERSION.RELEASE") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.osVersion + + // Then + result shouldBe Build.VERSION.RELEASE + } + + test("osBuildId returns Build.ID") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.osBuildId + + // Then + result shouldBe Build.ID + } + + test("sdkWrapper returns OneSignalWrapper.sdkType") { + // Given + OneSignalWrapper.sdkType = "Unity" + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapper + + // Then + result shouldBe "Unity" + } + + test("sdkWrapper returns null when not set") { + // Given + OneSignalWrapper.sdkType = null + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapper + + // Then + result shouldBe null + } + + test("sdkWrapperVersion returns OneSignalWrapper.sdkVersion") { + // Given + OneSignalWrapper.sdkVersion = "1.0.0" + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapperVersion + + // Then + result shouldBe "1.0.0" + } + + test("sdkWrapperVersion returns null when not set") { + // Given + OneSignalWrapper.sdkVersion = null + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapperVersion + + // Then + result shouldBe null + } + + // ===== Lazy ID Properties Tests ===== + + test("appId returns resolved appId from OtelIdResolver") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id-123") + } + 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.appId + + // Then + result shouldBe "test-app-id-123" + } + + test("appId returns null when not available") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appId + + // Then - should return error appId (not null, but error prefix) + result shouldNotBe null + result shouldContain "8123-1231-4343-2323-error-" + } + + test("onesignalId returns resolved onesignalId from OtelIdResolver") { + // Given + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id-123") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.onesignalId + + // Then + result shouldBe "test-onesignal-id-123" + } + + test("onesignalId returns null when not available") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.onesignalId + + // Then + result shouldBe null + } + + test("pushSubscriptionId returns resolved pushSubscriptionId from OtelIdResolver") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, "test-push-sub-id-123") + } + 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.pushSubscriptionId + + // Then + result shouldBe "test-push-sub-id-123" + } + + test("pushSubscriptionId returns null when not available") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.pushSubscriptionId + + // Then + result shouldBe null + } + + // ===== appState Tests ===== + + test("appState returns foreground when getIsInForeground returns true") { + // Given + val getIsInForeground: () -> Boolean? = { true } + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = appContext, + getIsInForeground = getIsInForeground + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "foreground" + } + + test("appState returns background when getIsInForeground returns false") { + // Given + val getIsInForeground: () -> Boolean? = { false } + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = appContext, + getIsInForeground = getIsInForeground + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "background" + } + + test("appState falls back to ActivityManager when getIsInForeground is null") { + // Given + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = appContext, + getIsInForeground = null + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then - should return a valid state (foreground, background, or unknown) + result shouldBeOneOf listOf("foreground", "background", "unknown") + } + + test("appState returns unknown when context is null and getIsInForeground is null") { + // Given + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = null, + getIsInForeground = null + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "unknown" + } + + test("appState handles exceptions gracefully and returns unknown") { + // Given + val mockContext = mockk(relaxed = true) + every { mockContext.getSystemService(any()) } throws RuntimeException("Test exception") + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = mockContext, + getIsInForeground = null + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "unknown" + } + + // ===== processUptime Tests ===== + + test("processUptime returns uptime in seconds") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.processUptime + + // Then + (result > 0.0) shouldBe true + (result < 1000000.0) shouldBe true // Reasonable upper bound + } + + // ===== currentThreadName Tests ===== + + test("currentThreadName returns current thread name") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.currentThreadName + + // Then + result shouldNotBe null + result shouldNotBe "" + } + + // ===== crashStoragePath Tests ===== + + test("crashStoragePath returns configured path") { + // Given + val expectedPath = "/test/crash/path" + val config = OtelPlatformProviderConfig( + crashStoragePath = expectedPath, + appPackageId = "com.test", + appVersion = "1.0" + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.crashStoragePath + + // Then + result shouldBe expectedPath + } + + test("crashStoragePath logs info message on first access") { + // Given + val logSlot = slot() + val expectedPath = "/test/crash/path" + val config = OtelPlatformProviderConfig( + crashStoragePath = expectedPath, + appPackageId = "com.test", + appVersion = "1.0" + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.crashStoragePath + + // Then + result shouldBe expectedPath + // Note: We can't easily verify Logging.info was called without mocking Logging, + // but the behavior is tested by ensuring the path is returned correctly + } + + test("createAndroidOtelPlatformProvider sets correct crashStoragePath") { + // Given & When + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // Then + provider.crashStoragePath shouldContain "onesignal" + provider.crashStoragePath shouldContain "otel" + provider.crashStoragePath shouldContain "crashes" + } + + // ===== minFileAgeForReadMillis Tests ===== + + test("minFileAgeForReadMillis returns default value") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.minFileAgeForReadMillis + + // Then + result shouldBe 5_000L + } + + // ===== remoteLogLevel Tests ===== + + test("remoteLogLevel returns ERROR when configLevel is null") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe "ERROR" + } + + test("remoteLogLevel returns configLevel name when available") { + // Given + 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 provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe "WARN" + } + + test("remoteLogLevel returns NONE when configLevel is NONE") { + // Given + 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!!) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe "NONE" + } + + test("remoteLogLevel returns ERROR when exception occurs") { + // Given + 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) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe "ERROR" + } + + // ===== appIdForHeaders Tests ===== + + test("appIdForHeaders returns appId when available") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id-123") + } + 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.appIdForHeaders + + // Then + result shouldBe "test-app-id-123" + } + + test("appIdForHeaders returns empty string when appId is null") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appIdForHeaders + + // Then - even with error appId, it should return something (not empty) + result shouldNotBe null + } + + // ===== getInstallId Tests ===== + + test("getInstallId returns installId from SharedPreferences") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123") + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = runBlocking { provider.getInstallId() } + + // Then + result shouldBe "test-install-id-123" + } + + test("getInstallId returns default when not found") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = runBlocking { provider.getInstallId() } + + // Then + result shouldBe "InstallId-Null" + } + + // ===== Factory Function Tests ===== + + test("createAndroidOtelPlatformProvider creates provider with correct config") { + // Given & When + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // Then + provider.appPackageId shouldBe appContext!!.packageName + provider.sdkBase shouldBe "android" + provider.osName shouldBe "Android" + } + + test("createAndroidOtelPlatformProvider handles null appVersion gracefully") { + // Given + val mockContext = mockk(relaxed = true) + val mockPackageManager = mockk(relaxed = true) + every { mockContext.packageName } returns "com.test" + every { mockContext.cacheDir } returns appContext!!.cacheDir + every { mockContext.packageManager } returns mockPackageManager + every { mockContext.getSharedPreferences(any(), any()) } returns sharedPreferences + // Make getPackageInfo throw NameNotFoundException to simulate missing package + every { mockPackageManager.getPackageInfo(any(), any()) } throws android.content.pm.PackageManager.NameNotFoundException() + + // When + val provider: OtelPlatformProvider = createAndroidOtelPlatformProvider(mockContext) + + // Then + provider.appVersion shouldBe "unknown" + } +}) + +// Helper extension for shouldBeOneOf +private infix fun T.shouldBeOneOf(expected: List) { + val isInList = expected.contains(this) + if (!isInList) { + throw AssertionError("Expected $this to be one of $expected") + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt new file mode 100644 index 0000000000..12758e0a43 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalCrashLogInitTest.kt @@ -0,0 +1,348 @@ +package com.onesignal.internal + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelCrashHandler +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.robolectric.annotation.Config +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OneSignalCrashLogInitTest : FunSpec({ + + val context: Context = ApplicationProvider.getApplicationContext() + val sharedPreferences = context.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + + beforeAny { + Logging.logLevel = LogLevel.NONE + // Clear SharedPreferences before each test + sharedPreferences.edit().clear().commit() + } + + afterAny { + // Clean up after each test + sharedPreferences.edit().clear().commit() + // Restore default uncaught exception handler + Thread.setDefaultUncaughtExceptionHandler(null) + } + + // ===== Platform Provider Reuse Tests ===== + + test("platform provider should be created once and reused") { + // Given + val crashLogInit = OneSignalCrashLogInit(context) + + // When - initialize crash handler (creates platform provider) + crashLogInit.initializeCrashHandler() + + // Then - initialize logging should reuse the same platform provider + // We can't directly access the private property, but we can verify behavior + // by checking that both initializations succeed without errors + runBlocking { + crashLogInit.initializeOtelLogging() + delay(100) // Give async initialization time to complete + } + + // If we got here without exceptions, the platform provider was reused successfully + } + + test("should create instance with context") { + // Given & When + val crashLogInit = OneSignalCrashLogInit(context) + + // Then + crashLogInit shouldNotBe null + } + + // ===== Crash Handler Initialization Tests ===== + + test("initializeCrashHandler should create and initialize crash handler") { + // Given + val crashLogInit = OneSignalCrashLogInit(context) + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + + // When + crashLogInit.initializeCrashHandler() + + // Then + val currentHandler = Thread.getDefaultUncaughtExceptionHandler() + currentHandler shouldNotBe null + currentHandler.shouldBeInstanceOf() + + // Cleanup + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("initializeCrashHandler should handle exceptions gracefully") { + // Given + val mockContext = io.mockk.mockk(relaxed = true) + io.mockk.every { mockContext.cacheDir } throws RuntimeException("Test exception") + io.mockk.every { mockContext.packageName } returns "com.test" + io.mockk.every { mockContext.getSharedPreferences(any(), any()) } returns sharedPreferences + + val crashLogInit = OneSignalCrashLogInit(mockContext) + + // When & Then - should not throw + crashLogInit.initializeCrashHandler() + } + + test("initializeCrashHandler should initialize ANR detector") { + // Given + val crashLogInit = OneSignalCrashLogInit(context) + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + + // When + crashLogInit.initializeCrashHandler() + + // Then - ANR detector should be started (we can't directly verify, but no exception means success) + // The method logs success, so if it doesn't throw, it worked + + // Cleanup + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("initializeCrashHandler can be called multiple times safely") { + // Given + val crashLogInit = OneSignalCrashLogInit(context) + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + + // When + crashLogInit.initializeCrashHandler() + crashLogInit.initializeCrashHandler() // Call again + + // Then - should not throw or cause issues + val currentHandler = Thread.getDefaultUncaughtExceptionHandler() + currentHandler shouldNotBe null + + // Cleanup + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + // ===== Otel Logging Initialization Tests ===== + + test("initializeOtelLogging should initialize remote telemetry when enabled") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "ERROR") + } + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val crashLogInit = OneSignalCrashLogInit(context) + + // When + runBlocking { + crashLogInit.initializeOtelLogging() + delay(200) // Give async initialization time to complete + } + + // Then - should not throw, telemetry should be set + // We can't directly verify Logging.setOtelTelemetry was called, but no exception means success + } + + test("initializeOtelLogging should skip initialization when remote logging is disabled") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "NONE") + } + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val crashLogInit = OneSignalCrashLogInit(context) + + // When + runBlocking { + crashLogInit.initializeOtelLogging() + delay(100) // Give async initialization time to complete + } + + // Then - should not throw, should skip initialization + } + + test("initializeOtelLogging should default to ERROR when remote log level is not configured") { + // Given - no remote logging config in SharedPreferences + val crashLogInit = OneSignalCrashLogInit(context) + + // When + runBlocking { + crashLogInit.initializeOtelLogging() + delay(200) // Give async initialization time to complete + } + + // Then - should default to ERROR level and initialize + // No exception means it worked + } + + test("initializeOtelLogging should handle exceptions gracefully") { + // Given + val mockContext = io.mockk.mockk(relaxed = true) + io.mockk.every { mockContext.cacheDir } returns context.cacheDir + io.mockk.every { mockContext.packageName } returns "com.test" + io.mockk.every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") + + val crashLogInit = OneSignalCrashLogInit(mockContext) + + // When & Then - should not throw + runBlocking { + crashLogInit.initializeOtelLogging() + delay(100) + } + } + + test("initializeOtelLogging can be called multiple times safely") { + // Given + val crashLogInit = OneSignalCrashLogInit(context) + + // When + runBlocking { + crashLogInit.initializeOtelLogging() + delay(100) + crashLogInit.initializeOtelLogging() // Call again + delay(100) + } + + // Then - should not throw or cause issues + } + + // ===== Integration Tests ===== + + test("both initializeCrashHandler and initializeOtelLogging should work together") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "WARN") + } + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val crashLogInit = OneSignalCrashLogInit(context) + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + + // When + crashLogInit.initializeCrashHandler() + runBlocking { + crashLogInit.initializeOtelLogging() + delay(200) + } + + // Then - both should succeed + val currentHandler = Thread.getDefaultUncaughtExceptionHandler() + currentHandler shouldNotBe null + + // Cleanup + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("should work with different log levels") { + // Given + val logLevels = listOf("ERROR", "WARN", "INFO", "DEBUG", "VERBOSE") + + logLevels.forEach { level -> + val remoteLoggingParams = JSONObject().apply { + put("logLevel", level) + } + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences.edit().clear().commit() + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val crashLogInit = OneSignalCrashLogInit(context) + + // When + runBlocking { + crashLogInit.initializeOtelLogging() + delay(100) + } + + // Then - should not throw for any log level + } + } + + test("should handle invalid log level gracefully") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "INVALID_LEVEL") + } + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val crashLogInit = OneSignalCrashLogInit(context) + + // When & Then - should default to ERROR and not throw + runBlocking { + crashLogInit.initializeOtelLogging() + delay(100) + } + } + + // ===== Context Handling Tests ===== + + test("should work with different contexts") { + // Given + val context1: Context = ApplicationProvider.getApplicationContext() + val context2: Context = ApplicationProvider.getApplicationContext() + + val crashLogInit1 = OneSignalCrashLogInit(context1) + val crashLogInit2 = OneSignalCrashLogInit(context2) + + // When + crashLogInit1.initializeCrashHandler() + crashLogInit2.initializeCrashHandler() + + // Then - both should work independently + val handler1 = Thread.getDefaultUncaughtExceptionHandler() + handler1 shouldNotBe null + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt index 8464ed594a..4ab4a7e8b6 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt @@ -3,6 +3,6 @@ package com.onesignal.otel /** * Platform-agnostic crash reporter interface. */ -internal interface IOtelCrashReporter { +interface IOtelCrashReporter { suspend fun saveCrash(thread: Thread, throwable: Throwable) } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt index 38c0c138c9..c4e46e6630 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt @@ -15,19 +15,16 @@ object OtelFactory { /** * Creates a fully configured crash handler that can be initialized immediately. * All fields are pre-populated for fast initialization. + * + * This method composes other factory methods to create the crash handler, + * ensuring consistency and reducing duplication. */ fun createCrashHandler( platformProvider: IOtelPlatformProvider, logger: IOtelLogger, ): IOtelCrashHandler { - val topLevelFields = OtelFieldsTopLevel(platformProvider) - val perEventFields = OtelFieldsPerEvent(platformProvider) - val crashLocal = OneSignalOpenTelemetryCrashLocal( - platformProvider, - topLevelFields, - perEventFields - ) - val crashReporter = OtelCrashReporter(crashLocal, logger) + val crashLocal = createCrashLocalTelemetry(platformProvider) + val crashReporter = createCrashReporter(crashLocal, logger) return OtelCrashHandler(crashReporter, logger) } @@ -75,4 +72,41 @@ object OtelFactory { perEventFields ) } + + /** + * Creates a local OpenTelemetry crash instance for saving crash reports locally. + * + * This is platform-agnostic and can be used in KMP projects. + * All platform-specific values must be provided through IOtelPlatformProvider. + * + * @param platformProvider Platform-specific provider that injects all required values + * @return Platform-agnostic crash local telemetry instance + */ + fun createCrashLocalTelemetry( + platformProvider: IOtelPlatformProvider, + ): IOtelOpenTelemetryCrash { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + return OneSignalOpenTelemetryCrashLocal( + platformProvider, + topLevelFields, + perEventFields + ) + } + + /** + * Creates a crash reporter for saving crash reports. + * + * This is platform-agnostic and can be used in KMP projects. + * + * @param openTelemetryCrash The crash telemetry instance to use + * @param logger Platform-specific logger implementation + * @return Platform-agnostic crash reporter + */ + fun createCrashReporter( + openTelemetryCrash: IOtelOpenTelemetryCrash, + logger: IOtelLogger, + ): IOtelCrashReporter { + return OtelCrashReporter(openTelemetryCrash, logger) + } } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt new file mode 100644 index 0000000000..b7b5027ee8 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt @@ -0,0 +1,21 @@ +package com.onesignal.otel.crash + +/** + * Platform-agnostic interface for ANR (Application Not Responding) detection. + * + * ANRs occur when the main thread is blocked for too long (typically >5 seconds on Android). + * Unlike crashes, ANRs don't throw exceptions - they're detected by monitoring thread responsiveness. + */ +interface IOtelAnrDetector { + /** + * Starts monitoring for ANRs. + * This should be called early in the app lifecycle, ideally right after crash handler initialization. + */ + fun start() + + /** + * Stops monitoring for ANRs. + * Should be called when the app is shutting down or when monitoring is no longer needed. + */ + fun stop() +} 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 5b261c13c0..ebe744e045 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 @@ -46,17 +46,29 @@ internal class OtelCrashHandler( 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 + // NOTE: Future improvements: // - Catch anything we may throw and print only to logcat // - Send a stop command to OneSignalCrashUploader, give a bit of time to finish // and then call existingHandler. This way the app doesn't have to open a 2nd // time to get the crash report and should help prevent duplicated reports. - if (!isOneSignalAtFault(throwable)) { + // NOTE: ANRs are typically detected by the standalone OtelAnrDetector, which only + // reports OneSignal-related ANRs. This handler would only catch ANRs if they're + // 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 } + if (isAnr) { + logger.info("OtelCrashHandler: ANR exception caught (unusual - ANRs are usually detected by standalone detector)") + } + logger.info("OtelCrashHandler: OneSignal-related crash detected, saving crash report...") /** @@ -93,5 +105,17 @@ internal class OtelCrashHandler( } } +/** + * Checks if a throwable's stack trace indicates OneSignal is at fault. + * Centralized logic used by both crash handler and ANR detector. + */ internal fun isOneSignalAtFault(throwable: Throwable): Boolean = - throwable.stackTrace.any { it.className.startsWith("com.onesignal") } + isOneSignalAtFault(throwable.stackTrace) + +/** + * Helper function to check if a stack trace indicates OneSignal is at fault. + * Centralized logic used by both crash handler and ANR detector. + * Made public so it can be accessed from core module. + */ +fun isOneSignalAtFault(stackTrace: Array): Boolean = + stackTrace.any { it.className.startsWith("com.onesignal") } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt index 57331bf6d0..b875f906cb 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt @@ -14,6 +14,7 @@ internal class OtelCrashReporter( private const val OTEL_EXCEPTION_TYPE = "exception.type" private const val OTEL_EXCEPTION_MESSAGE = "exception.message" private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" + private const val OTEL_EXCEPTION_THREAD_NAME = "ossdk.exception.thread.name" } override suspend fun saveCrash(thread: Thread, throwable: Throwable) { @@ -28,7 +29,7 @@ internal class OtelCrashReporter( .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name) // This matches the top level thread.name today, but it may not // always if things are refactored to use a different thread. - .put("ossdk.exception.thread.name", thread.name) + .put(OTEL_EXCEPTION_THREAD_NAME, thread.name) .build() logger.debug("OtelCrashReporter: Creating log record with attributes...") 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 f4aa6a9a00..cf29a0f210 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 @@ -1,5 +1,6 @@ package com.onesignal.otel +import com.onesignal.otel.crash.OtelCrashUploader import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf @@ -37,29 +38,166 @@ class OtelFactoryTest : FunSpec({ coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id" } + // ===== createCrashHandler Tests ===== + test("createCrashHandler should return IOtelCrashHandler") { + // When val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + // Then handler.shouldBeInstanceOf() } test("createCrashHandler should create handler with correct dependencies") { + // When val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + // Then handler shouldNotBe null // Handler should be initializable handler.initialize() } + test("createCrashHandler should create handler that can be initialized multiple times") { + // Given + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + // When + handler.initialize() + handler.initialize() // Should not throw + + // Then - no exception thrown + } + + // ===== createCrashUploader Tests ===== + test("createCrashUploader should return OtelCrashUploader") { + // When val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + // Then uploader shouldNotBe null + uploader.shouldBeInstanceOf() } test("createCrashUploader should create uploader with correct dependencies") { + // When val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + // Then uploader shouldNotBe null } + + // ===== createRemoteTelemetry Tests ===== + + test("createRemoteTelemetry should return IOtelOpenTelemetryRemote") { + // When + val telemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + // Then + telemetry shouldNotBe null + telemetry.shouldBeInstanceOf() + } + + test("createRemoteTelemetry should have logExporter") { + // When + val telemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + // Then + telemetry.logExporter shouldNotBe null + } + + // ===== createCrashLocalTelemetry Tests ===== + + test("createCrashLocalTelemetry should return IOtelOpenTelemetryCrash") { + // When + val telemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + // Then + telemetry shouldNotBe null + telemetry.shouldBeInstanceOf() + } + + test("createCrashLocalTelemetry should be different instance from remote") { + // When + val localTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + // Then + localTelemetry shouldNotBe remoteTelemetry + } + + // ===== createCrashReporter Tests ===== + + test("createCrashReporter should return IOtelCrashReporter") { + // Given + val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + // When + val reporter = OtelFactory.createCrashReporter(crashTelemetry, mockLogger) + + // Then + reporter shouldNotBe null + reporter.shouldBeInstanceOf() + } + + test("createCrashReporter should work with different telemetry instances") { + // Given + val crashTelemetry1 = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + val crashTelemetry2 = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + // When + val reporter1 = OtelFactory.createCrashReporter(crashTelemetry1, mockLogger) + val reporter2 = OtelFactory.createCrashReporter(crashTelemetry2, mockLogger) + + // Then + reporter1 shouldNotBe null + reporter2 shouldNotBe null + reporter1 shouldNotBe reporter2 + } + + // ===== Integration Tests ===== + + test("createCrashHandler uses platform provider values correctly") { + // Given + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.onesignalId } returns "test-onesignal-id" + + // When + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + // Then + handler shouldNotBe null + handler.initialize() // Should work with provided values + } + + test("createCrashUploader uses platform provider values correctly") { + // Given + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.crashStoragePath } returns "/custom/path" + + // When + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + + // Then + uploader shouldNotBe null + } + + test("all factory methods work with null appId") { + // Given + every { mockPlatformProvider.appId } returns null + + // When & Then - should not throw + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + handler shouldNotBe null + + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + uploader shouldNotBe null + + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + remoteTelemetry shouldNotBe null + + val localTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + localTelemetry shouldNotBe null + } })