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 c9c61be6a5..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 @@ -41,7 +41,11 @@ import com.onesignal.user.state.UserState import kotlinx.coroutines.CoroutineScope 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() { @@ -80,9 +84,43 @@ class MainApplicationKT : MultiDexApplication() { OneSignal.Notifications.requestPermission(true) Log.d(Tag.LOG_TAG, Text.ONESIGNAL_SDK_INIT) + + delay(3000) + } +// 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() { OneSignal.InAppMessages.addLifecycleListener(object : IInAppMessageLifecycleListener { override fun onWillDisplay(@NonNull event: IInAppMessageWillDisplayEvent) { diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index eab205d258..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' @@ -25,6 +25,10 @@ buildscript { 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 + // OpenTelemetry versions + opentelemetryBomVersion = '1.55.0' + opentelemetrySemconvVersion = '1.37.0' + opentelemetryDiskBufferingVersion = '1.51.0-alpha' sharedRepos = { google() @@ -45,11 +49,9 @@ buildscript { ] } - buildscript { - repositories sharedRepos - dependencies { - classpath sharedDeps - } + repositories sharedRepos + dependencies { + classpath sharedDeps } } diff --git a/OneSignalSDK/detekt/detekt-config.yml b/OneSignalSDK/detekt/detekt-config.yml index 12c24e6464..de24a4b2b2 100644 --- a/OneSignalSDK/detekt/detekt-config.yml +++ b/OneSignalSDK/detekt/detekt-config.yml @@ -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/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index b0f2580711..bf11425121 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -88,6 +88,8 @@ dependencies { } } + // 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 9083cddade..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,6 +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.OneSignalCrashUploaderWrapper import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -81,6 +82,9 @@ internal class CoreModule : IModule { // Purchase Tracking 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. builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt index 514cc798bc..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 @@ -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]. * @@ -20,7 +20,8 @@ interface IParamsBackendService { ): ParamsObject } -class ParamsObject( +@Suppress("LongParameterList") +internal class ParamsObject( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, @@ -36,9 +37,10 @@ class ParamsObject( var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, + val remoteLoggingParams: RemoteLoggingParamsObject, ) -class InfluenceParamsObject( +internal class InfluenceParamsObject( val indirectNotificationAttributionWindow: Int? = null, val notificationLimit: Int? = null, val indirectIAMAttributionWindow: Int? = null, @@ -48,8 +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, ) + +internal class RemoteLoggingParamsObject( + 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 85dd452d41..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 @@ -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,16 @@ internal class ParamsBackendService( ) } + // Process Remote Logging params + var remoteLoggingParams: RemoteLoggingParamsObject? = null + responseJson.expandJSONObject("remote_logging") { + val logLevel = parseLogLevel(it) + remoteLoggingParams = + RemoteLoggingParamsObject( + logLevel = logLevel, + ) + } + return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), @@ -75,6 +86,7 @@ internal class ParamsBackendService( opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), influenceParams = influenceParams ?: InfluenceParamsObject(), fcmParams = fcmParams ?: FCMParamsObject(), + remoteLoggingParams = remoteLoggingParams ?: RemoteLoggingParamsObject(), ) } @@ -122,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 74d31c4669..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 @@ -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,24 @@ class FCMConfigModel(parentModel: Model, parentProperty: String) : Model(parentM setOptStringProperty(::apiKey.name, value) } } + +/** + * Configuration related to OneSignal's remote logging. + */ +class RemoteLoggingConfigModel( + parentModel: Model, + parentProperty: String, +) : Model(parentModel, parentProperty) { + /** + * 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 logLevel: com.onesignal.debug.LogLevel? + get() = getOptEnumProperty(::logLevel.name) + set(value) { + setOptEnumProperty(::logLevel.name, value) + } +} 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/core/internal/config/impl/ConfigModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt index 5e3664e5f7..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,6 +103,8 @@ internal class ConfigModelStoreListener( params.influenceParams.isIndirectEnabled?.let { config.influenceParams.isIndirectEnabled = it } params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it } + params.remoteLoggingParams.logLevel?.let { config.remoteLoggingParams.logLevel = it } + _configModelStore.replace(config, ModelChangeTags.HYDRATE) success = true } catch (ex: BackendException) { 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/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/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 new file mode 100644 index 0000000000..162a4f7e08 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt @@ -0,0 +1,51 @@ +package com.onesignal.debug.internal.crash + +import android.content.Context +import android.os.Build +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.OtelFactory + +/** + * Factory for creating crash handlers with SDK version checks. + * For SDK < 26, returns a no-op implementation. + * For SDK >= 26, returns the Otel-based crash handler. + * + * 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. + * + * @param context Android context for creating platform provider + * @param logger Logger instance (can be shared with other components) + */ + fun createCrashHandler( + context: Context, + logger: IOtelLogger, + ): IOtelCrashHandler { + // Otel requires SDK 26+, use no-op for older versions + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Logging.info("OneSignal: Creating no-op crash handler (SDK ${Build.VERSION.SDK_INT} < 26)") + return NoOpCrashHandler() + } + + 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) + } +} + +/** + * No-op crash handler for SDK < 26. + */ +private class NoOpCrashHandler : IOtelCrashHandler { + override fun initialize() { + Logging.info("OneSignal: No-op crash handler initialized (SDK < 26, Otel not supported)") + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt new file mode 100644 index 0000000000..e9d620d09d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -0,0 +1,51 @@ +package com.onesignal.debug.internal.crash + +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.otel.OtelFactory +import com.onesignal.otel.crash.OtelCrashUploader +import kotlinx.coroutines.runBlocking + +/** + * Android-specific wrapper for OtelCrashUploader that implements IStartableService. + * + * 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, +) : IStartableService { + private val uploader: OtelCrashUploader by lazy { + // Create Android-specific platform provider (injects Android values) + val platformProvider = createAndroidOtelPlatformProvider( + applicationService.appContext + ) + // 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/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/Logging.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt index a4db03407a..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 @@ -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 a specific log level should be sent remotely. + * Set this to dynamically check remote logging configuration based on log level. + */ + @Volatile + private var shouldSendLogLevel: (LogLevel) -> Boolean = { false } + + /** + * 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 shouldSend Function that returns true if a log level should be sent remotely + */ + fun setOtelTelemetry( + telemetry: IOtelOpenTelemetryRemote?, + shouldSend: (LogLevel) -> Boolean = { false }, + ) { + otelRemoteTelemetry = telemetry + shouldSendLogLevel = shouldSend + } + + // 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,43 @@ 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, + throwable: Throwable?, + ) { + val telemetry = otelRemoteTelemetry ?: 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 { + 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/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/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 new file mode 100644 index 0000000000..84b48ecfcf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt @@ -0,0 +1,173 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelPlatformProvider + +/** + * Configuration for AndroidOtelPlatformProvider. + */ +internal data class OtelPlatformProviderConfig( + val crashStoragePath: String, + val appPackageId: String, + val appVersion: String, + val context: Context? = null, + val getIsInForeground: (() -> Boolean?)? = null, +) + +/** + * Android-specific implementation of IOtelPlatformProvider. + * Reads all values directly from SharedPreferences and system services. + * No SDK service dependencies required. + * + * 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 context: Context? = config.context + private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground + private val idResolver = OtelIdResolver(context) + + // Top-level attributes (static, calculated once) + override suspend fun getInstallId(): String = idResolver.resolveInstallId() + + override val sdkBase: String = "android" + + override val sdkBaseVersion: String = OneSignalUtils.sdkVersion + + 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 - IDs are cached (calculated once), appState is dynamic (calculated per access) + override val appId: String? by lazy { + idResolver.resolveAppId() + } + + override val onesignalId: String? by lazy { + idResolver.resolveOnesignalId() + } + + override val pushSubscriptionId: String? by lazy { + idResolver.resolvePushSubscriptionId() + } + + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ + override val appState: String + @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() = 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() { + // Log the path on first access so developers know where to find crash logs + Logging.info("OneSignal: Crash logs stored at: $_crashStoragePath") + return _crashStoragePath + } + + override val minFileAgeForReadMillis: Long = 5_000 + + // Remote logging configuration + /** + * The minimum log level to send remotely as a string. + * - If remote config log level is populated and valid: use that level + * - If remote config is null or unavailable: default to "ERROR" (always log errors) + * - If remote config is explicitly NONE: return "NONE" (no logs including errors) + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override val remoteLogLevel: String? by lazy { + try { + val configLevel = idResolver.resolveRemoteLogLevel() + when { + // Remote config is populated and working well - use whatever is sent from there + configLevel != null && configLevel != com.onesignal.debug.LogLevel.NONE -> configLevel.name + // Explicitly NONE means no logging (including errors) + configLevel == com.onesignal.debug.LogLevel.NONE -> "NONE" + // Remote config not available - default to ERROR (always log errors) + else -> "ERROR" + } + } catch (e: Exception) { + // If there's an error accessing config, default to ERROR (always log errors) + // Exception is intentionally swallowed to avoid recursion in logging + "ERROR" + } + } + + override val appIdForHeaders: String + get() = appId ?: "" +} + +/** + * Factory function to create AndroidOtelPlatformProvider without service dependencies. + * Reads all values directly from SharedPreferences and system services. + */ +internal fun createAndroidOtelPlatformProvider( + context: Context, +): OtelPlatformProvider { + val crashStoragePath = context.cacheDir.path + java.io.File.separator + + "onesignal" + java.io.File.separator + + "otel" + java.io.File.separator + + "crashes" + + return OtelPlatformProvider( + OtelPlatformProviderConfig( + crashStoragePath = crashStoragePath, + appPackageId = context.packageName, + appVersion = com.onesignal.common.AndroidUtils.getAppVersion(context) ?: "unknown", + context = context, + ) + ) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt new file mode 100644 index 0000000000..d69b25fd2f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalCrashLogInit.kt @@ -0,0 +1,119 @@ +package com.onesignal.internal + +import android.content.Context +import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.crash.createAnrDetector +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.OtelFactory +import com.onesignal.otel.crash.IOtelAnrDetector + +/** + * Helper class for OneSignal initialization tasks. + * Extracted from OneSignalImp to reduce class size and improve maintainability. + * + * Creates and reuses a single OtelPlatformProvider instance for both crash handler and logging. + */ +internal class OneSignalCrashLogInit( + private val context: Context, +) { + // Platform provider - created once and reused for both crash handler and logging + private val platformProvider: OtelPlatformProvider by lazy { + createAndroidOtelPlatformProvider(context) + } + + @Suppress("TooGenericExceptionCaught") + fun initializeCrashHandler() { + try { + Logging.info("OneSignal: Initializing crash handler early...") + Logging.info("OneSignal: Creating crash handler with minimal dependencies...") + + // Create crash handler directly (non-blocking, doesn't require services upfront) + val logger = AndroidOtelLogger() + val crashHandler: IOtelCrashHandler = OtelFactory.createCrashHandler(platformProvider, logger) + + Logging.info("OneSignal: Crash handler created, initializing...") + crashHandler.initialize() + + // Log crash storage location for debugging + Logging.info("OneSignal: ✅ Crash handler initialized successfully and ready to capture crashes") + Logging.info("OneSignal: 📁 Crash logs will be stored at: ${platformProvider.crashStoragePath}") + Logging.info("OneSignal: 💡 To view crash logs, use: adb shell run-as ${platformProvider.appPackageId} ls -la ${platformProvider.crashStoragePath}") + + // Initialize ANR detector (standalone, monitors main thread for ANRs) + try { + Logging.info("OneSignal: Initializing ANR detector...") + val anrDetector: IOtelAnrDetector = createAnrDetector( + platformProvider, + logger, + anrThresholdMs = com.onesignal.debug.internal.crash.AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + checkIntervalMs = com.onesignal.debug.internal.crash.AnrConstants.DEFAULT_CHECK_INTERVAL_MS + ) + anrDetector.start() + Logging.info("OneSignal: ✅ ANR detector initialized and started") + } catch (e: Exception) { + // If ANR detector initialization fails, log it but don't crash + Logging.error("OneSignal: Failed to initialize ANR detector: ${e.message}", e) + } + } catch (e: Exception) { + // If crash handler initialization fails, log it but don't crash + Logging.error("OneSignal: Failed to initialize crash handler: ${e.message}", e) + } + } + + @Suppress("TooGenericExceptionCaught") + fun initializeOtelLogging() { + // Initialize Otel logging asynchronously to avoid blocking initialization + // Remote logging is not critical for crashes, so it's safe to do this in the background + // Uses OtelIdResolver internally which reads directly from SharedPreferences + // No service dependencies required - fully decoupled from service architecture + suspendifyOnIO { + try { + // Reuses the same platform provider instance created for crash handler + // Get the remote log level as string (defaults to "ERROR" if null, "NONE" if explicitly set) + val remoteLogLevelStr = platformProvider.remoteLogLevel + + // Check if remote logging is enabled (not NONE) + if (remoteLogLevelStr != null && remoteLogLevelStr != "NONE") { + // Store in local variable for smart cast + val logLevelStr = remoteLogLevelStr + Logging.info("OneSignal: Remote logging enabled at level $logLevelStr, initializing Otel logging integration...") + val remoteTelemetry: IOtelOpenTelemetryRemote = OtelFactory.createRemoteTelemetry(platformProvider) + + // Parse the log level string to LogLevel enum for comparison + @Suppress("TooGenericExceptionCaught", "SwallowedException") + val remoteLogLevel: LogLevel = try { + LogLevel.valueOf(logLevelStr) + } catch (e: Exception) { + LogLevel.ERROR // Default to ERROR on parse error + } + + // Create a function that checks if a log level should be sent remotely + // - If remoteLogLevel is null: default to ERROR (send ERROR and above) + // - If remoteLogLevel is NONE: don't send anything (shouldn't reach here, but handle it) + // - Otherwise: send logs at that level and above + val shouldSendLogLevel: (LogLevel) -> Boolean = { level -> + when { + remoteLogLevel == LogLevel.NONE -> false // Don't send anything + else -> level >= remoteLogLevel // Send at configured level and above + } + } + + // Inject Otel telemetry into Logging class + Logging.setOtelTelemetry(remoteTelemetry, shouldSendLogLevel) + Logging.info("OneSignal: ✅ Otel logging integration initialized - logs at level $logLevelStr and above will be sent to remote server") + } else { + Logging.debug("OneSignal: Remote logging disabled (level: $remoteLogLevelStr), skipping Otel logging integration") + } + } catch (e: Exception) { + // If Otel logging initialization fails, log it but don't crash + Logging.warn("OneSignal: Failed to initialize Otel logging: ${e.message}", e) + } + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 57495235a7..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,6 +207,21 @@ internal class OneSignalImp( } private fun initEssentials(context: Context) { + // Create OneSignalCrashLogInit instance once - it manages platform provider lifecycle + // Platform provider is created lazily and reused for both crash handler and logging + val crashLogInit = OneSignalCrashLogInit(context) + + // Crash handler needs to be one of the first things we setup, + // otherwise we'll not report some crashes, resulting in a false sense + // of stability. + // Initialize crash handler early, before any other services that might crash. + // This is decoupled from getService to ensure fast initialization. + crashLogInit.initializeCrashHandler() + + // Initialize Otel logging integration - reuses the same platform provider created in initializeCrashHandler + // No service dependencies required, reads directly from SharedPreferences + crashLogInit.initializeOtelLogging() + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) // start the application service. This is called explicitly first because we want 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/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..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,13 +95,13 @@ class LoggingTests : FunSpec({ test("removeListener nested") { // Given val calls = ArrayList() - var listener: ILogListener? = null - listener = - ILogListener { - calls += it.entry - Logging.removeListener(listener!!) - } - Logging.addListener(listener!!) + lateinit var listener: ILogListener + listener = ILogListener { logEvent -> + calls += logEvent.entry + // Remove self from listeners + Logging.removeListener(listener) + } + Logging.addListener(listener) // When Logging.debug("test") 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 new file mode 100644 index 0000000000..3b71b4d05b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt @@ -0,0 +1,57 @@ +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.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.otel.IOtelCrashHandler +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +@RobolectricTest +class OneSignalCrashHandlerFactoryTest : FunSpec({ + 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( + appContext!!, + logger!! + ) + + handler.shouldBeInstanceOf() + } + + 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( + appContext!!, + logger!! + ) + + handler shouldNotBe null + // Should be able to initialize + handler.initialize() + } + + 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( + appContext!!, + logger!! + ) + + handler shouldNotBe null + handler.initialize() // Should not crash + } +}) 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 new file mode 100644 index 0000000000..bcce464249 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt @@ -0,0 +1,167 @@ +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.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.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.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 + +// 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({ + 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 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") + } + 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, "test-install-id") + .commit() + } + + afterEach { + sharedPreferences!!.edit().clear().commit() + } + + test("AndroidOtelPlatformProvider should provide correct Android values") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + + provider.shouldBeInstanceOf() + provider.sdkBase shouldBe "android" + 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 + 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 = createAndroidOtelPlatformProvider(appContext!!) + + provider.appId shouldBe "test-app-id" + provider.onesignalId shouldBe "test-onesignal-id" + provider.pushSubscriptionId shouldBe "test-subscription-id" + provider.appState shouldBeOneOf listOf("foreground", "background", "unknown") + (provider.processUptime > 0.0) shouldBe true + provider.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 = createAndroidOtelPlatformProvider(appContext!!) + 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") { + // 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() + handler.initialize() // Should not throw + } + + test("AndroidOtelPlatformProvider should provide crash storage path") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + + 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") { + 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/LoggingOtelTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt new file mode 100644 index 0000000000..6bde1defb8 --- /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 shouldSend = { _: LogLevel -> true } + + // When + Logging.setOtelTelemetry(mockTelemetry, shouldSend) + + // 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, { _: LogLevel -> 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, { _: LogLevel -> 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, { _: LogLevel -> 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, { _: LogLevel -> true }) + + // When + Logging.verbose("verbose message") + Logging.debug("debug message") + Logging.info("info message") + Logging.warn("warn message") + Logging.error("error message") + Logging.fatal("fatal message") + + // 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, { _: LogLevel -> 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, { _: LogLevel -> 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, { _: LogLevel -> 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, { _: LogLevel -> true }) + // Note: We can't mock getLogger() to throw due to OpenTelemetry type visibility, + // but the real implementation in Logging.logToOtel() handles errors gracefully + + // When + Logging.info("test message") + + // 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, { _: LogLevel -> 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, { _: LogLevel -> 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, { _: LogLevel -> 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/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/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 } 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..4ab4a7e8b6 --- /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. + */ +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..510ffab2eb --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt @@ -0,0 +1,35 @@ +package com.onesignal.otel + +/** + * Platform-agnostic logger interface for the Otel module. + * 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 new file mode 100644 index 0000000000..6a1843d722 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt @@ -0,0 +1,38 @@ +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 { + /** + * 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 +} + +/** + * 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..83dfdb3357 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt @@ -0,0 +1,49 @@ +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) + /** + * 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 + 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 + /** + * 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 new file mode 100644 index 0000000000..6592e6be59 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt @@ -0,0 +1,133 @@ +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 +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 +} + +/** + * 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: OtelFieldsTopLevel, + private val osPerEventFields: OtelFieldsPerEvent, +) : IOtelOpenTelemetry { + private val lock = Any() + private var sdkCachedValue: OpenTelemetrySdk? = null + + protected suspend fun getSdk(): OpenTelemetrySdk { + val attributes = osTopLevelFields.getAttributes() + synchronized(lock) { + var localSdk = sdkCachedValue + if (localSdk != null) { + return localSdk + } + + localSdk = getSdkInstance(attributes) + sdkCachedValue = 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(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()) +} + +internal class OneSignalOpenTelemetryRemote( + 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, + "X-OneSignal-SDK-Version" to platformProvider.sdkBaseVersion, + ) + } + + override val logExporter by lazy { + OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders, appId) + } + + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = + OpenTelemetrySdk + .builder() + .setLoggerProvider( + OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create(attributes), + extraHttpHeaders, + appId + ) + ).build() +} + +internal class OneSignalOpenTelemetryCrashLocal( + private val platformProvider: IOtelPlatformProvider, + osTopLevelFields: OtelFieldsTopLevel, + osPerEventFields: OtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields), + IOtelOpenTelemetryCrash { + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = + OpenTelemetrySdk + .builder() + .setLoggerProvider( + OtelConfigCrashFile.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create( + attributes + ), + 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..c4e46e6630 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt @@ -0,0 +1,112 @@ +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. + * + * This method composes other factory methods to create the crash handler, + * ensuring consistency and reducing duplication. + */ + fun createCrashHandler( + platformProvider: IOtelPlatformProvider, + logger: IOtelLogger, + ): IOtelCrashHandler { + val crashLocal = createCrashLocalTelemetry(platformProvider) + val crashReporter = createCrashReporter(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 + ) + } + + /** + * 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/OtelLoggingHelper.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt new file mode 100644 index 0000000000..8b1c85c7b0 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt @@ -0,0 +1,65 @@ +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. + * 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.setTimestamp(Instant.now()) + 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/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt new file mode 100644 index 0000000000..aa99748589 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt @@ -0,0 +1,50 @@ +package com.onesignal.otel.config + +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 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 + ): FileLogRecordStorage = + FileLogRecordStorage.create( + File(rootDir), + FileStorageConfiguration + .builder() + .setMaxFileAgeForWriteMillis(MAX_FILE_AGE_FOR_WRITE_MILLIS) + .setMinFileAgeForReadMillis(minFileAgeForReadMillis) + .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds) + .build() + ) + + fun create( + resource: io.opentelemetry.sdk.resources.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(OtelConfigShared.LogLimitsConfig::logLimits) + .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 new file mode 100644 index 0000000000..2e46d87cef --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt @@ -0,0 +1,52 @@ +package com.onesignal.otel.config + +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import java.time.Duration + +internal class OtelConfigRemoteOneSignal { + object LogRecordExporterConfig { + private const val EXPORTER_TIMEOUT_SECONDS = 10L + + 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(EXPORTER_TIMEOUT_SECONDS)) + return builder.build() + } + } + + object SdkLoggerProviderConfig { + // NOTE: Switch to https://sdklogs.onesignal.com:443/sdk/otel when ready + 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, appId) + ) + ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) + .build() + } + + object HttpRecordBatchExporter { + fun create(extraHttpHeaders: Map, appId: String) = + LogRecordExporterConfig.otlpHttpLogRecordExporter( + extraHttpHeaders, + "${SdkLoggerProviderConfig.BASE_URL}/v1/logs?app_id=$appId" + ) + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt new file mode 100644 index 0000000000..f54b3d5590 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt @@ -0,0 +1,58 @@ +package com.onesignal.otel.config + +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(attributes: Map): Resource = + Resource + .getDefault() + .toBuilder() + .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") + .putAll(attributes) + .build() + } + + object LogRecordProcessorConfig { + 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(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(MAX_NUMBER_OF_ATTRIBUTES) + .setMaxAttributeValueLength(MAX_ATTRIBUTE_VALUE_LENGTH) + .build() + } +} 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 new file mode 100644 index 0000000000..ebe744e045 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt @@ -0,0 +1,121 @@ +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}") + + // 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. + // 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...") + + /** + * 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) + } +} + +/** + * 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 = + 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 new file mode 100644 index 0000000000..b875f906cb --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt @@ -0,0 +1,63 @@ +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 +import java.time.Instant + +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" + private const val OTEL_EXCEPTION_THREAD_NAME = "ossdk.exception.thread.name" + } + + 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(OTEL_EXCEPTION_THREAD_NAME, thread.name) + .build() + + logger.debug("OtelCrashReporter: Creating log record with attributes...") + openTelemetry + .getLogger() + .setAllAttributes(attributes) + .setSeverity(Severity.FATAL) + .setTimestamp(Instant.now()) + .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/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt new file mode 100644 index 0000000000..d9091b8a32 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt @@ -0,0 +1,91 @@ +package com.onesignal.otel.crash + +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 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() + * } + * ``` + */ +class OtelCrashUploader( + private val openTelemetryRemote: IOtelOpenTelemetryRemote, + private val platformProvider: IOtelPlatformProvider, + private val logger: IOtelLogger, +) { + companion object { + const val SEND_TIMEOUT_SECONDS = 30L + } + + private fun getReports() = + OtelConfigCrashFile.SdkLoggerProviderConfig + .getFileLogRecordStorage( + platformProvider.crashStoragePath, + 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 (NONE level), this function returns immediately without doing anything. + */ + suspend fun start() { + val remoteLogLevel = platformProvider.remoteLogLevel + if (remoteLogLevel == null || remoteLogLevel == "NONE") { + logger.info("OtelCrashUploader: remote logging disabled (level: $remoteLogLevel)") + return + } + + logger.info("OtelCrashUploader: starting") + 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(platformProvider.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()) + logger.debug("Sending OneSignal crash report") + val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + failed = !result.isSuccess + 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..cf29a0f210 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt @@ -0,0 +1,203 @@ +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 +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.remoteLogLevel } returns "ERROR" + every { mockPlatformProvider.appIdForHeaders } returns "test-app-id" + 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 + } +}) 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'