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