From 2f2af829848b49a5b906e4e287085d3e8ca81ff4 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Wed, 4 Mar 2026 11:17:38 -0500 Subject: [PATCH 1/2] feat: tel crash reporting (#2511) Co-authored-by: Josh Kasten Co-authored-by: AR Abdul Azeez Co-authored-by: Cursor --- OneSignalSDK/build.gradle | 16 +- OneSignalSDK/detekt/detekt-baseline-core.xml | 64 +- OneSignalSDK/detekt/detekt-config.yml | 2 +- OneSignalSDK/onesignal/core/build.gradle | 2 + .../core/src/main/AndroidManifest.xml | 7 +- .../java/com/onesignal/common/JSONUtils.kt | 2 +- .../java/com/onesignal/core/CoreModule.kt | 4 + .../internal/backend/IParamsBackendService.kt | 15 +- .../backend/impl/ParamsBackendService.kt | 12 + .../background/impl/BackgroundManager.kt | 2 +- .../core/internal/config/ConfigModel.kt | 43 +- .../core/internal/config/ConfigModelStore.kt | 3 +- .../config/impl/ConfigModelStoreListener.kt | 3 + .../core/internal/http/OneSignalService.kt | 7 + .../core/internal/http/impl/HttpClient.kt | 7 +- .../internal/operations/impl/OperationRepo.kt | 6 +- .../com/onesignal/core/internal/time/ITime.kt | 5 + .../onesignal/core/internal/time/impl/Time.kt | 6 + .../main/java/com/onesignal/debug/LogLevel.kt | 14 + .../debug/internal/crash/AnrConstants.kt | 19 + .../crash/OneSignalCrashHandlerFactory.kt | 38 + .../crash/OneSignalCrashUploaderWrapper.kt | 60 + .../debug/internal/crash/OtelAnrDetector.kt | 221 ++++ .../debug/internal/crash/OtelSdkSupport.kt | 27 + .../debug/internal/logging/Logging.kt | 75 ++ .../logging/otel/android/AndroidOtelLogger.kt | 26 + .../logging/otel/android/OtelIdResolver.kt | 247 ++++ .../otel/android/OtelPlatformProvider.kt | 164 +++ .../com/onesignal/internal/OneSignalImp.kt | 6 + .../onesignal/internal/OtelConfigEvaluator.kt | 68 ++ .../internal/OtelLifecycleManager.kt | 240 ++++ .../outcomes/impl/OutcomeEventsController.kt | 8 +- .../internal/session/impl/SessionListener.kt | 2 +- .../internal/identity/IdentityModelStore.kt | 4 +- .../core/src/test/AndroidManifest.xml | 7 + .../onesignal/debug/internal/LoggingTests.kt | 14 +- .../crash/OneSignalCrashHandlerFactoryTest.kt | 75 ++ .../OneSignalCrashUploaderWrapperTest.kt | 104 ++ .../internal/crash/OtelAnrDetectorTest.kt | 221 ++++ .../internal/crash/OtelIntegrationTest.kt | 167 +++ .../internal/crash/OtelSdkSupportTest.kt | 38 + .../debug/internal/logging/LoggingOtelTest.kt | 232 ++++ .../debug/internal/logging/LoggingTest.kt | 360 ++++++ .../otel/android/AndroidOtelLoggerTest.kt | 74 ++ .../otel/android/OtelIdResolverTest.kt | 1051 +++++++++++++++++ .../otel/android/OtelPlatformProviderTest.kt | 903 ++++++++++++++ .../internal/OtelConfigEvaluatorTest.kt | 102 ++ .../internal/OtelLifecycleManagerFaultTest.kt | 311 +++++ .../internal/OtelLifecycleManagerTest.kt | 103 ++ .../internal/InAppMessagesManager.kt | 2 +- .../backend/impl/InAppBackendService.kt | 2 +- .../internal/display/impl/InAppDisplayer.kt | 2 +- .../internal/display/impl/InAppMessageView.kt | 2 +- .../internal/hydrators/InAppHydrator.kt | 2 +- .../location/internal/LocationManager.kt | 2 +- .../controller/impl/HmsLocationController.kt | 6 +- .../bridges/OneSignalHmsEventBridge.kt | 2 +- .../impl/NotificationChannelManager.kt | 4 +- .../internal/common/OSWorkManagerHelper.kt | 4 +- .../data/impl/NotificationRepository.kt | 2 +- .../impl/NotificationGenerationProcessor.kt | 6 +- .../impl/NotificationLifecycleService.kt | 15 +- .../internal/pushtoken/PushTokenManager.kt | 4 +- .../impl/ReceiveReceiptProcessor.kt | 2 +- .../registration/impl/PushRegistratorADM.kt | 4 +- .../impl/PushRegistratorAbstractGoogle.kt | 14 +- .../registration/impl/PushRegistratorHMS.kt | 4 +- .../impl/NotificationRestoreProcessor.kt | 2 +- .../impl/NotificationRestoreWorkManager.kt | 3 +- .../services/ADMMessageHandler.kt | 4 +- .../services/ADMMessageHandlerJob.kt | 4 +- OneSignalSDK/onesignal/otel/.gitignore | 1 + OneSignalSDK/onesignal/otel/build.gradle | 67 ++ .../onesignal/otel/consumer-rules.pro | 0 .../onesignal/otel/proguard-rules.pro | 21 + .../otel/src/main/AndroidManifest.xml | 4 + .../com/onesignal/otel/IOtelCrashHandler.kt | 19 + .../com/onesignal/otel/IOtelCrashReporter.kt | 8 + .../java/com/onesignal/otel/IOtelLogger.kt | 35 + .../com/onesignal/otel/IOtelOpenTelemetry.kt | 45 + .../onesignal/otel/IOtelPlatformProvider.kt | 64 + .../onesignal/otel/OneSignalOpenTelemetry.kt | 148 +++ .../java/com/onesignal/otel/OtelFactory.kt | 112 ++ .../com/onesignal/otel/OtelLoggingHelper.kt | 65 + .../otel/attributes/OtelFieldsPerEvent.kt | 35 + .../otel/attributes/OtelFieldsTopLevel.kt | 42 + .../otel/config/OtelConfigCrashFile.kt | 50 + .../otel/config/OtelConfigRemoteOneSignal.kt | 57 + .../onesignal/otel/config/OtelConfigShared.kt | 58 + .../onesignal/otel/crash/IOtelAnrDetector.kt | 21 + .../onesignal/otel/crash/OtelCrashHandler.kt | 127 ++ .../onesignal/otel/crash/OtelCrashReporter.kt | 63 + .../onesignal/otel/crash/OtelCrashUploader.kt | 91 ++ .../otel/OneSignalOpenTelemetryTest.kt | 181 +++ .../com/onesignal/otel/OtelFactoryTest.kt | 204 ++++ .../onesignal/otel/OtelLoggingHelperTest.kt | 145 +++ .../otel/attributes/OtelFieldsPerEventTest.kt | 69 ++ .../otel/attributes/OtelFieldsTopLevelTest.kt | 78 ++ .../onesignal/otel/config/OtelConfigTest.kt | 136 +++ .../otel/crash/OtelCrashHandlerTest.kt | 169 +++ .../otel/crash/OtelCrashReporterTest.kt | 146 +++ .../otel/crash/OtelCrashUploaderTest.kt | 89 ++ OneSignalSDK/settings.gradle | 1 + examples/demo/app/build.gradle.kts | 9 +- .../sdktest/application/MainApplication.kt | 3 +- .../sdktest/ui/secondary/SecondaryActivity.kt | 40 +- examples/demo/build.gradle.kts | 5 +- 107 files changed, 7555 insertions(+), 132 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt create mode 100644 OneSignalSDK/onesignal/otel/.gitignore create mode 100644 OneSignalSDK/onesignal/otel/build.gradle create mode 100644 OneSignalSDK/onesignal/otel/consumer-rules.pro create mode 100644 OneSignalSDK/onesignal/otel/proguard-rules.pro create mode 100644 OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt create mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt create mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index eab205d258..c02338492e 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -14,8 +14,8 @@ buildscript { huaweiAgconnectVersion = '1.9.1.304' huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' - kotlinVersion = '1.9.25' - dokkaVersion = '1.9.10' // Dokka version compatible with Kotlin 1.9.25 + kotlinVersion = '2.2.0' + dokkaVersion = '1.9.10' coroutinesVersion = '1.7.3' kotestVersion = '5.8.0' ioMockVersion = '1.13.2' @@ -25,6 +25,10 @@ buildscript { ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10) spotlessVersion = '6.25.0' tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP + // OpenTelemetry versions + opentelemetryBomVersion = '1.55.0' + opentelemetrySemconvVersion = '1.37.0' + opentelemetryDiskBufferingVersion = '1.51.0-alpha' sharedRepos = { google() @@ -45,11 +49,9 @@ buildscript { ] } - buildscript { - repositories sharedRepos - dependencies { - classpath sharedDeps - } + repositories sharedRepos + dependencies { + classpath sharedDeps } } diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 20c78da602..797b08f41e 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -6,10 +6,11 @@ ComplexCondition:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$args.size == 4 && args[0] == Int::class.javaPrimitiveType && args[1] == String::class.java && args[2] == String::class.java && args[3] == Bundle::class.java && returnType == Bundle::class.java ComplexCondition:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$args.size == 4 && args[0] == Int::class.javaPrimitiveType && args[1] == String::class.java && args[2] == String::class.java && args[3] == String::class.java ComplexMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams() - ComplexMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse + ComplexMethod:HttpClient.kt$HttpClient$@OptIn(DelicateCoroutinesApi::class) private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse ComplexMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse ComplexMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse ComplexMethod:OSDatabase.kt$OSDatabase$@Synchronized private fun internalOnUpgrade( db: SQLiteDatabase, oldVersion: Int, newVersion: Int, ) + ComplexMethod:OneSignalImp.kt$OneSignalImp$override fun initWithContext( context: Context, appId: String?, ): Boolean ComplexMethod:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ComplexMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) ComplexMethod:PreferencesService.kt$PreferencesService$private fun get( store: String, key: String, type: Class<*>, defValue: Any?, ): Any? @@ -138,6 +139,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 @@ -157,26 +159,30 @@ ForbiddenComment:HttpClient.kt$HttpClient$// TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT? ForbiddenComment:IPreferencesService.kt$PreferenceOneSignalKeys$* (String) The serialized IAMs TODO: This isn't currently used, determine if actually needed for cold start IAM fetch delay ForbiddenComment:IUserBackendService.kt$IUserBackendService$// TODO: Change to send only the push subscription, optimally - ForbiddenComment:LoginHelper.kt$LoginHelper$// TODO: Set JWT Token for all future requests. - ForbiddenComment:LogoutHelper.kt$LogoutHelper$// TODO: remove JWT Token for all future requests. + ForbiddenComment:OneSignalImp.kt$OneSignalImp$// TODO: Set JWT Token for all future requests. + ForbiddenComment:OneSignalImp.kt$OneSignalImp$// TODO: remove JWT Token for all future requests. ForbiddenComment:OperationRepo.kt$OperationRepo$// TODO: Need to provide callback for app to reset JWT. For now, fail with no retry. ForbiddenComment:ParamsBackendService.kt$ParamsBackendService$// TODO: New ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO after we remove IAM from being an activity window we may be able to remove this handler ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO improve this method - ForbiddenComment:PermissionsViewModel.kt$PermissionsViewModel.Companion$// TODO this will be removed once the handler is deleted + ForbiddenComment:PermissionsActivity.kt$PermissionsActivity.Companion$// TODO this will be removed once the handled is deleted ForbiddenComment:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$// TODO: whenever the end-user changes users, we need to add the read-your-write token here, currently no code to handle the re-fetch IAMs ForbiddenComment:TrackGooglePurchase.kt$TrackGooglePurchase$// TODO: Handle very large list. Test for continuationToken != null then call getPurchases again FunctionOnlyReturningConstant:AndroidUtils.kt$AndroidUtils$@Keep fun opaqueHasClass(_class: Class<*>): Boolean FunctionParameterNaming:AndroidUtils.kt$AndroidUtils$_class: Class<*> FunctionParameterNaming:JSONUtils.kt$JSONUtils$`object`: Any + GlobalCoroutineUsage:HttpClient.kt$HttpClient$GlobalScope.launch(Dispatchers.IO) { var httpResponse = -1 var con: HttpURLConnection? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { TrafficStats.setThreadStatsTag(THREAD_ID) } try { con = _connectionFactory.newHttpURLConnection(url) // https://github.com/OneSignal/OneSignal-Android-SDK/issues/1465 // Android 4.4 and older devices fail to register to onesignal.com to due it's TLS1.2+ requirement if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1 && con is HttpsURLConnection) { val conHttps = con conHttps.sslSocketFactory = TLS12SocketFactory( conHttps.sslSocketFactory, ) } con.useCaches = false con.connectTimeout = timeout con.readTimeout = timeout con.setRequestProperty(HTTP_SDK_VERSION_HEADER_KEY, HTTP_SDK_VERSION_HEADER_VALUE) if (OneSignalWrapper.sdkType != null && OneSignalWrapper.sdkVersion != null) { con.setRequestProperty("SDK-Wrapper", "onesignal/${OneSignalWrapper.sdkType}/${OneSignalWrapper.sdkVersion}") } con.setRequestProperty("Accept", OS_ACCEPT_HEADER) val subscriptionId = _configModelStore.model.pushSubscriptionId if (subscriptionId != null && subscriptionId.isNotEmpty()) { con.setRequestProperty("OneSignal-Subscription-Id", subscriptionId) } con.setRequestProperty("OneSignal-Install-Id", _installIdService.getId().toString()) if (jsonBody != null) { con.doInput = true } if (method != null) { con.setRequestProperty("Content-Type", "application/json; charset=UTF-8") con.requestMethod = method con.doOutput = true } logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties) if (jsonBody != null) { val strJsonBody = JSONUtils.toUnescapedEUIDString(jsonBody) val sendBytes = strJsonBody.toByteArray(charset("UTF-8")) con.setFixedLengthStreamingMode(sendBytes.size) val outputStream = con.outputStream outputStream.write(sendBytes) } // H E A D E R S if (headers?.cacheKey != null) { val eTag = _prefs.getString( PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_ETAG_PREFIX + headers.cacheKey, ) if (eTag != null) { con.setRequestProperty("If-None-Match", eTag) Logging.debug("HttpClient: Adding header if-none-match: $eTag") } } if (headers?.rywToken != null) { con.setRequestProperty("OneSignal-RYW-Token", headers.rywToken.toString()) } if (headers?.retryCount != null) { con.setRequestProperty("Onesignal-Retry-Count", headers.retryCount.toString()) } if (headers?.sessionDuration != null) { con.setRequestProperty("OneSignal-Session-Duration", headers.sessionDuration.toString()) } // Network request is made from getResponseCode() httpResponse = con.responseCode val retryAfter = retryAfterFromResponse(con) val retryLimit = retryLimitFromResponse(con) val newDelayUntil = _time.currentTimeMillis + (retryAfter ?: 0) * 1_000 if (newDelayUntil > delayNewRequestsUntil) delayNewRequestsUntil = newDelayUntil when (httpResponse) { HttpURLConnection.HTTP_NOT_MODIFIED -> { val cachedResponse = _prefs.getString( PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_HTTP_CACHE_PREFIX + headers?.cacheKey, ) Logging.debug( "HttpClient: Got Response = ${method ?: "GET"} ${con.url} - Using Cached response due to 304: " + cachedResponse, ) // TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT? retVal = HttpResponse(httpResponse, cachedResponse, retryAfterSeconds = retryAfter, retryLimit = retryLimit) } HttpURLConnection.HTTP_ACCEPTED, HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_OK -> { val inputStream = con.inputStream val scanner = Scanner(inputStream, "UTF-8") val json = if (scanner.useDelimiter("\\A").hasNext()) scanner.next() else "" scanner.close() Logging.debug( "HttpClient: Got Response = ${method ?: "GET"} ${con.url} - STATUS: $httpResponse - Body: " + json, ) if (headers?.cacheKey != null) { val eTag = con.getHeaderField("etag") if (eTag != null) { Logging.debug("HttpClient: Got Response = Response has etag of $eTag so caching the response.") _prefs.saveString( PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_ETAG_PREFIX + headers.cacheKey, eTag, ) _prefs.saveString( PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_HTTP_CACHE_PREFIX + headers.cacheKey, json, ) } } retVal = HttpResponse(httpResponse, json, retryAfterSeconds = retryAfter, retryLimit = retryLimit) } else -> { Logging.debug("HttpClient: Got Response = ${method ?: "GET"} ${con.url} - FAILED STATUS: $httpResponse") var inputStream = con.errorStream if (inputStream == null) { inputStream = con.inputStream } var jsonResponse: String? = null if (inputStream != null) { val scanner = Scanner(inputStream, "UTF-8") jsonResponse = if (scanner.useDelimiter("\\A").hasNext()) scanner.next() else "" scanner.close() Logging.warn("HttpClient: Got Response = $method - STATUS: $httpResponse - Body: $jsonResponse") } else { Logging.warn("HttpClient: Got Response = $method - STATUS: $httpResponse - No response body!") } retVal = HttpResponse(httpResponse, jsonResponse, retryAfterSeconds = retryAfter, retryLimit = retryLimit) } } } catch (t: Throwable) { if (t is ConnectException || t is UnknownHostException) { Logging.info("HttpClient: Could not send last request, device is offline. Throwable: " + t.javaClass.name) } else { Logging.warn("HttpClient: $method Error thrown from network stack. ", t) } retVal = HttpResponse(httpResponse, null, t) } finally { con?.disconnect() } } + GlobalCoroutineUsage:PreferencesService.kt$PreferencesService$GlobalScope.async(Dispatchers.IO) { var lastSyncTime = _time.currentTimeMillis while (true) { try { // go through all outstanding items to process for (storeKey in prefsToApply.keys) { val storeMap = prefsToApply[storeKey]!! val prefsToWrite = getSharedPrefsByName(storeKey) if (prefsToWrite == null) { // the assumption here is there is no context yet, but will be. So ensure // we wake up to try again and persist the preference. waiter.wake() continue } val editor = prefsToWrite.edit() synchronized(storeMap) { for (key in storeMap.keys) { when (val value = storeMap[key]) { is String -> editor.putString(key, value as String?) is Boolean -> editor.putBoolean(key, (value as Boolean?)!!) is Int -> editor.putInt(key, (value as Int?)!!) is Long -> editor.putLong(key, (value as Long?)!!) is Set<*> -> editor.putStringSet(key, value as Set<String?>?) null -> editor.remove(key) } } storeMap.clear() } editor.apply() } // potentially delay to prevent this from constant IO if a bunch of // preferences are set sequentially. val newTime = _time.currentTimeMillis val delay = lastSyncTime - newTime + WRITE_CALL_DELAY_TO_BUFFER_MS lastSyncTime = newTime if (delay > 0) { delay(delay) } // wait to be woken up for the next pass waiter.waitForWake() } catch (e: Throwable) { Logging.log(LogLevel.ERROR, "Error with Preference work loop", e) } } } + GlobalCoroutineUsage:RecoverFromDroppedLoginBug.kt$RecoverFromDroppedLoginBug$GlobalScope.launch(Dispatchers.IO) { _operationRepo.awaitInitialized() if (isInBadState()) { Logging.warn( "User with externalId:" + "${_identityModelStore.model.externalId} " + "was in a bad state, causing it to not update on OneSignal's " + "backend! We are recovering and replaying all unsent " + "operations now.", ) recoverByAddingBackDroppedLoginOperation() } } InstanceOfCheckForException:HttpClient.kt$HttpClient$t is ConnectException InstanceOfCheckForException:HttpClient.kt$HttpClient$t is UnknownHostException LongMethod:ApplicationService.kt$ApplicationService$override suspend fun waitUntilSystemConditionsAvailable(): Boolean LongMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams() - LongMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse + LongMethod:HttpClient.kt$HttpClient$@OptIn(DelicateCoroutinesApi::class) private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse LongMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + LongMethod:OneSignalImp.kt$OneSignalImp$override fun initWithContext( context: Context, appId: String?, ): Boolean LongMethod:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? LongMethod:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? @@ -191,15 +197,14 @@ 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, ) LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, ) LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, ) LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, ) LongParameterList:SubscriptionObject.kt$SubscriptionObject$( val id: String? = null, val type: SubscriptionObjectType? = null, val token: String? = null, val enabled: Boolean? = null, val notificationTypes: Int? = null, val sdk: String? = null, val deviceModel: String? = null, val deviceOS: String? = null, val rooted: Boolean? = null, val netType: Int? = null, val carrier: String? = null, val appVersion: String? = null, ) LongParameterList:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$( private val _subscriptionBackend: ISubscriptionBackendService, private val _deviceService: IDeviceService, private val _applicationService: IApplicationService, private val _subscriptionModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, ) - LongParameterList:UserSwitcher.kt$UserSwitcher$( private val preferencesService: IPreferencesService, private val operationRepo: IOperationRepo, private val services: ServiceProvider, private val idManager: IDManager = IDManager, private val identityModelStore: IdentityModelStore, private val propertiesModelStore: PropertiesModelStore, private val subscriptionModelStore: SubscriptionModelStore, private val configModel: ConfigModel, private val oneSignalUtils: OneSignalUtils = OneSignalUtils, private val carrierName: String? = null, private val deviceOS: String? = null, private val androidUtils: AndroidUtils = AndroidUtils, private val appContextProvider: () -> Context, ) LoopWithTooManyJumpStatements:ModelStore.kt$ModelStore$for (index in jsonArray.length() - 1 downTo 0) { val newModel = create(jsonArray.getJSONObject(index)) ?: continue /* * NOTE: Migration fix for bug introduced in 5.1.12 * The following check is intended for the operation model store. * When the call to this method moved out of the operation model store's initializer, * duplicate operations could be cached. * See https://github.com/OneSignal/OneSignal-Android-SDK/pull/2099 */ val hasExisting = models.any { it.id == newModel.id } if (hasExisting) { Logging.debug("ModelStore<$name>: load - operation.id: ${newModel.id} already exists in the store.") continue } models.add(0, newModel) // listen for changes to this model newModel.subscribe(this) } MagicNumber:ApplicationService.kt$ApplicationService$50 MagicNumber:BackgroundManager.kt$BackgroundManager$5000 @@ -226,7 +231,6 @@ MagicNumber:OSDatabase.kt$OSDatabase$7 MagicNumber:OSDatabase.kt$OSDatabase$8 MagicNumber:OSDatabase.kt$OSDatabase$9 - MagicNumber:OneSignalDispatchers.kt$OneSignalDispatchers$1024 MagicNumber:OperationRepo.kt$OperationRepo$1_000 MagicNumber:OutcomeEventsController.kt$OutcomeEventsController$1000 MagicNumber:PermissionsActivity.kt$PermissionsActivity$23 @@ -273,11 +277,11 @@ PrintStackTrace:DeviceUtils.kt$DeviceUtils$t PrintStackTrace:JSONUtils.kt$JSONUtils$e PrintStackTrace:OSDatabase.kt$OSDatabase$e + PrintStackTrace:OneSignalImp.kt$OneSignalImp$e PrintStackTrace:OutcomeTableProvider.kt$OutcomeTableProvider$e PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase$e PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e - ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model? ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse @@ -291,12 +295,12 @@ ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int? ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long? ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T? + ReturnCount:OneSignalImp.kt$OneSignalImp$override fun initWithContext( context: Context, appId: String?, ): Boolean ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? - ReturnCount:PermissionsViewModel.kt$PermissionsViewModel$private fun shouldShowSettings( permission: String, shouldShowRationaleAfter: Boolean, ): Boolean - ReturnCount:PermissionsViewModel.kt$PermissionsViewModel$suspend fun initialize( activity: Activity, permissionType: String?, androidPermission: String?, ): Boolean + ReturnCount:PermissionsActivity.kt$PermissionsActivity$private fun shouldShowSettings(permission: String): Boolean ReturnCount:PreferenceStoreFix.kt$PreferenceStoreFix$fun ensureNoObfuscatedPrefStore(context: Context) ReturnCount:PreferencesService.kt$PreferencesService$private fun get( store: String, key: String, type: Class<*>, defValue: Any?, ): Any? ReturnCount:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$override fun getUpdateOperation( model: PropertiesModel, path: String, property: String, oldValue: Any?, newValue: Any?, ): Operation? @@ -308,6 +312,7 @@ SpreadOperator:AndroidUtils.kt$AndroidUtils$(*packageInfo.requestedPermissions) SpreadOperator:ServiceRegistration.kt$ServiceRegistrationReflection$(*paramList.toTypedArray()) StringLiteralDuplication:OSDatabase.kt$OSDatabase$"Error closing transaction! " + StringLiteralDuplication:OneSignalImp.kt$OneSignalImp$"Must call 'initWithContext' before use" StringLiteralDuplication:OutcomesDbContract.kt$OutcomesDbContract$"CREATE TABLE " SwallowedException:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$ex: BadTokenException SwallowedException:AndroidUtils.kt$AndroidUtils$e: PackageManager.NameNotFoundException @@ -317,21 +322,17 @@ SwallowedException:JSONUtils.kt$JSONUtils$t: Throwable SwallowedException:PermissionsActivity.kt$PermissionsActivity$e: ClassNotFoundException SwallowedException:PreferencesService.kt$PreferencesService$ex: Exception - SwallowedException:SyncJobService.kt$SyncJobService$e: Exception SwallowedException:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$t: Throwable - ThrowsCount:OneSignalImp.kt$OneSignalImp$private suspend fun waitUntilInitInternal(operationName: String? = null) TooGenericExceptionCaught:AndroidUtils.kt$AndroidUtils$e: Throwable TooGenericExceptionCaught:DeviceUtils.kt$DeviceUtils$t: Throwable TooGenericExceptionCaught:HttpClient.kt$HttpClient$e: Throwable TooGenericExceptionCaught:HttpClient.kt$HttpClient$t: Throwable TooGenericExceptionCaught:JSONUtils.kt$JSONUtils$t: Throwable TooGenericExceptionCaught:Logging.kt$Logging$t: Throwable - TooGenericExceptionCaught:OneSignalDispatchers.kt$OneSignalDispatchers$e: Exception TooGenericExceptionCaught:OperationRepo.kt$OperationRepo$e: Throwable TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$ex: Exception - TooGenericExceptionCaught:SyncJobService.kt$SyncJobService$e: Exception TooGenericExceptionCaught:ThreadUtils.kt$e: Exception TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$t: Throwable @@ -345,11 +346,14 @@ TooGenericExceptionThrown:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$throw Exception("Unrecognized operation: $startingOp") TooGenericExceptionThrown:Model.kt$Model$throw Exception("If parent model is set, parent property must also be set.") TooGenericExceptionThrown:Model.kt$Model$throw Exception("If parent property is set, parent model must also be set.") + TooGenericExceptionThrown:OneSignalImp.kt$OneSignalImp$throw Exception( "Must call 'initWithContext' before use", ) + TooGenericExceptionThrown:OneSignalImp.kt$OneSignalImp$throw Exception("Must call 'initWithContext' before 'login'") + TooGenericExceptionThrown:OneSignalImp.kt$OneSignalImp$throw Exception("Must call 'initWithContext' before 'logout'") TooGenericExceptionThrown:OperationModelStore.kt$OperationModelStore$throw Exception("Unrecognized operation: $operationName") TooGenericExceptionThrown:OperationRepo.kt$OperationRepo$throw Exception("Both comparison keys can not be blank!") TooGenericExceptionThrown:OperationRepo.kt$OperationRepo$throw Exception("Could not find executor for operation ${startingOp.operation.name}") TooGenericExceptionThrown:PermissionsActivity.kt$PermissionsActivity$throw RuntimeException( "Could not find callback class for PermissionActivity: $className", ) - TooGenericExceptionThrown:PermissionsViewModel.kt$PermissionsViewModel$throw RuntimeException("Missing handler for permissionRequestType: $type") + TooGenericExceptionThrown:PermissionsActivity.kt$PermissionsActivity$throw RuntimeException("Missing handler for permissionRequestType: $permissionRequestType") TooGenericExceptionThrown:PreferencesService.kt$PreferencesService$throw Exception("Store not found: $store") TooGenericExceptionThrown:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$throw Exception("Unrecognized operation(s)! Attempted operations:\n$operations") TooGenericExceptionThrown:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$throw Exception("Unrecognized operation: $startingOp") @@ -366,16 +370,14 @@ TooManyFunctions:ApplicationService.kt$ApplicationService : IApplicationServiceActivityLifecycleCallbacksOnGlobalLayoutListener TooManyFunctions:BackgroundManager.kt$BackgroundManager : IApplicationLifecycleHandlerIBackgroundManagerIStartableService TooManyFunctions:HttpClient.kt$HttpClient : IHttpClient - TooManyFunctions:IOneSignal.kt$IOneSignal 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 TooManyFunctions:OSDatabase.kt$OSDatabase : SQLiteOpenHelperIDatabase - TooManyFunctions:OneSignal.kt$OneSignal$OneSignal - TooManyFunctions:OneSignalImp.kt$OneSignalImp : IOneSignalIServiceProvider TooManyFunctions:OperationRepo.kt$OperationRepo : IOperationRepoIStartableService TooManyFunctions:OutcomeEventsController.kt$OutcomeEventsController : IOutcomeEventsControllerIStartableServiceISessionLifecycleHandler TooManyFunctions:PreferencesService.kt$PreferencesService : IPreferencesServiceIStartableService @@ -384,7 +386,6 @@ UndocumentedPublicClass:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$Callback UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils$SchemaType - UndocumentedPublicClass:AppIdResolution.kt$AppIdResolution UndocumentedPublicClass:ApplicationService.kt$ApplicationService : IApplicationServiceActivityLifecycleCallbacksOnGlobalLayoutListener UndocumentedPublicClass:ConfigModel.kt$ConfigModel : Model UndocumentedPublicClass:ConfigModelStore.kt$ConfigModelStore : SingletonModelStore @@ -408,10 +409,6 @@ UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResponse UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResult UndocumentedPublicClass:IOutcomeEvent.kt$IOutcomeEvent - UndocumentedPublicClass:IParamsBackendService.kt$FCMParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$IParamsBackendService - UndocumentedPublicClass:IParamsBackendService.kt$InfluenceParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$ParamsObject UndocumentedPublicClass:IPreferencesService.kt$PreferenceOneSignalKeys UndocumentedPublicClass:IPreferencesService.kt$PreferencePlayerPurchasesKeys UndocumentedPublicClass:IPreferencesService.kt$PreferenceStores @@ -429,11 +426,10 @@ UndocumentedPublicClass:JSONConverter.kt$JSONConverter UndocumentedPublicClass:JSONUtils.kt$JSONUtils UndocumentedPublicClass:Logging.kt$Logging - UndocumentedPublicClass:LoginHelper.kt$LoginHelper - UndocumentedPublicClass:LogoutHelper.kt$LogoutHelper UndocumentedPublicClass:MigrationRecovery.kt$MigrationRecovery : IMigrationRecovery UndocumentedPublicClass:NetworkUtils.kt$NetworkUtils UndocumentedPublicClass:NetworkUtils.kt$NetworkUtils$ResponseStatusType + UndocumentedPublicClass:OSPrimaryCoroutineScope.kt$OSPrimaryCoroutineScope UndocumentedPublicClass:OneSignalDbContract.kt$OneSignalDbContract UndocumentedPublicClass:OneSignalDbContract.kt$OneSignalDbContract$InAppMessageTable : BaseColumns UndocumentedPublicClass:OneSignalDbContract.kt$OneSignalDbContract$NotificationTable : BaseColumns @@ -441,6 +437,7 @@ UndocumentedPublicClass:OneSignalWrapper.kt$OneSignalWrapper UndocumentedPublicClass:Operation.kt$GroupComparisonType UndocumentedPublicClass:OptionalHeaders.kt$OptionalHeaders + UndocumentedPublicClass:PermissionsActivity.kt$PermissionsActivity : Activity UndocumentedPublicClass:PreferenceStoreFix.kt$PreferenceStoreFix UndocumentedPublicClass:PropertiesDeltasObject.kt$PropertiesDeltasObject UndocumentedPublicClass:PropertiesDeltasObject.kt$PurchaseObject @@ -461,10 +458,8 @@ UndocumentedPublicClass:SyncJobService.kt$SyncJobService : JobService UndocumentedPublicClass:TimeUtils.kt$TimeUtils UndocumentedPublicClass:UserRefreshService.kt$UserRefreshService : IStartableServiceISessionLifecycleHandler - UndocumentedPublicClass:UserSwitcher.kt$UserSwitcher UndocumentedPublicClass:ViewUtils.kt$ViewUtils UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$fun show( activity: Activity, titlePrefix: String, previouslyDeniedPostfix: String, callback: Callback, ) - UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$fun show( activity: Activity, titlePrefix: String, previouslyDeniedPostfix: String, callback: Callback, dismissCallback: (() -> Unit)?, ) UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings.Callback$fun onAccept() UndocumentedPublicFunction:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings.Callback$fun onDecline() UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$@Keep fun opaqueHasClass(_class: Class<*>): Boolean @@ -487,7 +482,6 @@ UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun openURLInBrowser( appContext: Context, url: String, ) UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils$fun openURLInBrowserIntent(uri: Uri): Intent UndocumentedPublicFunction:AndroidUtils.kt$AndroidUtils.SchemaType.Companion$fun fromString(text: String?): SchemaType? - UndocumentedPublicFunction:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution UndocumentedPublicFunction:ApplicationService.kt$ApplicationService$fun decorViewReady( activity: Activity, runnable: Runnable, ) UndocumentedPublicFunction:DateUtils.kt$DateUtils$fun iso8601Format(): SimpleDateFormat UndocumentedPublicFunction:DeviceUtils.kt$DeviceUtils$fun getCarrierName(appContext: Context): String? @@ -544,8 +538,6 @@ UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun warn( message: String, throwable: Throwable? = null, ) UndocumentedPublicFunction:Logging.kt$Logging$fun addListener(listener: ILogListener) UndocumentedPublicFunction:Logging.kt$Logging$fun removeListener(listener: ILogListener) - UndocumentedPublicFunction:LoginHelper.kt$LoginHelper$suspend fun login( externalId: String, jwtBearerToken: String? = null, ) - UndocumentedPublicFunction:LogoutHelper.kt$LogoutHelper$fun logout() UndocumentedPublicFunction:Model.kt$Model$fun <T> setListProperty( name: String, value: List<T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) UndocumentedPublicFunction:Model.kt$Model$fun <T> setMapModelProperty( name: String, value: MapModel<T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) UndocumentedPublicFunction:Model.kt$Model$fun <T> setOptListProperty( name: String, value: List<T>?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) @@ -572,8 +564,7 @@ UndocumentedPublicFunction:NewRecordsState.kt$NewRecordsState$fun add(key: String) UndocumentedPublicFunction:NewRecordsState.kt$NewRecordsState$fun canAccess(key: String): Boolean UndocumentedPublicFunction:NewRecordsState.kt$NewRecordsState$fun isInMissingRetryWindow(key: String): Boolean - UndocumentedPublicFunction:OneSignalDispatchers.kt$OneSignalDispatchers$fun launchOnDefault(block: suspend () -> Unit): Job - UndocumentedPublicFunction:OneSignalDispatchers.kt$OneSignalDispatchers$fun launchOnIO(block: suspend () -> Unit): Job + UndocumentedPublicFunction:OSPrimaryCoroutineScope.kt$OSPrimaryCoroutineScope$suspend fun waitForIdle() UndocumentedPublicFunction:OneSignalUtils.kt$OneSignalUtils$fun isValidEmail(email: String): Boolean UndocumentedPublicFunction:OneSignalUtils.kt$OneSignalUtils$fun isValidPhoneNumber(number: String): Boolean UndocumentedPublicFunction:PushSubscriptionChangedState.kt$PushSubscriptionChangedState$fun toJSONObject(): JSONObject @@ -588,9 +579,6 @@ UndocumentedPublicFunction:TimeUtils.kt$TimeUtils$fun getTimeZoneOffset(): Int UndocumentedPublicFunction:UserChangedState.kt$UserChangedState$fun toJSONObject(): JSONObject UndocumentedPublicFunction:UserState.kt$UserState$fun toJSONObject(): JSONObject - UndocumentedPublicFunction:UserSwitcher.kt$UserSwitcher$fun createAndSwitchToNewUser( suppressBackendOperation: Boolean = false, modify: ((identityModel: IdentityModel, propertiesModel: PropertiesModel) -> Unit)? = null, ) - UndocumentedPublicFunction:UserSwitcher.kt$UserSwitcher$fun createPushSubscriptionFromLegacySync( legacyPlayerId: String, legacyUserSyncJSON: JSONObject, configModel: ConfigModel, subscriptionModelStore: SubscriptionModelStore, appContext: Context, ): Boolean - UndocumentedPublicFunction:UserSwitcher.kt$UserSwitcher$fun initUser(forceCreateUser: Boolean) UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun dpToPx(dp: Int): Int UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun getCutoutAndStatusBarInsets(activity: Activity): IntArray UndocumentedPublicFunction:ViewUtils.kt$ViewUtils$fun getFullbleedWindowWidth(activity: Activity): Int @@ -600,11 +588,7 @@ UnusedPrivateMember:AndroidUtils.kt$AndroidUtils$var requestPermission: String? = null UnusedPrivateMember:ApplicationService.kt$ApplicationService$val listenerKey = "decorViewReady:$runnable" UnusedPrivateMember:JSONUtils.kt$JSONUtils$`object`: Any - UnusedPrivateMember:LoginHelper.kt$LoginHelper$jwtBearerToken: String? = null UnusedPrivateMember:OSDatabase.kt$OSDatabase.Companion$private const val FLOAT_TYPE = " FLOAT" UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'login'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'logout'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") diff --git a/OneSignalSDK/detekt/detekt-config.yml b/OneSignalSDK/detekt/detekt-config.yml index 12c24e6464..de24a4b2b2 100644 --- a/OneSignalSDK/detekt/detekt-config.yml +++ b/OneSignalSDK/detekt/detekt-config.yml @@ -91,7 +91,7 @@ comments: UndocumentedPublicFunction: active: true excludes: ['**/test/**', '**/androidTest/**', '**/testhelpers/**'] - + EndOfSentenceFormat: active: false endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$) diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index 6f90bb1224..8dd5c206da 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -82,6 +82,8 @@ dependencies { } } + // Otel module dependency + implementation(project(':OneSignal:otel')) testImplementation(project(':OneSignal:testhelpers')) testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") diff --git a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml index 285ce5c588..7d0c8323f0 100644 --- a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml +++ b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ - + + + + + diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt index 0cf3b0bdd1..9f400bb559 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt @@ -61,7 +61,7 @@ object JSONUtils { try { val value = jsonObject.opt(key) if (value is JSONArray || value is JSONObject) { - Logging.error("Omitting key '$key'! sendTags DO NOT supported nested values!") + Logging.warn("Omitting key '$key'! sendTags DO NOT supported nested values!") } else if (jsonObject.isNull(key) || "" == value) { result[key] = "" } else { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 9083cddade..8897bb13a6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -33,6 +33,7 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time +import com.onesignal.debug.internal.crash.OneSignalCrashUploaderWrapper import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -81,6 +82,9 @@ internal class CoreModule : IModule { // Purchase Tracking builder.register().provides() + // Crash Uploader (crash handler is initialized directly in OneSignalImp for early initialization) + builder.register().provides() + // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt index 514cc798bc..8773a23af3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt @@ -2,7 +2,7 @@ package com.onesignal.core.internal.backend import org.json.JSONArray -interface IParamsBackendService { +internal interface IParamsBackendService { /** * Retrieve the configuration parameters for the [appId] and optional [subscriptionId]. * @@ -20,7 +20,8 @@ interface IParamsBackendService { ): ParamsObject } -class ParamsObject( +@Suppress("LongParameterList") +internal class ParamsObject( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, @@ -36,9 +37,10 @@ class ParamsObject( var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, + val remoteLoggingParams: RemoteLoggingParamsObject, ) -class InfluenceParamsObject( +internal class InfluenceParamsObject( val indirectNotificationAttributionWindow: Int? = null, val notificationLimit: Int? = null, val indirectIAMAttributionWindow: Int? = null, @@ -48,8 +50,13 @@ class InfluenceParamsObject( val isUnattributedEnabled: Boolean? = null, ) -class FCMParamsObject( +internal class FCMParamsObject( val projectId: String? = null, val appId: String? = null, val apiKey: String? = null, ) + +internal class RemoteLoggingParamsObject( + val logLevel: com.onesignal.debug.LogLevel? = null, + 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 85dd452d41..dfaaa027dc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -11,6 +11,7 @@ import com.onesignal.core.internal.backend.FCMParamsObject import com.onesignal.core.internal.backend.IParamsBackendService import com.onesignal.core.internal.backend.InfluenceParamsObject import com.onesignal.core.internal.backend.ParamsObject +import com.onesignal.core.internal.backend.RemoteLoggingParamsObject import com.onesignal.core.internal.http.CacheKeys import com.onesignal.core.internal.http.IHttpClient import com.onesignal.core.internal.http.impl.OptionalHeaders @@ -57,6 +58,16 @@ internal class ParamsBackendService( ) } + // Process Remote Logging params + var remoteLoggingParams: RemoteLoggingParamsObject? = null + responseJson.expandJSONObject("logging_config") { + val logLevel = LogLevel.fromString(it.safeString("log_level")) + remoteLoggingParams = + RemoteLoggingParamsObject( + logLevel = logLevel, + ) + } + return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), @@ -75,6 +86,7 @@ internal class ParamsBackendService( opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), influenceParams = influenceParams ?: InfluenceParamsObject(), fcmParams = fcmParams ?: FCMParamsObject(), + remoteLoggingParams = remoteLoggingParams ?: RemoteLoggingParamsObject(), ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/background/impl/BackgroundManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/background/impl/BackgroundManager.kt index eddb183784..01c6c81934 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/background/impl/BackgroundManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/background/impl/BackgroundManager.kt @@ -186,7 +186,7 @@ internal class BackgroundManager( } catch (e: NullPointerException) { // Catch for buggy Oppo devices // https://github.com/OneSignal/OneSignal-Android-SDK/issues/487 - Logging.error( + Logging.info( "scheduleSyncServiceAsJob called JobScheduler.jobScheduler which " + "triggered an internal null Android error. Skipping job.", e, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index 74d31c4669..bd06e4c3e4 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) } @@ -301,6 +302,9 @@ class ConfigModel : Model() { val fcmParams: FCMConfigModel get() = getAnyProperty(::fcmParams.name) { FCMConfigModel(this, ::fcmParams.name) } as FCMConfigModel + val remoteLoggingParams: RemoteLoggingConfigModel + get() = getAnyProperty(::remoteLoggingParams.name) { RemoteLoggingConfigModel(this, ::remoteLoggingParams.name) } as RemoteLoggingConfigModel + override fun createModelForProperty( property: String, jsonObject: JSONObject, @@ -317,6 +321,12 @@ class ConfigModel : Model() { return model } + if (property == ::remoteLoggingParams.name) { + val model = RemoteLoggingConfigModel(this, ::remoteLoggingParams.name) + model.initializeFromJson(jsonObject) + return model + } + return null } } @@ -425,3 +435,34 @@ class FCMConfigModel(parentModel: Model, parentProperty: String) : Model(parentM setOptStringProperty(::apiKey.name, value) } } + +/** + * Configuration related to OneSignal's remote logging. + */ +class RemoteLoggingConfigModel( + parentModel: Model, + parentProperty: String, +) : Model(parentModel, parentProperty) { + /** + * The minimum log level to send to OneSignal's server. + * If null, defaults to ERROR level for client-side logging. + * If NONE, no logs (including errors) will be sent remotely. + * + * Log levels: NONE < FATAL < ERROR < WARN < INFO < DEBUG < VERBOSE + */ + var logLevel: com.onesignal.debug.LogLevel? + get() = getOptEnumProperty(::logLevel.name) + set(value) { + setOptEnumProperty(::logLevel.name, value) + } + + /** + * 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/ConfigModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt index 687a8547b0..801a85e903 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelStore.kt @@ -3,7 +3,8 @@ package com.onesignal.core.internal.config import com.onesignal.common.modeling.SimpleModelStore import com.onesignal.common.modeling.SingletonModelStore import com.onesignal.core.internal.preferences.IPreferencesService +const val CONFIG_NAME_SPACE = "config" open class ConfigModelStore(prefs: IPreferencesService) : SingletonModelStore( - SimpleModelStore({ ConfigModel() }, "config", prefs), + SimpleModelStore({ ConfigModel() }, CONFIG_NAME_SPACE, prefs), ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt index 87d7eae6b0..581943bc58 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt @@ -103,6 +103,9 @@ internal class ConfigModelStoreListener( params.influenceParams.isIndirectEnabled?.let { config.influenceParams.isIndirectEnabled = it } params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it } + params.remoteLoggingParams.logLevel?.let { config.remoteLoggingParams.logLevel = it } + config.remoteLoggingParams.isEnabled = params.remoteLoggingParams.isEnabled + _configModelStore.replace(config, ModelChangeTags.HYDRATE) success = true } catch (ex: BackendException) { 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 0000000000..b7533961de --- /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/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 00748d428e..747b0b7085 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -29,6 +29,9 @@ import java.net.UnknownHostException import java.util.Scanner import javax.net.ssl.HttpsURLConnection +internal const val HTTP_SDK_VERSION_HEADER_KEY = "SDK-Version" +internal val HTTP_SDK_VERSION_HEADER_VALUE = "onesignal/android/${OneSignalUtils.sdkVersion}" + internal class HttpClient( private val _connectionFactory: IHttpConnectionFactory, private val _prefs: IPreferencesService, @@ -93,7 +96,7 @@ internal class HttpClient( return@withTimeout makeRequestIODispatcher(url, method, jsonBody, timeout, headers) } } catch (e: TimeoutCancellationException) { - Logging.error("HttpClient: Request timed out: $url", e) + Logging.info("HttpClient: Request timed out: $url", e) return HttpResponse(0, null, e) } catch (e: Throwable) { return HttpResponse(0, null, e) @@ -135,7 +138,7 @@ internal class HttpClient( con.useCaches = false con.connectTimeout = timeout con.readTimeout = timeout - con.setRequestProperty("SDK-Version", "onesignal/android/" + OneSignalUtils.sdkVersion) + con.setRequestProperty(HTTP_SDK_VERSION_HEADER_KEY, HTTP_SDK_VERSION_HEADER_VALUE) if (OneSignalWrapper.sdkType != null && OneSignalWrapper.sdkVersion != null) { con.setRequestProperty("SDK-Wrapper", "onesignal/${OneSignalWrapper.sdkType}/${OneSignalWrapper.sdkVersion}") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 1861261506..78983cc7fc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -264,7 +264,7 @@ internal class OperationRepo( ExecutionResult.FAIL_NORETRY, ExecutionResult.FAIL_CONFLICT, -> { - Logging.error("Operation execution failed without retry: $operations") + Logging.warn("Operation execution failed without retry: $operations") // on failure we remove the operation from the store and wake any waiters ops.forEach { _operationModelStore.remove(it.operation.id) } ops.forEach { it.waiter?.wake(false) } @@ -279,7 +279,7 @@ internal class OperationRepo( } } ExecutionResult.FAIL_RETRY -> { - Logging.error("Operation execution failed, retrying: $operations") + Logging.info("Operation execution failed, retrying: $operations") // add back all operations to the front of the queue to be re-executed. synchronized(queue) { ops.reversed().forEach { @@ -341,7 +341,7 @@ internal class OperationRepo( val delayForOnRetries = retries * _configModelStore.model.opRepoDefaultFailRetryBackoff val delayFor = max(delayForOnRetries, retryAfterSecondsNonNull * 1_000) if (delayFor < 1) return - Logging.error("Operations being delay for: $delayFor ms") + Logging.debug("Operations being delay for: $delayFor ms") withTimeoutOrNull(delayFor) { retryWaiter.waitForWake() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt index ff35096efd..8f1824d481 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt @@ -10,4 +10,9 @@ interface ITime { * current time and midnight, January 1, 1970 UTC). */ val currentTimeMillis: Long + + /** + * Returns how long the app has been running. + */ + val processUptimeMillis: Long } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt index 231f37edf3..753ef124d5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt @@ -1,8 +1,14 @@ package com.onesignal.core.internal.time.impl +import android.os.Build +import android.os.SystemClock +import androidx.annotation.RequiresApi import com.onesignal.core.internal.time.ITime internal class Time : ITime { override val currentTimeMillis: Long get() = System.currentTimeMillis() + override val processUptimeMillis: Long + @RequiresApi(Build.VERSION_CODES.N) + get() = SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/LogLevel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/LogLevel.kt index 9c3f99e877..e88922909c 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/AnrConstants.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt new file mode 100644 index 0000000000..3f0e115eb2 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrConstants.kt @@ -0,0 +1,19 @@ +package com.onesignal.debug.internal.crash + +/** + * Constants for ANR (Application Not Responding) detection configuration. + */ +internal object AnrConstants { + /** + * Default ANR threshold in milliseconds. + * Android's default ANR threshold is 5 seconds (5000ms). + * An ANR is reported when the main thread is unresponsive for this duration. + */ + const val DEFAULT_ANR_THRESHOLD_MS: Long = 5_000L + + /** + * Default check interval in milliseconds. + * The ANR detector checks the main thread responsiveness every 2 seconds. + */ + const val DEFAULT_CHECK_INTERVAL_MS: Long = 2_000L +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt new file mode 100644 index 0000000000..568134287f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt @@ -0,0 +1,38 @@ +package com.onesignal.debug.internal.crash + +import android.content.Context +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.OtelFactory + +/** + * Factory for creating 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 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 { + require(OtelSdkSupport.isSupported) { + "createCrashHandler called on unsupported SDK (< ${OtelSdkSupport.MIN_SDK_VERSION})" + } + + Logging.info("OneSignal: Creating Otel crash handler (SDK >= ${OtelSdkSupport.MIN_SDK_VERSION})") + val platformProvider = createAndroidOtelPlatformProvider(context) + return OtelFactory.createCrashHandler(platformProvider, logger) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt new file mode 100644 index 0000000000..1d197ce797 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -0,0 +1,60 @@ +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 + +/** + * Android-specific wrapper for OtelCrashUploader that implements IStartableService. + * + * This is a thin adapter layer that: + * 1. Takes Android-specific services as dependencies + * 2. Creates platform-agnostic implementations (IOtelPlatformProvider, IOtelLogger) + * 3. Wraps the platform-agnostic OtelCrashUploader for Android service architecture + * + * The OtelCrashUploader itself is fully platform-agnostic and can be used directly + * in KMP projects by providing platform-specific implementations of: + * - IOtelPlatformProvider (inject all platform values) + * - IOtelLogger (platform logging interface) + * + * Example KMP usage: + * ```kotlin + * val platformProvider = MyPlatformProvider(...) // iOS/Android specific + * val logger = MyPlatformLogger() // iOS/Android specific + * val uploader = OtelFactory.createCrashUploader(platformProvider, logger) + * // Use uploader.start() in a coroutine + * ``` + */ +internal class OneSignalCrashUploaderWrapper( + private val applicationService: IApplicationService, +) : IStartableService { + private val uploader: OtelCrashUploader by lazy { + // Create Android-specific platform provider (injects Android values) + val platformProvider = createAndroidOtelPlatformProvider( + applicationService.appContext + ) + // Create Android-specific logger (delegates to Android Logging) + val logger = AndroidOtelLogger() + // Create platform-agnostic uploader using factory + OtelFactory.createCrashUploader(platformProvider, logger) + } + + @Suppress("TooGenericExceptionCaught") + override fun 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 new file mode 100644 index 0000000000..d7ad6960a1 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt @@ -0,0 +1,221 @@ +package com.onesignal.debug.internal.crash + +import android.os.Handler +import android.os.Looper +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +import com.onesignal.otel.OtelFactory +import com.onesignal.otel.crash.IOtelAnrDetector +import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Android-specific implementation of ANR detection. + * + * Uses a watchdog pattern to monitor the main thread: + * - Posts a message to the main thread every check interval + * - If the main thread doesn't respond within the ANR threshold, reports an ANR + * - Captures the main thread's stack trace when ANR is detected + * + * This is a standalone component that can be initialized independently of the crash handler. + * It creates its own crash reporter to save ANR reports. + */ +internal class OtelAnrDetector( + openTelemetryCrash: IOtelOpenTelemetryCrash, + private val logger: IOtelLogger, + private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, +) : IOtelAnrDetector { + private val crashReporter: IOtelCrashReporter = OtelFactory.createCrashReporter(openTelemetryCrash, logger) + private val mainHandler = Handler(Looper.getMainLooper()) + private val isMonitoring = AtomicBoolean(false) + private val lastResponseTime = AtomicLong(System.currentTimeMillis()) + private val lastAnrReportTime = AtomicLong(0L) + private var watchdogThread: Thread? = null + private var watchdogRunnable: Runnable? = null + private var mainThreadRunnable: Runnable? = null + + companion object { + private const val TAG = "OtelAnrDetector" + + // Minimum time between ANR reports (to avoid duplicate reports for the same ANR) + private const val MIN_TIME_BETWEEN_ANR_REPORTS_MS = 30_000L // 30 seconds + } + + override fun start() { + if (isMonitoring.getAndSet(true)) { + logger.warn("$TAG: Already monitoring for ANRs, skipping start") + return + } + + logger.info("$TAG: Starting ANR detection (threshold: ${anrThresholdMs}ms, check interval: ${checkIntervalMs}ms)") + + setupRunnables() + startWatchdogThread() + + logger.info("$TAG: ✅ ANR detection started successfully") + } + + @Suppress("TooGenericExceptionCaught") + private fun setupRunnables() { + // Runnable that runs on the main thread to indicate it's responsive + mainThreadRunnable = Runnable { + lastResponseTime.set(System.currentTimeMillis()) + } + + // Runnable that runs on the watchdog thread to check for ANRs + watchdogRunnable = Runnable { + while (isMonitoring.get()) { + try { + checkForAnr() + } catch (e: InterruptedException) { + // Thread was interrupted, stop monitoring + logger.info("$TAG: Watchdog thread interrupted, stopping ANR detection") + break + } catch (t: Throwable) { + logger.error("$TAG: Error in ANR watchdog: ${t.message} - ${t.javaClass.simpleName}") + } + } + } + } + + private fun checkForAnr() { + val runnable = mainThreadRunnable ?: return + mainHandler.post(runnable) + + // Wait for the check interval + Thread.sleep(checkIntervalMs) + + // Check if main thread responded + val timeSinceLastResponse = System.currentTimeMillis() - lastResponseTime.get() + if (timeSinceLastResponse > anrThresholdMs) { + handleAnrDetected(timeSinceLastResponse) + } else { + handleMainThreadResponsive() + } + } + + private fun handleAnrDetected(timeSinceLastResponse: Long) { + // Main thread hasn't responded - ANR detected! + val now = System.currentTimeMillis() + val timeSinceLastReport = now - lastAnrReportTime.get() + + // Only report if enough time has passed since last report (avoid duplicates) + if (timeSinceLastReport > MIN_TIME_BETWEEN_ANR_REPORTS_MS) { + logger.warn("$TAG: ⚠️ ANR detected! Main thread unresponsive for ${timeSinceLastResponse}ms") + lastAnrReportTime.set(now) + reportAnr(timeSinceLastResponse) + } else { + logger.debug("$TAG: ANR still ongoing (${timeSinceLastResponse}ms), but already reported recently (${timeSinceLastReport}ms ago)") + } + } + + private fun handleMainThreadResponsive() { + // Main thread is responsive - reset ANR report time so we can detect new ANRs + if (lastAnrReportTime.get() > 0) { + lastAnrReportTime.set(0L) + logger.debug("$TAG: Main thread recovered, ready to detect new ANRs") + } + } + + private fun startWatchdogThread() { + // Start the watchdog thread + watchdogThread = Thread(watchdogRunnable, "OneSignal-ANR-Watchdog") + watchdogThread?.isDaemon = true + watchdogThread?.start() + } + + override fun stop() { + if (!isMonitoring.getAndSet(false)) { + logger.warn("$TAG: Not monitoring, skipping stop") + return + } + + logger.info("$TAG: Stopping ANR detection...") + + // Interrupt the watchdog thread to stop it + watchdogThread?.interrupt() + watchdogThread = null + watchdogRunnable = null + // Remove pending callbacks before nulling to prevent execution after stop + mainThreadRunnable?.let { mainHandler.removeCallbacks(it) } + mainThreadRunnable = null + + 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)") + + // Get the main thread's stack trace + val mainThread = Looper.getMainLooper().thread + val stackTrace = mainThread.stackTrace + + // Only report if OneSignal is at fault (uses centralized utility from otel module) + val isOneSignalAtFault = com.onesignal.otel.crash.isOneSignalAtFault(stackTrace) + + if (!isOneSignalAtFault) { + logger.debug("$TAG: ANR is not OneSignal-related, skipping report") + return + } + + logger.info("$TAG: OneSignal-related ANR detected, reporting...") + + // Create an ANR exception with the stack trace + val anrException = ApplicationNotRespondingException( + "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms", + stackTrace + ) + + // Report it as a crash (but mark it as ANR) + runBlocking { + crashReporter.saveCrash(mainThread, anrException) + } + + logger.info("$TAG: ✅ ANR report saved successfully") + } catch (t: Throwable) { + logger.error("$TAG: Failed to report ANR: ${t.message} - ${t.javaClass.simpleName}") + } + } + + /** + * Custom exception type for ANRs. + * This allows us to distinguish ANRs from regular crashes in the crash reporting system. + */ + private class ApplicationNotRespondingException( + message: String, + stackTrace: Array + ) : RuntimeException(message) { + init { + this.stackTrace = stackTrace + } + } +} + +// Use the centralized isOneSignalAtFault from otel module + +/** + * Factory function to create an ANR detector for Android. + * This is in the core module since it needs to access Android-specific classes. + */ + +internal fun createAnrDetector( + platformProvider: com.onesignal.otel.IOtelPlatformProvider, + logger: IOtelLogger, + anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, +): IOtelAnrDetector { + // Use the factory to create crash local instance (keeps implementation details internal) + val crashLocal = OtelFactory.createCrashLocalTelemetry(platformProvider) + + return OtelAnrDetector( + crashLocal, + logger, + anrThresholdMs, + checkIntervalMs + ) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt new file mode 100644 index 0000000000..47fc0034de --- /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 a4db03407a..673db1b8da 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt @@ -6,6 +6,12 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.ILogListener import com.onesignal.debug.LogLevel import com.onesignal.debug.OneSignalLogEvent +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.OtelLoggingHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import java.io.PrintWriter import java.io.StringWriter import java.util.concurrent.CopyOnWriteArraySet @@ -17,6 +23,38 @@ object Logging { private val logListeners = CopyOnWriteArraySet() + /** + * Optional Otel remote telemetry for logging SDK events. + * Set this when remote logging is enabled. + */ + @Volatile + private var otelRemoteTelemetry: IOtelOpenTelemetryRemote? = null + + /** + * Function to check if a specific log level should be sent remotely. + * Set this to dynamically check remote logging configuration based on log level. + */ + @Volatile + private var shouldSendLogLevel: (LogLevel) -> Boolean = { false } + + /** + * Sets the Otel remote telemetry instance and log level check function. + * This should be called when remote logging is enabled. + * + * @param telemetry The Otel remote telemetry instance + * @param shouldSend Function that returns true if a log level should be sent remotely + */ + fun setOtelTelemetry( + telemetry: IOtelOpenTelemetryRemote?, + shouldSend: (LogLevel) -> Boolean = { false }, + ) { + otelRemoteTelemetry = telemetry + shouldSendLogLevel = shouldSend + } + + // Coroutine scope for async Otel logging (non-blocking) + private val otelLoggingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @JvmStatic var logLevel = LogLevel.WARN @@ -93,6 +131,7 @@ object Logging { logToLogcat(level, fullMessage, throwable) showVisualLogging(level, fullMessage, throwable) callLogListeners(level, fullMessage, throwable) + logToOtel(level, fullMessage, throwable) } private fun logToLogcat( @@ -160,6 +199,42 @@ object Logging { } } + /** + * Logs to Otel remote telemetry if enabled. + * This is non-blocking and runs asynchronously. + */ + @Suppress("TooGenericExceptionCaught", "ReturnCount") + private fun logToOtel( + level: LogLevel, + message: String, + throwable: Throwable?, + ) { + val telemetry = otelRemoteTelemetry ?: return + + // Skip NONE level + if (level == LogLevel.NONE) return + + // Check if this log level should be sent remotely + if (!shouldSendLogLevel(level)) return + + // Log asynchronously (non-blocking) + otelLoggingScope.launch { + try { + OtelLoggingHelper.logToOtel( + telemetry = telemetry, + level = level.name, + message = message, + exceptionType = throwable?.javaClass?.name, + exceptionMessage = throwable?.message, + exceptionStacktrace = throwable?.stackTraceToString(), + ) + } catch (t: Throwable) { + // Don't log Otel errors to Otel (would cause infinite loop) + android.util.Log.e(TAG, "Failed to log to Otel: ${t.message}", t) + } + } + } + fun addListener(listener: ILogListener) { logListeners.add(listener) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt new file mode 100644 index 0000000000..0452a8dca3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt @@ -0,0 +1,26 @@ +package com.onesignal.debug.internal.logging.otel.android + +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelLogger + +/** + * Android-specific implementation of IOtelLogger. + * Delegates to the existing Logging object. + */ +internal class AndroidOtelLogger : IOtelLogger { + override fun error(message: String) { + Logging.error(message) + } + + override fun warn(message: String) { + Logging.warn(message) + } + + override fun info(message: String) { + Logging.info(message) + } + + override fun debug(message: String) { + Logging.debug(message) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt new file mode 100644 index 0000000000..b205fffd9f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt @@ -0,0 +1,247 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.content.Context +import com.onesignal.common.IDManager +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.backend.IdentityConstants +import org.json.JSONArray +import org.json.JSONObject + +/** + * Resolves OneSignal IDs from SharedPreferences with fallback strategies. + * This class encapsulates all the logic for reading IDs from ConfigModelStore and legacy SharedPreferences, + * making it easier to maintain and test. + * + * Note: Data is read fresh from SharedPreferences each time (not cached) to ensure test reliability + * and correctness. The performance impact is minimal since these methods are not called frequently. + */ +@Suppress("TooManyFunctions") // This class intentionally groups related ID resolution functions +internal class OtelIdResolver( + private val context: Context?, +) { + companion object { + /** + * Hardcoded error appId prefix when appId cannot be resolved. + */ + private const val ERROR_APP_ID_RESOLVE = "00000000-0000-4000-a000-000000000000" + private const val ERROR_APP_ID_PREFIX_UNKNOWN = "e1100000-0000-4000-a000-000000000000" + private const val ERROR_APP_ID_PREFIX_NO_APPID_IN_CONFIG = "e1100000-0000-4000-a000-000000000001" + private const val ERROR_APP_ID_PREFIX_NO_CONFIG_STORE = "e1100000-0000-4000-a000-000000000002" + private const val ERROR_APP_ID_PREFIX_NO_APPID_IN_CONFIG_STORE = "e1100000-0000-4000-a000-000000000003" + private const val ERROR_APP_ID_PREFIX_NO_CONTEXT = "e1100000-0000-4000-a000-000000000004" + } + + // Get SharedPreferences instance (fresh each time to avoid caching issues in tests) + private fun getSharedPreferences(): android.content.SharedPreferences? { + return context?.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + + // Read ConfigModelStore JSON (fresh read each time for testability) + // In production, this is called multiple times per resolver instance, but the performance impact is minimal + // and this ensures test reliability + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun readConfigModel(): JSONObject? { + return try { + val configStoreJson = getSharedPreferences()?.getString( + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + com.onesignal.core.internal.config.CONFIG_NAME_SPACE, + null + ) + + if (configStoreJson != null && configStoreJson.isNotEmpty()) { + val jsonArray = JSONArray(configStoreJson) + if (jsonArray.length() > 0) { + jsonArray.getJSONObject(0) + } else { + null + } + } else { + null + } + } catch (e: Exception) { + null + } + } + + // Check if ConfigModelStore exists but is empty (to distinguish from "not found") + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun hasEmptyConfigStore(): Boolean { + return try { + val configStoreJson = getSharedPreferences()?.getString( + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + com.onesignal.core.internal.config.CONFIG_NAME_SPACE, + null + ) + if (configStoreJson != null && configStoreJson.isNotEmpty()) { + val jsonArray = JSONArray(configStoreJson) + jsonArray.length() == 0 + } else { + false + } + } catch (e: Exception) { + false + } + } + + /** + * Resolves appId with the following fallback chain: + * 1. Try ConfigModelStore in SharedPreferences (MODEL_STORE_config) + * 2. Try legacy OneSignal SharedPreferences + * 3. Return error appId with affix if all fail + */ + @Suppress("TooGenericExceptionCaught") + fun resolveAppId(): String { + return try { + val configModel = readConfigModel() + val appIdFromConfig = extractAppIdFromConfig(configModel) + appIdFromConfig ?: resolveAppIdFromLegacy(configModel) + } catch (e: Exception) { + Logging.error("Trying resolve the app Id${e.message}") + ERROR_APP_ID_RESOLVE + } + } + + private fun extractAppIdFromConfig(configModel: JSONObject?): String? { + if (configModel == null) return null + val appIdProperty = ConfigModel::appId + return if (configModel.has(appIdProperty.name)) { + val appId = configModel.getString(appIdProperty.name) + appId.ifEmpty { null } + } else { + null + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException", "NestedBlockDepth") + private fun resolveAppIdFromLegacy(configModel: JSONObject?): String { + // Second: fall back to legacy OneSignal SharedPreferences + val legacyAppId = try { + getSharedPreferences()?.getString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, null) + ?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + + return legacyAppId ?: run { + // Third: return error appId with affix + return when { + context == null -> ERROR_APP_ID_PREFIX_NO_CONTEXT + hasEmptyConfigStore() -> ERROR_APP_ID_PREFIX_NO_APPID_IN_CONFIG_STORE // Store exists but is empty array + configModel == null -> ERROR_APP_ID_PREFIX_NO_CONFIG_STORE // Store doesn't exist + !configModel.has("appId") -> ERROR_APP_ID_PREFIX_NO_APPID_IN_CONFIG // Store exists but no appId field + else -> ERROR_APP_ID_PREFIX_UNKNOWN + } + } + } + + /** + * Resolves onesignalId with the following fallback chain: + * 1. Try IdentityModelStore in SharedPreferences (MODEL_STORE_identity) + * 2. Return null if all fail + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException", "NestedBlockDepth") + fun resolveOnesignalId(): String? { + return try { + val identityStoreJson = getSharedPreferences()?.getString( + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE, + null + ) + + if (identityStoreJson != null && identityStoreJson.isNotEmpty()) { + extractOnesignalIdFromJson(identityStoreJson) + } else { + null + } + } catch (e: Exception) { + null + } + } + + private fun extractOnesignalIdFromJson(identityStoreJson: String): String? { + val jsonArray = JSONArray(identityStoreJson) + if (jsonArray.length() > 0) { + val identityModel = jsonArray.getJSONObject(0) + if (identityModel.has(IdentityConstants.ONESIGNAL_ID)) { + val onesignalId = identityModel.getString(IdentityConstants.ONESIGNAL_ID) + return onesignalId.takeIf { it.isNotEmpty() && !IDManager.isLocalId(it) } + } + } + return null + } + + /** + * Resolves pushSubscriptionId from cached ConfigModelStore. + * Returns null if not found or if it's a local ID. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun resolvePushSubscriptionId(): String? { + return try { + val configModel = readConfigModel() + val pushSubscriptionIdProperty = ConfigModel::pushSubscriptionId + if (configModel != null && configModel.has(pushSubscriptionIdProperty.name)) { + val pushSubscriptionId = configModel.getString(pushSubscriptionIdProperty.name) + pushSubscriptionId.takeIf { it.isNotEmpty() && !IDManager.isLocalId(pushSubscriptionId) } + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Resolves 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. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException", "NestedBlockDepth") + fun resolveRemoteLogLevel(): com.onesignal.debug.LogLevel? { + return try { + val configModel = readConfigModel() + val remoteLoggingParamsProperty = ConfigModel::remoteLoggingParams + if (configModel != null && configModel.has(remoteLoggingParamsProperty.name)) { + extractLogLevelFromParams(configModel.getJSONObject(remoteLoggingParamsProperty.name)) + } else { + null + } + } catch (e: Exception) { + null + } + } + + 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. + * Returns "InstallId-Null" if not found, "InstallId-NotFound" if there's an error. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + fun resolveInstallId(): String { + return try { + val installIdString = getSharedPreferences()?.getString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "InstallId-Null") + installIdString ?: "InstallId-Null" + } catch (e: Exception) { + "InstallId-NotFound" + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt new file mode 100644 index 0000000000..eebf9469c0 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt @@ -0,0 +1,164 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.internal.http.OneSignalService +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelPlatformProvider + +/** + * Configuration for AndroidOtelPlatformProvider. + */ +internal data class OtelPlatformProviderConfig( + val crashStoragePath: String, + val appPackageId: String, + val appVersion: String, + val context: Context? = null, + val getIsInForeground: (() -> Boolean?)? = null, +) + +/** + * Android-specific implementation of IOtelPlatformProvider. + * Reads all values directly from SharedPreferences and system services. + * No SDK service dependencies required. + * + * All IDs (appId, onesignalId, pushSubscriptionId) are resolved from SharedPreferences via OtelIdResolver. + * Remote log level defaults to ERROR if not found in config. + */ +internal class OtelPlatformProvider( + config: OtelPlatformProviderConfig, +) : IOtelPlatformProvider { + override val appPackageId: String = config.appPackageId + override val appVersion: String = config.appVersion + private val context: Context? = config.context + private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground + private val idResolver = OtelIdResolver(context) + + // Top-level attributes (static, calculated once) + override suspend fun getInstallId(): String = idResolver.resolveInstallId() + + override val sdkBase: String = "android" + + override val sdkBaseVersion: String = OneSignalUtils.sdkVersion + + override val deviceManufacturer: String = Build.MANUFACTURER + + override val deviceModel: String = Build.MODEL + + override val osName: String = "Android" + + override val osVersion: String = Build.VERSION.RELEASE + + override val osBuildId: String = Build.ID + + override val sdkWrapper: String? = OneSignalWrapper.sdkType + + override val sdkWrapperVersion: String? = OneSignalWrapper.sdkVersion + + // Per-event attributes - IDs are cached (calculated once), appState is dynamic (calculated per access) + override val appId: String? by lazy { + idResolver.resolveAppId() + } + + override val onesignalId: String? by lazy { + idResolver.resolveOnesignalId() + } + + override val pushSubscriptionId: String? by lazy { + idResolver.resolvePushSubscriptionId() + } + + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ + override val appState: String + @Suppress("TooGenericExceptionCaught", "SwallowedException") + get() = try { + // Try to get from ApplicationService if available + getIsInForeground?.invoke()?.let { isForeground -> + if (isForeground) "foreground" else "background" + } ?: run { + // Fall back to ActivityManager if Context is available + context?.let { ctx -> + @Suppress("TooGenericExceptionCaught", "SwallowedException") + try { + val activityManager = ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val runningAppProcesses = activityManager?.runningAppProcesses + val currentProcess = runningAppProcesses?.find { it.pid == android.os.Process.myPid() } + when (currentProcess?.importance) { + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE -> "foreground" + else -> "background" + } + } catch (e: Exception) { + "unknown" + } + } ?: "unknown" + } + } catch (e: Exception) { + "unknown" + } + + // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime + override val processUptime: 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 + + 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 + + // 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 { + idResolver.resolveRemoteLogLevel()?.name + } catch (e: Exception) { + null + } + } + + override val appIdForHeaders: String + get() = appId ?: "" + + override val apiBaseUrl: String = OneSignalService.ONESIGNAL_API_BASE_URL +} + +/** + * Factory function to create AndroidOtelPlatformProvider without service dependencies. + * Reads all values directly from SharedPreferences and system services. + */ +internal fun createAndroidOtelPlatformProvider( + context: Context, +): OtelPlatformProvider { + val crashStoragePath = context.cacheDir.path + java.io.File.separator + + "onesignal" + java.io.File.separator + + "otel" + java.io.File.separator + + "crashes" + + return OtelPlatformProvider( + OtelPlatformProviderConfig( + crashStoragePath = crashStoragePath, + appPackageId = context.packageName, + appVersion = com.onesignal.common.AndroidUtils.getAppVersion(context) ?: "unknown", + context = context, + ) + ) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 1ccf96809b..afd7ab39d8 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 @@ -53,6 +53,8 @@ import com.onesignal.user.internal.subscriptions.SubscriptionType import org.json.JSONObject internal class OneSignalImp : IOneSignal, IServiceProvider { + private var otelManager: OtelLifecycleManager? = null + override val sdkVersion: String = OneSignalUtils.sdkVersion override var isInitialized: Boolean = false @@ -202,6 +204,8 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { Logging.log(LogLevel.DEBUG, "initWithContext: SDK initializing") + otelManager = OtelLifecycleManager(context).also { it.initializeFromCachedConfig() } + PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) // start the application service. This is called explicitly first because we want @@ -218,6 +222,8 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { sessionModel = services.getService().model operationRepo = services.getService() + otelManager?.subscribeToConfigStore(services.getService()) + var forceCreateUser = false // initWithContext is called by our internal services/receivers/activities but they do not provide 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 0000000000..ea8b862ae5 --- /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 0000000000..1b8b97b58b --- /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/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt index 70758efe36..17137a0665 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsController.kt @@ -80,9 +80,9 @@ internal class OutcomeEventsController( val err = "OutcomeEventsController.sendSavedOutcomeEvent: Sending outcome with name: ${event.outcomeId} failed with status code: ${ex.statusCode} and response: ${ex.response}" if (responseType == NetworkUtils.ResponseStatusType.RETRYABLE) { - Logging.warn("$err Outcome event was cached and will be reattempted on app cold start") + Logging.info("$err Outcome event was cached and will be reattempted on app cold start") } else { - Logging.error("$err Outcome event will be omitted!") + Logging.warn("$err Outcome event will be omitted!") _outcomeEventsCache.deleteOldOutcomeEvent(event) } } @@ -223,13 +223,13 @@ internal class OutcomeEventsController( val err = "OutcomeEventsController.sendAndCreateOutcomeEvent: Sending outcome with name: $name failed with status code: ${ex.statusCode} and response: ${ex.response}" if (responseType == NetworkUtils.ResponseStatusType.RETRYABLE) { - Logging.warn("$err Outcome event was cached and will be reattempted on app cold start") + Logging.info("$err Outcome event was cached and will be reattempted on app cold start") // Only if we need to save and retry the outcome, then we will save the timestamp for future sending eventParams.timestamp = timestampSeconds _outcomeEventsCache.saveOutcomeEvent(eventParams) } else { - Logging.error("$err Outcome event will be omitted!") + Logging.warn("$err Outcome event will be omitted!") _outcomeEventsCache.deleteOldOutcomeEvent(eventParams) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt index acef72d3c5..2f4f3f8ce2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt @@ -56,7 +56,7 @@ internal class SessionListener( // Time is erroneous if below 1 second or over a day if (durationInSeconds < 1L || durationInSeconds > SECONDS_IN_A_DAY) { - Logging.error("SessionListener.onSessionEnded sending duration of $durationInSeconds seconds") + Logging.info("SessionListener.onSessionEnded sending duration of $durationInSeconds seconds") } _operationRepo.enqueue( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt index 911c4ba71b..35ff97298f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt @@ -4,6 +4,8 @@ import com.onesignal.common.modeling.SimpleModelStore import com.onesignal.common.modeling.SingletonModelStore import com.onesignal.core.internal.preferences.IPreferencesService +const val IDENTITY_NAME_SPACE = "identity" + open class IdentityModelStore(prefs: IPreferencesService) : SingletonModelStore( - SimpleModelStore({ IdentityModel() }, "identity", prefs), + SimpleModelStore({ IdentityModel() }, IDENTITY_NAME_SPACE, prefs), ) diff --git a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..8768713b9a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt index ca6ce9b308..4f9c377348 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/LoggingTests.kt @@ -95,13 +95,13 @@ class LoggingTests : FunSpec({ test("removeListener nested") { // Given val calls = ArrayList() - var listener: ILogListener? = null - listener = - ILogListener { - calls += it.entry - Logging.removeListener(listener!!) - } - Logging.addListener(listener!!) + lateinit var listener: ILogListener + listener = ILogListener { logEvent -> + calls += logEvent.entry + // Remove self from listeners + Logging.removeListener(listener) + } + Logging.addListener(listener) // When Logging.debug("test") diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt new file mode 100644 index 0000000000..5eaaa714d7 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt @@ -0,0 +1,75 @@ +package com.onesignal.debug.internal.crash + +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.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelLogger +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.mockk +import org.robolectric.annotation.Config + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OneSignalCrashHandlerFactoryTest : FunSpec({ + lateinit var appContext: Context + lateinit var logger: AndroidOtelLogger + // Save original handler to restore after tests + val originalHandler: Thread.UncaughtExceptionHandler? = Thread.getDefaultUncaughtExceptionHandler() + + beforeAny { + appContext = ApplicationProvider.getApplicationContext() + logger = AndroidOtelLogger() + } + + afterEach { + // Restore original uncaught exception handler after each test + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("createCrashHandler should return IOtelCrashHandler") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) + + handler.shouldBeInstanceOf() + } + + test("createCrashHandler should create handler that can be initialized") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) + + handler shouldNotBe null + handler.initialize() + } + + test("createCrashHandler should accept mock logger") { + val mockLogger = mockk(relaxed = true) + + val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, mockLogger) + + handler shouldNotBe null + handler.shouldBeInstanceOf() + } + + test("handler should be idempotent when initialized multiple times") { + val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) + + handler.initialize() + handler.initialize() // Should not throw + + handler shouldNotBe null + } + + test("createCrashHandler should work with different contexts") { + val context1: Context = ApplicationProvider.getApplicationContext() + val context2: Context = ApplicationProvider.getApplicationContext() + + val handler1 = OneSignalCrashHandlerFactory.createCrashHandler(context1, logger) + val handler2 = OneSignalCrashHandlerFactory.createCrashHandler(context2, logger) + + handler1 shouldNotBe null + handler2 shouldNotBe null + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt new file mode 100644 index 0000000000..942c02af20 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt @@ -0,0 +1,104 @@ +package com.onesignal.debug.internal.crash + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.core.internal.startup.IStartableService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +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 OneSignalCrashUploaderWrapperTest : FunSpec({ + + lateinit var appContext: Context + lateinit var sharedPreferences: SharedPreferences + + beforeAny { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + + afterEach { + sharedPreferences.edit().clear().commit() + } + + test("should implement IStartableService interface") { + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + wrapper.shouldBeInstanceOf() + } + + test("start should complete without error when remote logging is disabled") { + // Configure remote logging as disabled (NONE) + val remoteLoggingParams = JSONObject().put("logLevel", "NONE") + val configModel = JSONObject().put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, JSONArray().put(configModel).toString()) + .commit() + + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // Should return early without error when remote logging is disabled + runBlocking { wrapper.start() } + } + + test("start should complete without error when no crash reports exist") { + // Configure remote logging as enabled + val remoteLoggingParams = JSONObject().put("logLevel", "ERROR") + val configModel = JSONObject().put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, JSONArray().put(configModel).toString()) + .commit() + + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // Should complete without error even when no crash reports exist + runBlocking { wrapper.start() } + } + + test("start can be called multiple times safely") { + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + // Multiple calls should not throw + runBlocking { + wrapper.start() + wrapper.start() + } + } + + test("wrapper should be non-null after creation") { + val mockApplicationService = mockk(relaxed = true) + every { mockApplicationService.appContext } returns appContext + + val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService) + + wrapper shouldNotBe null + } +}) 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 new file mode 100644 index 0000000000..25cac810c6 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt @@ -0,0 +1,221 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +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.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.robolectric.annotation.Config + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelAnrDetectorTest : FunSpec({ + + val mockPlatformProvider = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + val mockCrashTelemetry = mockk(relaxed = true) + + fun setupDefaultMocks() { + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0" + every { mockPlatformProvider.deviceManufacturer } returns "Test" + every { mockPlatformProvider.deviceModel } returns "TestDevice" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "10" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns null + every { mockPlatformProvider.sdkWrapperVersion } returns null + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.onesignalId } returns null + every { mockPlatformProvider.pushSubscriptionId } returns null + every { mockPlatformProvider.appState } returns "foreground" + 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" + } + + beforeEach { + setupDefaultMocks() + } + + // ===== Factory Function Tests ===== + + test("createAnrDetector should return IOtelAnrDetector") { + // When + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + + // Then + detector.shouldBeInstanceOf() + } + + test("createAnrDetector should create detector with default thresholds") { + // When + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + + // Then + detector shouldNotBe null + } + + test("createAnrDetector should accept custom thresholds") { + // When + val detector = createAnrDetector( + mockPlatformProvider, + mockLogger, + anrThresholdMs = 10_000L, + checkIntervalMs = 2_000L + ) + + // Then + detector shouldNotBe null + } + + // ===== Start/Stop Tests ===== + + test("start should log info messages") { + // Given + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + + // When + detector.start() + + // Then + verify { mockLogger.info(match { it.contains("Starting ANR detection") }) } + + // Cleanup + detector.stop() + } + + test("stop should log info messages") { + // Given + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + detector.start() + + // When + detector.stop() + + // Then + verify { mockLogger.info(match { it.contains("Stopping ANR detection") }) } + } + + test("start should warn when already monitoring") { + // Given + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + detector.start() + + // When - start again + detector.start() + + // Then + verify { mockLogger.warn(match { it.contains("Already monitoring") }) } + + // Cleanup + detector.stop() + } + + test("stop should warn when not monitoring") { + // Given + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + + // When - stop without starting + detector.stop() + + // Then + verify { mockLogger.warn(match { it.contains("Not monitoring") }) } + } + + test("start and stop can be called multiple times safely") { + // Given + val detector = createAnrDetector(mockPlatformProvider, mockLogger) + + // When + detector.start() + detector.stop() + detector.start() + detector.stop() + + // Then - no exceptions thrown + } + + // ===== OtelAnrDetector Internal Tests ===== + + test("OtelAnrDetector should implement IOtelAnrDetector") { + // Given + val detector = OtelAnrDetector(mockCrashTelemetry, mockLogger) + + // Then + detector.shouldBeInstanceOf() + } + + test("OtelAnrDetector should accept custom thresholds") { + // When + val detector = OtelAnrDetector( + mockCrashTelemetry, + mockLogger, + anrThresholdMs = 15_000L, + checkIntervalMs = 3_000L + ) + + // Then + detector shouldNotBe null + } + + test("OtelAnrDetector start should initialize watchdog thread") { + // Given + val detector = OtelAnrDetector( + mockCrashTelemetry, + mockLogger, + anrThresholdMs = 100_000L, // Very long threshold to prevent actual ANR detection + checkIntervalMs = 100_000L // Very long interval + ) + + // When + detector.start() + + // Then + verify { mockLogger.info(match { it.contains("ANR detection started successfully") }) } + + // Cleanup + detector.stop() + } + + test("OtelAnrDetector stop should stop watchdog thread") { + // Given + val detector = OtelAnrDetector( + mockCrashTelemetry, + mockLogger, + anrThresholdMs = 100_000L, + checkIntervalMs = 100_000L + ) + detector.start() + + // When + detector.stop() + + // Then + verify { mockLogger.info(match { it.contains("ANR detection stopped") }) } + } + + // ===== AnrConstants Tests ===== + + test("AnrConstants should have reasonable defaults") { + // Then + AnrConstants.DEFAULT_ANR_THRESHOLD_MS shouldBe 5_000L + AnrConstants.DEFAULT_CHECK_INTERVAL_MS shouldBe 2_000L + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt new file mode 100644 index 0000000000..f7cf09c7dd --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt @@ -0,0 +1,167 @@ +package com.onesignal.debug.internal.crash + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.otel.IOtelCrashHandler +import com.onesignal.otel.IOtelPlatformProvider +import com.onesignal.otel.OtelFactory +import com.onesignal.user.internal.backend.IdentityConstants +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.robolectric.annotation.Config +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +// Helper extension for shouldBeOneOf +private infix fun T.shouldBeOneOf(expected: List) { + val isInList = expected.contains(this) + if (!isInList) { + throw AssertionError("Expected $this to be one of $expected") + } +} + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelIntegrationTest : FunSpec({ + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + } + + beforeEach { + // Ensure sharedPreferences is initialized + if (sharedPreferences == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // Clear and set up SharedPreferences with test data + sharedPreferences!!.edit().clear().commit() + + // Set up ConfigModelStore data + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::pushSubscriptionId.name, "test-subscription-id") + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "ERROR") + } + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + + // Set up IdentityModelStore data + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id") + .commit() + } + + afterEach { + sharedPreferences!!.edit().clear().commit() + } + + test("AndroidOtelPlatformProvider should provide correct Android values") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + + provider.shouldBeInstanceOf() + provider.sdkBase shouldBe "android" + provider.appPackageId shouldBe appContext!!.packageName // Use actual package name from context + provider.osName shouldBe "Android" + provider.deviceManufacturer shouldBe Build.MANUFACTURER + provider.deviceModel shouldBe Build.MODEL + provider.osVersion shouldBe Build.VERSION.RELEASE + provider.osBuildId shouldBe Build.ID + + runBlocking { + provider.getInstallId() shouldNotBe null + } + } + + test("AndroidOtelPlatformProvider should provide per-event values") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + + provider.appId shouldBe "test-app-id" + provider.onesignalId shouldBe "test-onesignal-id" + provider.pushSubscriptionId shouldBe "test-subscription-id" + provider.appState shouldBeOneOf listOf("foreground", "background", "unknown") + (provider.processUptime > 0) shouldBe true + provider.currentThreadName shouldBe Thread.currentThread().name + } + + test("AndroidOtelLogger should delegate to Logging") { + val logger = AndroidOtelLogger() + + logger.shouldBeInstanceOf() + // Should not throw + logger.debug("test") + logger.info("test") + logger.warn("test") + logger.error("test") + } + + test("OtelFactory should create crash handler with Android provider") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + val logger = AndroidOtelLogger() + + val handler = OtelFactory.createCrashHandler(provider, logger) + + handler shouldNotBe null + handler.shouldBeInstanceOf() + handler.initialize() // Should not throw + } + + test("OneSignalCrashHandlerFactory should create working crash handler") { + // Note: OneSignalCrashHandlerFactory may need to be updated to use the new approach + // For now, we'll test the direct creation + val provider = createAndroidOtelPlatformProvider(appContext!!) + val logger = AndroidOtelLogger() + val handler = OtelFactory.createCrashHandler(provider, logger) + + handler shouldNotBe null + handler.shouldBeInstanceOf() + handler.initialize() // Should not throw + } + + test("AndroidOtelPlatformProvider should provide crash storage path") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + + provider.crashStoragePath.contains("onesignal") shouldBe true + provider.crashStoragePath.contains("otel") shouldBe true + provider.crashStoragePath.contains("crashes") shouldBe true + provider.minFileAgeForReadMillis shouldBe 5000L + } + + test("AndroidOtelPlatformProvider should handle remote logging config") { + val provider = createAndroidOtelPlatformProvider(appContext!!) + + provider.remoteLogLevel shouldBe "ERROR" + provider.appIdForHeaders shouldBe "test-app-id" + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt new file mode 100644 index 0000000000..f7660108e8 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt @@ -0,0 +1,38 @@ +package com.onesignal.debug.internal.crash + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.robolectric.annotation.Config + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelSdkSupportTest : FunSpec({ + + afterEach { + OtelSdkSupport.reset() + } + + test("isSupported is true on SDK >= 26") { + OtelSdkSupport.reset() + OtelSdkSupport.isSupported shouldBe true + } + + test("isSupported can be overridden to false for testing") { + OtelSdkSupport.isSupported = false + OtelSdkSupport.isSupported shouldBe false + } + + test("reset restores runtime-detected value") { + OtelSdkSupport.isSupported = false + OtelSdkSupport.isSupported shouldBe false + + OtelSdkSupport.reset() + OtelSdkSupport.isSupported shouldBe true + } + + test("MIN_SDK_VERSION is 26") { + OtelSdkSupport.MIN_SDK_VERSION shouldBe 26 + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt new file mode 100644 index 0000000000..6bde1defb8 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt @@ -0,0 +1,232 @@ +package com.onesignal.debug.internal.logging + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.debug.LogLevel +import com.onesignal.otel.IOtelOpenTelemetryRemote +import io.kotest.core.spec.style.FunSpec +import io.mockk.mockk +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.robolectric.annotation.Config + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class LoggingOtelTest : FunSpec({ + val mockTelemetry = mockk(relaxed = true) + + beforeEach { + // Reset Logging state + Logging.setOtelTelemetry(null, { false }) + + // Setup default mock behavior - relaxed mock automatically returns mocks for suspend functions + // The return type (LogRecordBuilder) is handled by the relaxed mock, but we can't verify it + // directly due to type visibility. We'll test behavior instead. + } + + test("setOtelTelemetry should store telemetry and enabled check function") { + // Given + val shouldSend = { _: LogLevel -> true } + + // When + Logging.setOtelTelemetry(mockTelemetry, shouldSend) + + // Then - verify it's set (we'll test it works by logging) + Logging.info("test") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - verify it doesn't crash (integration test) + // Note: We can't verify exact calls due to OpenTelemetry type visibility + } + + test("logToOtel should work when remote logging is enabled") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash (integration test) + // The actual Otel call is verified in otel module tests + } + + test("logToOtel should NOT crash when remote logging is disabled") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> false }) + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash + } + + test("logToOtel should NOT crash when telemetry is null") { + // Given + Logging.setOtelTelemetry(null, { _: LogLevel -> true }) + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash + } + + test("logToOtel should handle all log levels without crashing") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + + // When + Logging.verbose("verbose message") + Logging.debug("debug message") + Logging.info("info message") + Logging.warn("warn message") + Logging.error("error message") + Logging.fatal("fatal message") + + // Wait for async logging + runBlocking { + delay(200) + } + + // Then - should not crash for any level + } + + test("logToOtel should NOT log NONE level") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + + // When + Logging.log(LogLevel.NONE, "none message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash, NONE level is skipped + } + + test("logToOtel should handle exceptions in logs") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + val exception = RuntimeException("test exception") + + // When + Logging.error("error with exception", exception) + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash, exception details are included + } + + test("logToOtel should handle null exception message") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + val exception = RuntimeException() + + // When + Logging.error("error with null exception message", exception) + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash + } + + test("logToOtel should handle Otel errors gracefully") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + // Note: We can't mock getLogger() to throw due to OpenTelemetry type visibility, + // but the real implementation in Logging.logToOtel() handles errors gracefully + + // When + Logging.info("test message") + + // Wait for async logging + runBlocking { + delay(100) + } + + // Then - should not crash, error handling is tested in integration tests + } + + test("logToOtel should use dynamic remote logging check") { + // Given + var isEnabled = false + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> isEnabled }) + + // When - initially disabled + Logging.info("message 1") + runBlocking { delay(50) } + + // When - enable remote logging + isEnabled = true + Logging.info("message 2") + runBlocking { delay(50) } + + // When - disable again + isEnabled = false + Logging.info("message 3") + runBlocking { delay(50) } + + // Then - should not crash, dynamic check works + } + + test("logToOtel should handle multiple rapid log calls") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + + // When - rapid fire logging + repeat(10) { + Logging.info("message $it") + } + + // Wait for async logging + runBlocking { + delay(200) + } + + // Then - should not crash + } + + test("logToOtel should work with different message formats") { + // Given + Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true }) + + // When + Logging.info("simple message") + Logging.info("message with numbers: 12345") + Logging.info("message with special chars: !@#$%") + Logging.info("message with unicode: 测试 🚀") + + // Wait for async logging + runBlocking { + delay(200) + } + + // Then - should not crash + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt new file mode 100644 index 0000000000..92d8d69885 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt @@ -0,0 +1,360 @@ +package com.onesignal.debug.internal.logging + +import android.os.Build +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.debug.ILogListener +import com.onesignal.debug.LogLevel +import com.onesignal.debug.OneSignalLogEvent +import com.onesignal.otel.IOtelOpenTelemetryRemote +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.robolectric.annotation.Config + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class LoggingTest : FunSpec({ + + val originalLogLevel = Logging.logLevel + val originalVisualLogLevel = Logging.visualLogLevel + + beforeEach { + // Reset Logging state + Logging.logLevel = LogLevel.WARN + Logging.visualLogLevel = LogLevel.NONE + Logging.setOtelTelemetry(null) { false } + } + + afterEach { + // Restore original state + Logging.logLevel = originalLogLevel + Logging.visualLogLevel = originalVisualLogLevel + Logging.setOtelTelemetry(null) { false } + } + + // ===== Log Level Tests ===== + + test("default logLevel should be WARN") { + // Reset to default + Logging.logLevel = LogLevel.WARN + + // Then + Logging.logLevel shouldBe LogLevel.WARN + } + + test("default visualLogLevel should be NONE") { + // Reset to default + Logging.visualLogLevel = LogLevel.NONE + + // Then + Logging.visualLogLevel shouldBe LogLevel.NONE + } + + test("logLevel can be changed") { + // When + Logging.logLevel = LogLevel.DEBUG + + // Then + Logging.logLevel shouldBe LogLevel.DEBUG + } + + test("visualLogLevel can be changed") { + // When + Logging.visualLogLevel = LogLevel.INFO + + // Then + Logging.visualLogLevel shouldBe LogLevel.INFO + } + + // ===== atLogLevel Tests ===== + + test("atLogLevel returns true when level is at or below logLevel") { + // Given + Logging.logLevel = LogLevel.WARN + + // Then + Logging.atLogLevel(LogLevel.WARN) shouldBe true + Logging.atLogLevel(LogLevel.ERROR) shouldBe true + Logging.atLogLevel(LogLevel.FATAL) shouldBe true + } + + test("atLogLevel returns false when level is above logLevel") { + // Given + Logging.logLevel = LogLevel.WARN + Logging.visualLogLevel = LogLevel.NONE + + // Then + Logging.atLogLevel(LogLevel.INFO) shouldBe false + Logging.atLogLevel(LogLevel.DEBUG) shouldBe false + Logging.atLogLevel(LogLevel.VERBOSE) shouldBe false + } + + test("atLogLevel considers visualLogLevel too") { + // Given + Logging.logLevel = LogLevel.NONE + Logging.visualLogLevel = LogLevel.INFO + + // Then - INFO should pass because visualLogLevel is INFO + Logging.atLogLevel(LogLevel.INFO) shouldBe true + } + + // ===== Log Methods Tests ===== + + test("verbose method should not throw") { + // Given + Logging.logLevel = LogLevel.VERBOSE + + // When & Then - should not throw + Logging.verbose("Test message") + Logging.verbose("Test message with throwable", RuntimeException("test")) + } + + test("debug method should not throw") { + // Given + Logging.logLevel = LogLevel.DEBUG + + // When & Then - should not throw + Logging.debug("Test message") + Logging.debug("Test message with throwable", RuntimeException("test")) + } + + test("info method should not throw") { + // Given + Logging.logLevel = LogLevel.INFO + + // When & Then - should not throw + Logging.info("Test message") + Logging.info("Test message with throwable", RuntimeException("test")) + } + + test("warn method should not throw") { + // Given + Logging.logLevel = LogLevel.WARN + + // When & Then - should not throw + Logging.warn("Test message") + Logging.warn("Test message with throwable", RuntimeException("test")) + } + + test("error method should not throw") { + // Given + Logging.logLevel = LogLevel.ERROR + + // When & Then - should not throw + Logging.error("Test message") + Logging.error("Test message with throwable", RuntimeException("test")) + } + + test("fatal method should not throw") { + // Given + Logging.logLevel = LogLevel.FATAL + + // When & Then - should not throw + Logging.fatal("Test message") + Logging.fatal("Test message with throwable", RuntimeException("test")) + } + + test("log method with level and message should not throw") { + // When & Then - should not throw + Logging.log(LogLevel.INFO, "Test message") + } + + test("log method with level, message, and throwable should not throw") { + // When & Then - should not throw + Logging.log(LogLevel.ERROR, "Test message", RuntimeException("test")) + } + + // ===== Log Listener Tests ===== + + test("addListener should register listener") { + // Given + val mockListener = mockk(relaxed = true) + val eventSlot = slot() + every { mockListener.onLogEvent(capture(eventSlot)) } returns Unit + + Logging.addListener(mockListener) + Logging.logLevel = LogLevel.INFO + + // When + Logging.info("Test listener message") + + // Then + verify { mockListener.onLogEvent(any()) } + eventSlot.captured.level shouldBe LogLevel.INFO + eventSlot.captured.entry.contains("Test listener message") shouldBe true + + // Cleanup + Logging.removeListener(mockListener) + } + + test("removeListener should unregister listener") { + // Given + val mockListener = mockk(relaxed = true) + Logging.addListener(mockListener) + Logging.removeListener(mockListener) + Logging.logLevel = LogLevel.INFO + + // When + Logging.info("Test message after removal") + + // Then - listener should not be called + verify(exactly = 0) { mockListener.onLogEvent(any()) } + } + + test("listener should receive throwable in message") { + // Given + val mockListener = mockk(relaxed = true) + val eventSlot = slot() + every { mockListener.onLogEvent(capture(eventSlot)) } returns Unit + + Logging.addListener(mockListener) + Logging.logLevel = LogLevel.ERROR + + // When + val exception = RuntimeException("Test exception message") + Logging.error("Test error", exception) + + // Then + verify { mockListener.onLogEvent(any()) } + eventSlot.captured.entry.contains("Test error") shouldBe true + eventSlot.captured.entry.contains("Test exception message") shouldBe true + + // Cleanup + Logging.removeListener(mockListener) + } + + // ===== Otel Integration Tests ===== + + test("setOtelTelemetry should set telemetry instance") { + // Given + val mockTelemetry = mockk(relaxed = true) + + // When + Logging.setOtelTelemetry(mockTelemetry) { true } + + // Then - no exception thrown + } + + test("setOtelTelemetry with null should clear telemetry") { + // Given + val mockTelemetry = mockk(relaxed = true) + Logging.setOtelTelemetry(mockTelemetry) { true } + + // When + Logging.setOtelTelemetry(null) { false } + + // Then - no exception thrown + } + + test("log with Otel configured should not throw") { + // Given - Using relaxed mock that doesn't require OpenTelemetry classes + val mockTelemetry = mockk(relaxed = true) + + Logging.setOtelTelemetry(mockTelemetry) { level -> level >= LogLevel.ERROR } + Logging.logLevel = LogLevel.ERROR + + // When & Then - should not throw + Logging.error("Test Otel error message") + runBlocking { delay(100) } + } + + test("log with Otel telemetry set to null should not throw") { + // Given + Logging.setOtelTelemetry(null) { true } + Logging.logLevel = LogLevel.ERROR + + // When & Then - should not throw + Logging.error("Test error - telemetry is null") + } + + test("log with NONE level and Otel configured should not throw") { + // Given + val mockTelemetry = mockk(relaxed = true) + Logging.setOtelTelemetry(mockTelemetry) { true } + + // When & Then - should not throw + Logging.log(LogLevel.NONE, "Should not be logged") + } + + // ===== Message Formatting Tests ===== + + test("log message should include thread name") { + // Given + val mockListener = mockk(relaxed = true) + val eventSlot = slot() + every { mockListener.onLogEvent(capture(eventSlot)) } returns Unit + + Logging.addListener(mockListener) + Logging.logLevel = LogLevel.INFO + + // When + Logging.info("Test message") + + // Then - message should contain thread name in brackets + eventSlot.captured.entry.contains("[") shouldBe true + eventSlot.captured.entry.contains("]") shouldBe true + + // Cleanup + Logging.removeListener(mockListener) + } + + // ===== Thread Safety Tests ===== + + test("multiple listeners can be added safely") { + // Given + val listener1 = mockk(relaxed = true) + val listener2 = mockk(relaxed = true) + val listener3 = mockk(relaxed = true) + + Logging.addListener(listener1) + Logging.addListener(listener2) + Logging.addListener(listener3) + Logging.logLevel = LogLevel.INFO + + // When + Logging.info("Test message to multiple listeners") + + // Then - all listeners should receive the event + verify { listener1.onLogEvent(any()) } + verify { listener2.onLogEvent(any()) } + verify { listener3.onLogEvent(any()) } + + // Cleanup + Logging.removeListener(listener1) + Logging.removeListener(listener2) + Logging.removeListener(listener3) + } + + test("removing non-existent listener should not throw") { + // Given + val mockListener = mockk(relaxed = true) + + // When & Then - should not throw + Logging.removeListener(mockListener) + } + + // ===== All Log Levels Tests ===== + + test("all log levels should work correctly") { + // Given + Logging.logLevel = LogLevel.VERBOSE + val logLevels = listOf( + LogLevel.VERBOSE to { msg: String -> Logging.verbose(msg) }, + LogLevel.DEBUG to { msg: String -> Logging.debug(msg) }, + LogLevel.INFO to { msg: String -> Logging.info(msg) }, + LogLevel.WARN to { msg: String -> Logging.warn(msg) }, + LogLevel.ERROR to { msg: String -> Logging.error(msg) }, + LogLevel.FATAL to { msg: String -> Logging.fatal(msg) } + ) + + // When & Then - none should throw + logLevels.forEach { (level, logFn) -> + logFn("Test message for level $level") + } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt new file mode 100644 index 0000000000..67336bd367 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt @@ -0,0 +1,74 @@ +package com.onesignal.debug.internal.logging.otel.android + +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.otel.IOtelLogger +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.types.shouldBeInstanceOf + +class AndroidOtelLoggerTest : FunSpec({ + // Save original log level to restore after tests + val originalLogLevel = Logging.logLevel + + beforeEach { + // Disable logging during tests to avoid polluting test output + Logging.logLevel = LogLevel.NONE + } + + afterEach { + // Restore original log level + Logging.logLevel = originalLogLevel + } + + test("should implement IOtelLogger interface") { + val logger = AndroidOtelLogger() + + logger.shouldBeInstanceOf() + } + + test("error should not throw") { + val logger = AndroidOtelLogger() + + // Should not throw + logger.error("test error message") + } + + test("warn should not throw") { + val logger = AndroidOtelLogger() + + // Should not throw + logger.warn("test warn message") + } + + test("info should not throw") { + val logger = AndroidOtelLogger() + + // Should not throw + logger.info("test info message") + } + + test("debug should not throw") { + val logger = AndroidOtelLogger() + + // Should not throw + logger.debug("test debug message") + } + + test("should handle empty messages") { + val logger = AndroidOtelLogger() + + // Should not throw with empty messages + logger.error("") + logger.warn("") + logger.info("") + logger.debug("") + } + + test("should handle messages with special characters") { + val logger = AndroidOtelLogger() + + // Should not throw with special characters + logger.error("Error: \n\t special chars: @#$%^&*()") + logger.info("Unicode: 日本語 中文 한국어") + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt new file mode 100644 index 0000000000..86be0f189a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt @@ -0,0 +1,1051 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.IDManager.LOCAL_PREFIX +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.LogLevel +import com.onesignal.user.internal.backend.IdentityConstants +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.json.JSONArray +import org.json.JSONObject +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +@RobolectricTest +class OtelIdResolverTest : FunSpec({ + + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + // Helper function to ensure SharedPreferences data is written and verified + fun writeAndVerifyConfigData(configArray: JSONArray) { + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + } + + // Helper function to ensure SharedPreferences identity data is written and verified + fun writeAndVerifyIdentityData(identityArray: JSONArray) { + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + } + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // AGGRESSIVE CLEANUP: Clear ALL SharedPreferences before any test runs + // This ensures clean state even if previous test classes left data behind + sharedPreferences!!.edit().clear().commit() + try { + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + + beforeEach { + // Ensure appContext is initialized + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + } + + // Get a FRESH SharedPreferences instance for each test to avoid caching issues + // This ensures we're not reading stale data from previous tests + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + + // AGGRESSIVE CLEANUP: Clear ALL SharedPreferences to ensure complete isolation + sharedPreferences!!.edit().clear().commit() + + // Also clear any other potential SharedPreferences files + try { + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + + afterEach { + // Clean up after each test + sharedPreferences!!.edit().clear().commit() + + // Also clear any other potential SharedPreferences files + try { + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + + afterSpec { + // Final cleanup after all tests in this spec + if (appContext != null) { + try { + val prefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + prefs.edit().clear().commit() + + val otherPrefs = appContext!!.getSharedPreferences("com.onesignal", Context.MODE_PRIVATE) + otherPrefs.edit().clear().commit() + } catch (e: Exception) { + // Ignore any errors during cleanup + } + } + } + + // ===== resolveAppId Tests ===== + + test("resolveAppId returns appId from ConfigModelStore when available") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "test-app-id-123" + } + + test("resolveAppId returns empty string appId as null and falls back to legacy") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "") + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, "legacy-app-id") + .commit() + + // Ensure commit is complete before creating resolver + Thread.sleep(10) + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "legacy-app-id" + } + + test("resolveAppId falls back to legacy SharedPreferences when ConfigModelStore has no appId") { + // Given + val configModel = JSONObject() // No appId field + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .putString(PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID, "legacy-app-id") + .commit() + + // Ensure commit is complete before creating resolver + Thread.sleep(10) + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then + result shouldBe "legacy-app-id" + } + + test("resolveAppId returns error appId when ConfigModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then - ERROR_APP_ID_PREFIX_NO_CONFIG_STORE + result shouldBe "e1100000-0000-4000-a000-000000000002" + } + + test("resolveAppId returns error appId when ConfigModelStore is empty array") { + // Given + val configArray = JSONArray() // Empty array + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then - ERROR_APP_ID_PREFIX_NO_APPID_IN_CONFIG_STORE + result shouldBe "e1100000-0000-4000-a000-000000000003" + } + + test("resolveAppId returns error appId when context is null") { + // Given + val resolver = OtelIdResolver(null) + + // When + val result = resolver.resolveAppId() + + // Then - ERROR_APP_ID_PREFIX_NO_CONTEXT + result shouldBe "e1100000-0000-4000-a000-000000000004" + } + + test("resolveAppId handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveAppId() + + // Then - JSON parse error results in null configModel, so ERROR_APP_ID_PREFIX_NO_CONFIG_STORE + result shouldBe "e1100000-0000-4000-a000-000000000002" + } + + // ===== resolveOnesignalId Tests ===== + + test("resolveOnesignalId returns onesignalId from IdentityModelStore when available") { + // Given + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id-123") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe "test-onesignal-id-123" + } + + test("resolveOnesignalId returns null when onesignalId is empty string") { + // Given + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when onesignalId is a local ID") { + // Given + val localId = "${LOCAL_PREFIX}test-id" + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, localId) + } + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when IdentityModelStore has no onesignalId field") { + // Given + val identityModel = JSONObject() // No ONESIGNAL_ID field + val identityArray = JSONArray().apply { + put(identityModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when IdentityModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId returns null when IdentityModelStore is empty array") { + // Given + val identityArray = JSONArray() // Empty array + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, null) + if (verifyData == null || verifyData != identityArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + test("resolveOnesignalId handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveOnesignalId() + + // Then + result shouldBe null + } + + // ===== resolvePushSubscriptionId Tests ===== + + test("resolvePushSubscriptionId returns pushSubscriptionId from ConfigModelStore when available") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, "test-push-sub-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe "test-push-sub-id-123" + } + + test("resolvePushSubscriptionId returns null when pushSubscriptionId is empty string") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, "") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId returns null when pushSubscriptionId is a local ID") { + // Given + val localId = "${LOCAL_PREFIX}test-id" + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, localId) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId returns null when ConfigModelStore has no pushSubscriptionId field") { + // Given + val configModel = JSONObject() // No pushSubscriptionId field + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId returns null when ConfigModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + test("resolvePushSubscriptionId handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolvePushSubscriptionId() + + // Then + result shouldBe null + } + + // ===== 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") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "ERROR") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe LogLevel.ERROR + } + + test("resolveRemoteLogLevel returns LogLevel case-insensitively") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "warn") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe LogLevel.WARN + } + + test("resolveRemoteLogLevel returns null when logLevel field is missing") { + // Given + val remoteLoggingParams = JSONObject() // No logLevel field + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel returns null when remoteLoggingParams field is missing") { + // Given + val configModel = JSONObject() // No remoteLoggingParams field + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel returns null when logLevel is invalid") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "INVALID_LEVEL") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel returns null when ConfigModelStore is null") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + test("resolveRemoteLogLevel handles JSON parsing exceptions gracefully") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveRemoteLogLevel() + + // Then + result shouldBe null + } + + // ===== 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") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123") + .commit() + + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveInstallId() + + // Then + result shouldBe "test-install-id-123" + } + + test("resolveInstallId returns default InstallId-Null when not found") { + // Given + val resolver = OtelIdResolver(appContext!!) + + // When + val result = resolver.resolveInstallId() + + // Then + result shouldBe "InstallId-Null" + } + + test("resolveInstallId returns InstallId-NotFound when exception occurs") { + // Given + val mockContext = mockk(relaxed = true) + val mockSharedPreferences = mockk(relaxed = true) + every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") + + val resolver = OtelIdResolver(mockContext) + + // When + val result = resolver.resolveInstallId() + + // Then + result shouldBe "InstallId-NotFound" + } + + // ===== Caching Tests ===== + + test("cachedConfigModel is reused across multiple resolve calls") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id") + put(ConfigModel::pushSubscriptionId.name, "test-push-id") + } + val configArray = JSONArray().apply { + put(configModel) + } + // Write data and ensure it's committed + val editor = sharedPreferences!!.edit() + editor.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + editor.commit() // Use commit() to ensure synchronous write + + // Get a fresh SharedPreferences instance to ensure we read the latest data + val freshPrefs = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + val verifyData = freshPrefs.getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, null) + if (verifyData == null || verifyData != configArray.toString()) { + throw AssertionError("Failed to write SharedPreferences data - test isolation issue") + } + + val resolver = OtelIdResolver(appContext!!) + + // When - resolve multiple IDs + val appId1 = resolver.resolveAppId() + val pushId1 = resolver.resolvePushSubscriptionId() + val appId2 = resolver.resolveAppId() + val pushId2 = resolver.resolvePushSubscriptionId() + + // Then - should return same values (cached) + appId1 shouldBe "test-app-id" + pushId1 shouldBe "test-push-id" + appId2 shouldBe "test-app-id" + pushId2 shouldBe "test-push-id" + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt new file mode 100644 index 0000000000..f47e3b65ab --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt @@ -0,0 +1,903 @@ +package com.onesignal.debug.internal.logging.otel.android + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.backend.IdentityConstants +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.robolectric.annotation.Config +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace +import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace + +@RobolectricTest +@Config(sdk = [Build.VERSION_CODES.O]) +class OtelPlatformProviderTest : FunSpec({ + + var appContext: Context? = null + var sharedPreferences: SharedPreferences? = null + + beforeAny { + if (appContext == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + } + + beforeEach { + // Ensure sharedPreferences is initialized + if (sharedPreferences == null) { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext!!.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + } + // Clear SharedPreferences and reset wrapper + sharedPreferences!!.edit().clear().commit() + OneSignalWrapper.sdkType = null + OneSignalWrapper.sdkVersion = null + Logging.logLevel = LogLevel.NONE + } + + afterEach { + // Clean up + sharedPreferences!!.edit().clear().commit() + OneSignalWrapper.sdkType = null + OneSignalWrapper.sdkVersion = null + } + + // ===== Static Properties Tests ===== + + test("sdkBase returns android") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkBase + + // Then + result shouldBe "android" + } + + test("sdkBaseVersion returns OneSignalUtils.sdkVersion") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkBaseVersion + + // Then + result shouldBe OneSignalUtils.sdkVersion + } + + test("appPackageId returns context.packageName") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appPackageId + + // Then + result shouldBe appContext!!.packageName + } + + test("appVersion returns AndroidUtils.getAppVersion") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appVersion + + // Then + result shouldNotBe null + result shouldNotBe "" + } + + test("deviceManufacturer returns Build.MANUFACTURER") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.deviceManufacturer + + // Then + result shouldBe Build.MANUFACTURER + } + + test("deviceModel returns Build.MODEL") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.deviceModel + + // Then + result shouldBe Build.MODEL + } + + test("osName returns Android") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.osName + + // Then + result shouldBe "Android" + } + + test("osVersion returns Build.VERSION.RELEASE") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.osVersion + + // Then + result shouldBe Build.VERSION.RELEASE + } + + test("osBuildId returns Build.ID") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.osBuildId + + // Then + result shouldBe Build.ID + } + + test("sdkWrapper returns OneSignalWrapper.sdkType") { + // Given + OneSignalWrapper.sdkType = "Unity" + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapper + + // Then + result shouldBe "Unity" + } + + test("sdkWrapper returns null when not set") { + // Given + OneSignalWrapper.sdkType = null + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapper + + // Then + result shouldBe null + } + + test("sdkWrapperVersion returns OneSignalWrapper.sdkVersion") { + // Given + OneSignalWrapper.sdkVersion = "1.0.0" + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapperVersion + + // Then + result shouldBe "1.0.0" + } + + test("sdkWrapperVersion returns null when not set") { + // Given + OneSignalWrapper.sdkVersion = null + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.sdkWrapperVersion + + // Then + result shouldBe null + } + + // ===== Lazy ID Properties Tests ===== + + test("appId returns resolved appId from OtelIdResolver") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appId + + // Then + result shouldBe "test-app-id-123" + } + + test("appId returns error UUID when not available") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appId + + // Then - should return error appId (not null, but error UUID prefix) + result shouldNotBe null + result shouldContain "e1100000-0000-4000-a000-" + } + + test("onesignalId returns resolved onesignalId from OtelIdResolver") { + // Given + val identityModel = JSONObject().apply { + put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id-123") + } + val identityArray = JSONArray().apply { + put(identityModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.onesignalId + + // Then + result shouldBe "test-onesignal-id-123" + } + + test("onesignalId returns null when not available") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.onesignalId + + // Then + result shouldBe null + } + + test("pushSubscriptionId returns resolved pushSubscriptionId from OtelIdResolver") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::pushSubscriptionId.name, "test-push-sub-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.pushSubscriptionId + + // Then + result shouldBe "test-push-sub-id-123" + } + + test("pushSubscriptionId returns null when not available") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.pushSubscriptionId + + // Then + result shouldBe null + } + + // ===== appState Tests ===== + + test("appState returns foreground when getIsInForeground returns true") { + // Given + val getIsInForeground: () -> Boolean? = { true } + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = appContext, + getIsInForeground = getIsInForeground + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "foreground" + } + + test("appState returns background when getIsInForeground returns false") { + // Given + val getIsInForeground: () -> Boolean? = { false } + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = appContext, + getIsInForeground = getIsInForeground + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "background" + } + + test("appState falls back to ActivityManager when getIsInForeground is null") { + // Given + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = appContext, + getIsInForeground = null + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then - should return a valid state (foreground, background, or unknown) + result shouldBeOneOf listOf("foreground", "background", "unknown") + } + + test("appState returns unknown when context is null and getIsInForeground is null") { + // Given + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = null, + getIsInForeground = null + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "unknown" + } + + test("appState handles exceptions gracefully and returns unknown") { + // Given + val mockContext = mockk(relaxed = true) + every { mockContext.getSystemService(any()) } throws RuntimeException("Test exception") + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = mockContext, + getIsInForeground = null + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.appState + + // Then + result shouldBe "unknown" + } + + // ===== processUptime Tests ===== + + test("processUptime returns uptime in milliseconds") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.processUptime + + // Then + (result >= 0) shouldBe true + (result < 1000000.0) shouldBe true // Reasonable upper bound + } + + // ===== currentThreadName Tests ===== + + test("currentThreadName returns current thread name") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.currentThreadName + + // Then + result shouldNotBe null + result shouldNotBe "" + } + + // ===== crashStoragePath Tests ===== + + test("crashStoragePath returns configured path") { + // Given + val expectedPath = "/test/crash/path" + val config = OtelPlatformProviderConfig( + crashStoragePath = expectedPath, + appPackageId = "com.test", + appVersion = "1.0" + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.crashStoragePath + + // Then + result shouldBe expectedPath + } + + test("crashStoragePath logs info message on first access") { + // Given + val logSlot = slot() + val expectedPath = "/test/crash/path" + val config = OtelPlatformProviderConfig( + crashStoragePath = expectedPath, + appPackageId = "com.test", + appVersion = "1.0" + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.crashStoragePath + + // Then + result shouldBe expectedPath + // Note: We can't easily verify Logging.info was called without mocking Logging, + // but the behavior is tested by ensuring the path is returned correctly + } + + test("createAndroidOtelPlatformProvider sets correct crashStoragePath") { + // Given & When + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // Then + provider.crashStoragePath shouldContain "onesignal" + provider.crashStoragePath shouldContain "otel" + provider.crashStoragePath shouldContain "crashes" + } + + // ===== minFileAgeForReadMillis Tests ===== + + test("minFileAgeForReadMillis returns default value") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.minFileAgeForReadMillis + + // Then + result shouldBe 5_000L + } + + // ===== 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 null when no config exists (disabled)") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.remoteLogLevel + + // Then + 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") { + // Given + val remoteLoggingParams = JSONObject().apply { + put("logLevel", "WARN") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe "WARN" + } + + test("remoteLogLevel returns 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 { + put("logLevel", "NONE") + } + val configModel = JSONObject().apply { + put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams) + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe "NONE" + } + + test("remoteLogLevel returns null when exception occurs") { + // Given + val mockContext = mockk(relaxed = true) + every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception") + val config = OtelPlatformProviderConfig( + crashStoragePath = "/test/path", + appPackageId = "com.test", + appVersion = "1.0", + context = mockContext + ) + val provider = OtelPlatformProvider(config) + + // When + val result = provider.remoteLogLevel + + // Then + result shouldBe null + } + + // ===== appIdForHeaders Tests ===== + + test("appIdForHeaders returns appId when available") { + // Given + val configModel = JSONObject().apply { + put(ConfigModel::appId.name, "test-app-id-123") + } + val configArray = JSONArray().apply { + put(configModel) + } + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appIdForHeaders + + // Then + result shouldBe "test-app-id-123" + } + + test("appIdForHeaders returns empty string when appId is null") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = provider.appIdForHeaders + + // Then - even with error appId, it should return something (not empty) + result shouldNotBe null + } + + // ===== 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") { + // Given + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123") + .commit() + + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = runBlocking { provider.getInstallId() } + + // Then + result shouldBe "test-install-id-123" + } + + test("getInstallId returns default when not found") { + // Given + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // When + val result = runBlocking { provider.getInstallId() } + + // Then + result shouldBe "InstallId-Null" + } + + // ===== Factory Function Tests ===== + + test("createAndroidOtelPlatformProvider creates provider with correct config") { + // Given & When + val provider = createAndroidOtelPlatformProvider(appContext!!) + + // Then + provider.appPackageId shouldBe appContext!!.packageName + provider.sdkBase shouldBe "android" + provider.osName shouldBe "Android" + } + + // ===== 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) + val mockPackageManager = mockk(relaxed = true) + every { mockContext.packageName } returns "com.test" + every { mockContext.cacheDir } returns appContext!!.cacheDir + every { mockContext.packageManager } returns mockPackageManager + every { mockContext.getSharedPreferences(any(), any()) } returns sharedPreferences + // Make getPackageInfo throw NameNotFoundException to simulate missing package + every { mockPackageManager.getPackageInfo(any(), any()) } throws android.content.pm.PackageManager.NameNotFoundException() + + // When + val provider: OtelPlatformProvider = createAndroidOtelPlatformProvider(mockContext) + + // Then + provider.appVersion shouldBe "unknown" + } +}) + +// Helper extension for shouldBeOneOf +private infix fun T.shouldBeOneOf(expected: List) { + val isInList = expected.contains(this) + if (!isInList) { + throw AssertionError("Expected $this to be one of $expected") + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt new file mode 100644 index 0000000000..6fd5478cdd --- /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 0000000000..7cd5fb6418 --- /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 0000000000..c5c2380346 --- /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 64ae3a9b82..c3b7e72d80 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 @@ -446,7 +446,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/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index 79d9a76099..9bbd738d55 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -202,7 +202,7 @@ internal class InAppBackendService( statusCode: Int, response: String?, ) { - Logging.error("Encountered a $statusCode error while attempting in-app message $requestType request: $response") + Logging.info("Encountered a $statusCode error while attempting in-app message $requestType request: $response") } private suspend fun attemptFetchWithRetries( diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppDisplayer.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppDisplayer.kt index 9c7115cad3..7bf7c14fb2 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppDisplayer.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppDisplayer.kt @@ -149,7 +149,7 @@ internal class InAppDisplayer( } catch (e: Exception) { // Need to check error message to only catch MissingWebViewPackageException as it isn't public if (e.message != null && e.message!!.contains("No WebView installed")) { - Logging.error("Error setting up WebView: ", e) + Logging.info("Error setting up WebView: ", e) } else { throw e } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt index a86a8bed6b..f4b8f1263a 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/InAppMessageView.kt @@ -460,7 +460,7 @@ internal class InAppMessageView( */ suspend fun dismissAndAwaitNextMessage() { if (draggableRelativeLayout == null) { - Logging.error("No host presenter to trigger dismiss animation, counting as dismissed already") + Logging.info("No host presenter to trigger dismiss animation, counting as dismissed already") dereferenceViews() return } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/hydrators/InAppHydrator.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/hydrators/InAppHydrator.kt index 0518dc5c72..a860357bb6 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/hydrators/InAppHydrator.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/hydrators/InAppHydrator.kt @@ -30,7 +30,7 @@ internal class InAppHydrator( try { val content = InAppMessageContent(jsonObject) if (content.contentHtml == null) { - Logging.debug("displayMessage:OnSuccess: No HTML retrieved from loadMessageContent") + Logging.info("displayMessage:OnSuccess: No HTML retrieved from loadMessageContent") return null } diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt index 903183d369..bd26095c5a 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt @@ -102,7 +102,7 @@ internal class LocationManager( if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { if (!hasFinePermissionGranted && !hasCoarsePermissionGranted) { // Permission missing on manifest - Logging.error("Location permissions not added on AndroidManifest file < M") + Logging.info("Location permissions not added on AndroidManifest file < M") return@withContext false } diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt index e2e219fcd5..19c11038ad 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/controller/impl/HmsLocationController.kt @@ -50,7 +50,7 @@ internal class HmsLocationController( hmsFusedLocationClient = com.huawei.hms.location.LocationServices.getFusedLocationProviderClient(_applicationService.appContext) } catch (e: Exception) { - Logging.error("Huawei LocationServices getFusedLocationProviderClient failed! $e") + Logging.warn("Huawei LocationServices getFusedLocationProviderClient failed! $e") wasSuccessful = false return@withLock } @@ -75,7 +75,7 @@ internal class HmsLocationController( }, ) .addOnFailureListener { e -> - Logging.error("Huawei LocationServices getLastLocation failed!", e) + Logging.warn("Huawei LocationServices getLastLocation failed!", e) waiter.wake(false) } wasSuccessful = waiter.waitForWake() @@ -133,7 +133,7 @@ internal class HmsLocationController( }, ) .addOnFailureListener { e -> - Logging.error("Huawei LocationServices getLastLocation failed!", e) + Logging.warn("Huawei LocationServices getLastLocation failed!", e) waiter.wake() } waiter.waitForWake() diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt index 7a9a8fef54..d4599af876 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/bridges/OneSignalHmsEventBridge.kt @@ -87,7 +87,7 @@ object OneSignalHmsEventBridge { data = messageDataJSON.toString() } catch (e: JSONException) { - Logging.error("OneSignalHmsEventBridge error when trying to create RemoteMessage data JSON") + Logging.warn("OneSignalHmsEventBridge error when trying to create RemoteMessage data JSON") } // HMS notification with Message Type being Message won't trigger Activity reverse trampolining logic diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt index f5e9fe6e74..45577fc7c9 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt @@ -116,7 +116,7 @@ internal class NotificationChannelManager( ledColor = BigInteger(ledc, 16) channel.lightColor = ledColor.toInt() } catch (t: Throwable) { - Logging.error("Couldn't convert ARGB Hex value to BigInteger:", t) + Logging.warn("Couldn't convert ARGB Hex value to BigInteger:", t) } } channel.enableLights(payload.optInt("led", 1) == 1) @@ -211,7 +211,7 @@ internal class NotificationChannelManager( } catch (e: NullPointerException) { // Catch issue caused by "Attempt to invoke virtual method 'boolean android.app.NotificationChannel.isDeleted()' on a null object reference" // https://github.com/OneSignal/OneSignal-Android-SDK/issues/1291 - Logging.error("Error when trying to delete notification channel: " + e.message) + Logging.warn("Error when trying to delete notification channel: " + e.message) } // Delete old channels - Payload will include all changes for the app. Any extra OS_ ones must diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/OSWorkManagerHelper.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/OSWorkManagerHelper.kt index a0c9397984..9dfab0e6d7 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/OSWorkManagerHelper.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/OSWorkManagerHelper.kt @@ -26,7 +26,7 @@ object OSWorkManagerHelper { This aims to catch the IllegalStateException "WorkManager is not initialized properly..." - https://androidx.tech/artifacts/work/work-runtime/2.8.1-source/androidx/work/impl/WorkManagerImpl.java.html */ - Logging.error("OSWorkManagerHelper.getInstance failed, attempting to initialize: ", e) + Logging.warn("OSWorkManagerHelper.getInstance failed, attempting to initialize: ", e) initializeWorkManager(context) WorkManager.getInstance(context) } @@ -51,7 +51,7 @@ object OSWorkManagerHelper { 1. We lost the race with another call to WorkManager.initialize outside of OneSignal. 2. It is possible for some other unexpected error is thrown from WorkManager. */ - Logging.error("OSWorkManagerHelper initializing WorkManager failed: ", e) + Logging.warn("OSWorkManagerHelper initializing WorkManager failed: ", e) } } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt index 66c750e3c0..056540cac9 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt @@ -429,7 +429,7 @@ internal class NotificationRepository( } } } catch (t: Throwable) { - Logging.error("Error clearing oldest notifications over limit! ", t) + Logging.warn("Error clearing oldest notifications over limit! ", t) } } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt index 86ac61fe58..2fe22d6aa8 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/generation/impl/NotificationGenerationProcessor.kt @@ -85,9 +85,9 @@ internal class NotificationGenerationProcessor( }.join() } } catch (to: TimeoutCancellationException) { - Logging.error("remoteNotificationReceived timed out, continuing with wantsToDisplay=$wantsToDisplay.", to) + Logging.info("remoteNotificationReceived timed out, continuing with wantsToDisplay=$wantsToDisplay.", to) } catch (t: Throwable) { - Logging.error("remoteNotificationReceived threw an exception. Displaying normal OneSignal notification.", t) + Logging.info("remoteNotificationReceived threw an exception. Displaying normal OneSignal notification.", t) } var shouldDisplay = @@ -120,7 +120,7 @@ internal class NotificationGenerationProcessor( } catch (to: TimeoutCancellationException) { Logging.info("notificationWillShowInForegroundHandler timed out, continuing with wantsToDisplay=$wantsToDisplay.", to) } catch (t: Throwable) { - Logging.error( + Logging.info( "notificationWillShowInForegroundHandler threw an exception. Displaying normal OneSignal notification.", t, ) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt index eb4b3cac52..1235267aba 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/lifecycle/impl/NotificationLifecycleService.kt @@ -149,7 +149,7 @@ internal class NotificationLifecycleService( deviceType, ) } catch (ex: BackendException) { - Logging.error("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") + Logging.info("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") } } } @@ -266,20 +266,17 @@ internal class NotificationLifecycleService( val intent = intentGenerator.getIntentVisible() if (intent != null) { - Logging.info("SDK running startActivity with Intent: $intent") + Logging.debug("SDK running startActivity with Intent: $intent") activity.startActivity(intent) } else { - Logging.info("SDK not showing an Activity automatically due to it's settings.") + Logging.debug("SDK not showing an Activity automatically due to it's settings.") } } catch (e: JSONException) { - Logging.error("Could not parse JSON to open notification activity.") - e.printStackTrace() + Logging.error("Could not parse JSON to open notification activity.", e) } catch (e: ActivityNotFoundException) { - Logging.error("No activity found to handle notification open intent.") - e.printStackTrace() + Logging.warn("No activity found to handle notification open intent.", e) } catch (e: Exception) { - Logging.error("Could not open notification activity.") - e.printStackTrace() + Logging.error("Could not open notification activity.", e) } } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt index 4dffeec5c5..5813d156bb 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/pushtoken/PushTokenManager.kt @@ -18,11 +18,11 @@ internal class PushTokenManager( override suspend fun retrievePushToken(): PushTokenResponse { when (_deviceService.jetpackLibraryStatus) { IDeviceService.JetpackLibraryStatus.MISSING -> { - Logging.fatal("Could not find the Jetpack/AndroidX. Please make sure it has been correctly added to your project.") + Logging.info("Could not find the Jetpack/AndroidX. Please make sure it has been correctly added to your project.") pushTokenStatus = SubscriptionStatus.MISSING_JETPACK_LIBRARY } IDeviceService.JetpackLibraryStatus.OUTDATED -> { - Logging.fatal( + Logging.info( "The included Jetpack/AndroidX Library is too old or incomplete.", ) pushTokenStatus = SubscriptionStatus.OUTDATED_JETPACK_LIBRARY diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptProcessor.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptProcessor.kt index ad7827d11a..e15937d1d3 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptProcessor.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/receivereceipt/impl/ReceiveReceiptProcessor.kt @@ -20,7 +20,7 @@ internal class ReceiveReceiptProcessor( try { _backend.updateNotificationAsReceived(appId, notificationId, subscriptionId, deviceType) } catch (ex: BackendException) { - Logging.error("Receive receipt failed with statusCode: ${ex.statusCode} response: ${ex.response}") + Logging.info("Receive receipt failed with statusCode: ${ex.statusCode} response: ${ex.response}") } } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorADM.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorADM.kt index 98d1611302..6970196777 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorADM.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorADM.kt @@ -38,13 +38,13 @@ internal class PushRegistratorADM( result = if (registrationId != null) { - Logging.error("ADM registered with ID:$registrationId") + Logging.debug("ADM registered with ID:$registrationId") IPushRegistrator.RegisterResult( registrationId, SubscriptionStatus.SUBSCRIBED, ) } else { - Logging.error("com.onesignal.ADMMessageHandler timed out, please check that your have the receiver, service, and your package name matches(NOTE: Case Sensitive) per the OneSignal instructions.") + Logging.info("com.onesignal.ADMMessageHandler timed out, please check that your have the receiver, service, and your package name matches(NOTE: Case Sensitive) per the OneSignal instructions.") IPushRegistrator.RegisterResult( null, SubscriptionStatus.ERROR, diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorAbstractGoogle.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorAbstractGoogle.kt index d1d53fcdc6..ac24ca3558 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorAbstractGoogle.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorAbstractGoogle.kt @@ -57,12 +57,12 @@ internal abstract class PushRegistratorAbstractGoogle( } if (!_deviceService.hasFCMLibrary) { - Logging.fatal("The Firebase FCM library is missing! Please make sure to include it in your project.") + Logging.warn("The Firebase FCM library is missing! Please make sure to include it in your project.") return IPushRegistrator.RegisterResult(null, SubscriptionStatus.MISSING_FIREBASE_FCM_LIBRARY) } return if (!isValidProjectNumber(_configModelStore.model.googleProjectNumber)) { - Logging.error( + Logging.warn( "Missing Google Project number!\nPlease enter a Google Project number / Sender ID on under App Settings > Android > Configuration on the OneSignal dashboard.", ) IPushRegistrator.RegisterResult( @@ -84,14 +84,14 @@ internal abstract class PushRegistratorAbstractGoogle( registerInBackground(senderId) } else { _upgradePrompt.showUpdateGPSDialog() - Logging.error("'Google Play services' app not installed or disabled on the device.") + Logging.warn("'Google Play services' app not installed or disabled on the device.") IPushRegistrator.RegisterResult( null, SubscriptionStatus.OUTDATED_GOOGLE_PLAY_SERVICES_APP, ) } } catch (t: Throwable) { - Logging.error( + Logging.warn( "Could not register with $providerName due to an issue with your AndroidManifest.xml or with 'Google Play services'.", t, ) @@ -140,7 +140,7 @@ internal abstract class PushRegistratorAbstractGoogle( // Wrapping with new Exception so the current line is included in the stack trace. val exception = Exception(e) if (currentRetry >= REGISTRATION_RETRY_COUNT - 1) { - Logging.error("Retry count of $REGISTRATION_RETRY_COUNT exceed! Could not get a $providerName Token.", exception) + Logging.info("Retry count of $REGISTRATION_RETRY_COUNT exceed! Could not get a $providerName Token.", exception) } else { Logging.info("'Google Play services' returned $exceptionMessage error. Current retry count: $currentRetry", exception) @@ -152,12 +152,12 @@ internal abstract class PushRegistratorAbstractGoogle( } else { // Wrapping with new Exception so the current line is included in the stack trace. val exception = Exception(e) - Logging.error("Error Getting $providerName Token", exception) + Logging.warn("Error Getting $providerName Token", exception) return IPushRegistrator.RegisterResult(null, pushStatus) } } catch (t: Throwable) { - Logging.error("Unknown error getting $providerName Token", t) + Logging.warn("Unknown error getting $providerName Token", t) return IPushRegistrator.RegisterResult( null, SubscriptionStatus.FIREBASE_FCM_ERROR_MISC_EXCEPTION, diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt index b568c34e9d..d637242cdc 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorHMS.kt @@ -78,13 +78,13 @@ internal class PushRegistratorHMS( } return if (pushToken != null) { - Logging.error("HMS registered with ID:$pushToken") + Logging.debug("HMS registered with ID:$pushToken") IPushRegistrator.RegisterResult( pushToken, SubscriptionStatus.SUBSCRIBED, ) } else { - Logging.error("HmsMessageServiceOneSignal.onNewToken timed out.") + Logging.warn("HmsMessageServiceOneSignal.onNewToken timed out.") IPushRegistrator.RegisterResult( null, SubscriptionStatus.HMS_TOKEN_TIMEOUT, diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreProcessor.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreProcessor.kt index d00de6ce8d..bc2ba38c7d 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreProcessor.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreProcessor.kt @@ -30,7 +30,7 @@ internal class NotificationRestoreProcessor( _badgeCountUpdater.update() } catch (t: Throwable) { - Logging.error("Error restoring notification records! ", t) + Logging.warn("Error restoring notification records! ", t) } } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt index bb2b567850..5a95221ccd 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt @@ -16,13 +16,14 @@ internal class NotificationRestoreWorkManager : INotificationRestoreWorkManager // Notifications will never be force removed when the app's process is running, // so we only need to restore at most once per cold start of the app. private var restored = false + private val lock = Any() override fun beginEnqueueingWork( context: Context, shouldDelay: Boolean, ) { // Only allow one piece of work to be enqueued. - synchronized(restored) { + synchronized(lock) { if (restored) { return } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt index cc8d9c2e2e..5434bb13d7 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandler.kt @@ -34,10 +34,10 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") { } override fun onRegistrationError(error: String) { - Logging.error("ADM:onRegistrationError: $error") + Logging.info("ADM:onRegistrationError: $error") if ("INVALID_SENDER" == error) { - Logging.error( + Logging.info( "Please double check that you have a matching package name (NOTE: Case Sensitive), api_key.txt, and the apk was signed with the same Keystore and Alias.", ) } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt index c707333743..f309538d23 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/services/ADMMessageHandlerJob.kt @@ -50,9 +50,9 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() { context: Context?, error: String?, ) { - Logging.error("ADM:onRegistrationError: $error") + Logging.info("ADM:onRegistrationError: $error") if ("INVALID_SENDER" == error) { - Logging.error( + Logging.info( "Please double check that you have a matching package name (NOTE: Case Sensitive), api_key.txt, and the apk was signed with the same Keystore and Alias.", ) } diff --git a/OneSignalSDK/onesignal/otel/.gitignore b/OneSignalSDK/onesignal/otel/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/.gitignore @@ -0,0 +1 @@ +/build diff --git a/OneSignalSDK/onesignal/otel/build.gradle b/OneSignalSDK/onesignal/otel/build.gradle new file mode 100644 index 0000000000..7860f04201 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.diffplug.spotless' + id 'io.gitlab.arturbosch.detekt' +} + +android { + namespace 'com.onesignal.otel' + compileSdkVersion rootProject.buildVersions.compileSdkVersion + + defaultConfig { + minSdkVersion 26 + consumerProguardFiles "consumer-rules.pro" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + original { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + release { + minifyEnabled false + } + unity { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + freeCompilerArgs += ['-module-name', namespace] + } +} + +ext { + projectName = "OneSignal SDK Otel" + projectDescription = "OneSignal Android SDK - OpenTelemetry Module" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" + + implementation platform("io.opentelemetry:opentelemetry-bom:$rootProject.opentelemetryBomVersion") + implementation('io.opentelemetry:opentelemetry-api') + implementation('io.opentelemetry:opentelemetry-sdk') + implementation('io.opentelemetry:opentelemetry-exporter-otlp') + implementation("io.opentelemetry.semconv:opentelemetry-semconv:$rootProject.opentelemetrySemconvVersion") + implementation("io.opentelemetry.contrib:opentelemetry-disk-buffering:$rootProject.opentelemetryDiskBufferingVersion") + + testImplementation(project(':OneSignal:testhelpers')) + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.mockk:mockk:$ioMockVersion") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") +} + +apply from: '../detekt.gradle' +apply from: '../spotless.gradle' diff --git a/OneSignalSDK/onesignal/otel/consumer-rules.pro b/OneSignalSDK/onesignal/otel/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/OneSignalSDK/onesignal/otel/proguard-rules.pro b/OneSignalSDK/onesignal/otel/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/OneSignalSDK/onesignal/otel/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt new file mode 100644 index 0000000000..93b31fc75f --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt @@ -0,0 +1,19 @@ +package com.onesignal.otel + +/** + * Platform-agnostic crash handler interface. + * This should be initialized as early as possible and be independent of service architecture. + */ +interface IOtelCrashHandler { + /** + * Initialize the crash handler. This should be called as early as possible, + * before any other initialization that might crash. + */ + fun initialize() + + /** + * 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/IOtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt new file mode 100644 index 0000000000..4ab4a7e8b6 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt @@ -0,0 +1,8 @@ +package com.onesignal.otel + +/** + * Platform-agnostic crash reporter interface. + */ +interface IOtelCrashReporter { + suspend fun saveCrash(thread: Thread, throwable: Throwable) +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt new file mode 100644 index 0000000000..510ffab2eb --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt @@ -0,0 +1,35 @@ +package com.onesignal.otel + +/** + * Platform-agnostic logger interface for the Otel module. + * Implementations should be provided by the platform (Android/iOS). + */ +interface IOtelLogger { + /** + * Logs an error message. + * + * @param message The error message to log + */ + fun error(message: String) + + /** + * Logs a warning message. + * + * @param message The warning message to log + */ + fun warn(message: String) + + /** + * Logs an informational message. + * + * @param message The info message to log + */ + fun info(message: String) + + /** + * Logs a debug message. + * + * @param message The debug message to log + */ + fun debug(message: String) +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt new file mode 100644 index 0000000000..156df29ffd --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt @@ -0,0 +1,45 @@ +package com.onesignal.otel + +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.export.LogRecordExporter + +/** + * Platform-agnostic OpenTelemetry interface. + */ +interface IOtelOpenTelemetry { + /** + * Gets a LogRecordBuilder for creating log records. + * This is a suspend function as it may need to initialize the SDK on first call. + * + * @return A LogRecordBuilder instance for building log records + */ + suspend fun getLogger(): LogRecordBuilder + + /** + * Forces a flush of all pending log records. + * This ensures all buffered logs are exported immediately. + * + * @return A CompletableResultCode indicating the flush operation result + */ + suspend fun forceFlush(): CompletableResultCode + + /** + * 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() +} + +/** + * Interface for crash-specific OpenTelemetry (local file storage). + */ +interface IOtelOpenTelemetryCrash : IOtelOpenTelemetry + +/** + * Interface for remote OpenTelemetry (network export). + */ +interface IOtelOpenTelemetryRemote : IOtelOpenTelemetry { + val logExporter: LogRecordExporter +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt new file mode 100644 index 0000000000..98978ee19b --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt @@ -0,0 +1,64 @@ +package com.onesignal.otel + +/** + * Platform-agnostic provider interface for injecting platform-specific values. + * All Android/iOS specific values should be provided through this interface. + */ +interface IOtelPlatformProvider { + // Top-level attributes (static, calculated once) + /** + * Gets the installation ID for this device. + * This is an async operation as it may need to generate a new ID if one doesn't exist. + * + * @return The installation ID as a string + */ + suspend fun getInstallId(): String + val sdkBase: String + val sdkBaseVersion: String + val appPackageId: String + val appVersion: String + val deviceManufacturer: String + val deviceModel: String + val osName: String + val osVersion: String + val osBuildId: String + val sdkWrapper: String? + val sdkWrapperVersion: String? + + // Per-event attributes (dynamic, calculated per event) + val appId: String? + val onesignalId: String? + val pushSubscriptionId: String? + val appState: String // "foreground" or "background" + val processUptime: Long // in milliseconds + val currentThreadName: String + + // Crash-specific configuration + val crashStoragePath: String + val minFileAgeForReadMillis: Long + + // Remote logging configuration + /** + * 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 new file mode 100644 index 0000000000..3e470f9422 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt @@ -0,0 +1,148 @@ +package com.onesignal.otel + +import com.onesignal.otel.attributes.OtelFieldsPerEvent +import com.onesignal.otel.attributes.OtelFieldsTopLevel +import com.onesignal.otel.config.OtelConfigCrashFile +import com.onesignal.otel.config.OtelConfigRemoteOneSignal +import com.onesignal.otel.config.OtelConfigShared +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.common.CompletableResultCode +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal fun LogRecordBuilder.setAllAttributes(attributes: Map): LogRecordBuilder { + attributes.forEach { this.setAttribute(it.key, it.value) } + return this +} + +/** + * Extension function to set all attributes from an Attributes object. + * Made public so it can be used from other modules (e.g., core module for logging). + */ +fun LogRecordBuilder.setAllAttributes(attributes: io.opentelemetry.api.common.Attributes): LogRecordBuilder { + attributes.forEach { key, value -> + val keyString = key.key + when (value) { + is String -> this.setAttribute(keyString, value) + is Long -> this.setAttribute(keyString, value) + is Double -> this.setAttribute(keyString, value) + is Boolean -> this.setAttribute(keyString, value) + else -> this.setAttribute(keyString, value.toString()) + } + } + return this +} + +internal abstract class OneSignalOpenTelemetryBase( + private val osTopLevelFields: OtelFieldsTopLevel, + private val osPerEventFields: OtelFieldsPerEvent, +) : IOtelOpenTelemetry { + private val lock = Any() + private var sdkCachedValue: OpenTelemetrySdk? = null + + protected suspend fun getSdk(): OpenTelemetrySdk { + val attributes = osTopLevelFields.getAttributes() + synchronized(lock) { + var localSdk = sdkCachedValue + if (localSdk != null) { + return localSdk + } + + localSdk = getSdkInstance(attributes) + sdkCachedValue = localSdk + return localSdk + } + } + + protected abstract fun getSdkInstance(attributes: Map): OpenTelemetrySdk + + override suspend fun forceFlush(): CompletableResultCode { + val sdkLoggerProvider = getSdk().sdkLoggerProvider + return suspendCoroutine { + it.resume( + sdkLoggerProvider.forceFlush().join(FORCE_FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + } + } + + @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 + } + + override suspend fun getLogger(): LogRecordBuilder = + getSdk() + .sdkLoggerProvider + .loggerBuilder("loggerBuilder") + .build() + .logRecordBuilder() + .setAllAttributes(osPerEventFields.getAttributes()) +} + +internal class OneSignalOpenTelemetryRemote( + private val platformProvider: IOtelPlatformProvider, + osTopLevelFields: OtelFieldsTopLevel, + osPerEventFields: OtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields), + IOtelOpenTelemetryRemote { + + private val appId: String get() = platformProvider.appIdForHeaders + + val extraHttpHeaders: Map by lazy { + mapOf( + "X-OneSignal-App-Id" to appId, + "X-OneSignal-SDK-Version" to platformProvider.sdkBaseVersion, + ) + } + + private val apiBaseUrl: String get() = platformProvider.apiBaseUrl + + override val logExporter by lazy { + OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders, appId, apiBaseUrl) + } + + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = + OpenTelemetrySdk + .builder() + .setLoggerProvider( + OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create(attributes), + extraHttpHeaders, + appId, + apiBaseUrl, + ) + ).build() +} + +internal class OneSignalOpenTelemetryCrashLocal( + private val platformProvider: IOtelPlatformProvider, + osTopLevelFields: OtelFieldsTopLevel, + osPerEventFields: OtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields), + IOtelOpenTelemetryCrash { + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = + OpenTelemetrySdk + .builder() + .setLoggerProvider( + OtelConfigCrashFile.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create( + attributes + ), + platformProvider.crashStoragePath, + platformProvider.minFileAgeForReadMillis, + ) + ).build() +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt new file mode 100644 index 0000000000..c4e46e6630 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt @@ -0,0 +1,112 @@ +package com.onesignal.otel + +import com.onesignal.otel.attributes.OtelFieldsPerEvent +import com.onesignal.otel.attributes.OtelFieldsTopLevel +import com.onesignal.otel.crash.OtelCrashHandler +import com.onesignal.otel.crash.OtelCrashReporter +import com.onesignal.otel.crash.OtelCrashUploader + +/** + * Factory class for creating Otel components. + * This allows for fast initialization of the crash handler with all dependencies + * pre-populated. + */ +object OtelFactory { + /** + * Creates a fully configured crash handler that can be initialized immediately. + * All fields are pre-populated for fast initialization. + * + * This method composes other factory methods to create the crash handler, + * ensuring consistency and reducing duplication. + */ + fun createCrashHandler( + platformProvider: IOtelPlatformProvider, + logger: IOtelLogger, + ): IOtelCrashHandler { + val crashLocal = createCrashLocalTelemetry(platformProvider) + val crashReporter = createCrashReporter(crashLocal, logger) + return OtelCrashHandler(crashReporter, logger) + } + + /** + * Creates a crash uploader for sending crash reports to the server. + * + * This is platform-agnostic and can be used in KMP projects. + * All platform-specific values must be provided through IOtelPlatformProvider. + * + * @param platformProvider Platform-specific provider that injects all required values + * @param logger Platform-specific logger implementation + * @return Platform-agnostic crash uploader that can be used on Android/iOS + */ + fun createCrashUploader( + platformProvider: IOtelPlatformProvider, + logger: IOtelLogger, + ): OtelCrashUploader { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + val remote = OneSignalOpenTelemetryRemote( + platformProvider, + topLevelFields, + perEventFields + ) + return OtelCrashUploader(remote, platformProvider, logger) + } + + /** + * Creates a remote OpenTelemetry instance for logging SDK events. + * + * This is platform-agnostic and can be used in KMP projects. + * All platform-specific values must be provided through IOtelPlatformProvider. + * + * @param platformProvider Platform-specific provider that injects all required values + * @return Platform-agnostic remote telemetry instance for logging + */ + fun createRemoteTelemetry( + platformProvider: IOtelPlatformProvider, + ): IOtelOpenTelemetryRemote { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + return OneSignalOpenTelemetryRemote( + platformProvider, + topLevelFields, + perEventFields + ) + } + + /** + * Creates a local OpenTelemetry crash instance for saving crash reports locally. + * + * This is platform-agnostic and can be used in KMP projects. + * All platform-specific values must be provided through IOtelPlatformProvider. + * + * @param platformProvider Platform-specific provider that injects all required values + * @return Platform-agnostic crash local telemetry instance + */ + fun createCrashLocalTelemetry( + platformProvider: IOtelPlatformProvider, + ): IOtelOpenTelemetryCrash { + val topLevelFields = OtelFieldsTopLevel(platformProvider) + val perEventFields = OtelFieldsPerEvent(platformProvider) + return OneSignalOpenTelemetryCrashLocal( + platformProvider, + topLevelFields, + perEventFields + ) + } + + /** + * Creates a crash reporter for saving crash reports. + * + * This is platform-agnostic and can be used in KMP projects. + * + * @param openTelemetryCrash The crash telemetry instance to use + * @param logger Platform-specific logger implementation + * @return Platform-agnostic crash reporter + */ + fun createCrashReporter( + openTelemetryCrash: IOtelOpenTelemetryCrash, + logger: IOtelLogger, + ): IOtelCrashReporter { + return OtelCrashReporter(openTelemetryCrash, logger) + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt new file mode 100644 index 0000000000..8b1c85c7b0 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt @@ -0,0 +1,65 @@ +package com.onesignal.otel + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity +import java.time.Instant + +/** + * Helper class for logging to Otel from the Logging class. + * This abstracts away OpenTelemetry-specific types so the core module + * doesn't need direct OpenTelemetry dependencies. + */ +object OtelLoggingHelper { + /** + * Logs a message to Otel remote telemetry. + * This method handles all OpenTelemetry-specific types internally. + * + * @param telemetry The Otel remote telemetry instance + * @param level The log level as a string (VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL) + * @param message The log message + * @param exceptionType Optional exception type + * @param exceptionMessage Optional exception message + * @param exceptionStacktrace Optional exception stacktrace + */ + suspend fun logToOtel( + telemetry: IOtelOpenTelemetryRemote, + level: String, + message: String, + exceptionType: String? = null, + exceptionMessage: String? = null, + exceptionStacktrace: String? = null, + ) { + val severity = when (level.uppercase()) { + "VERBOSE" -> Severity.TRACE + "DEBUG" -> Severity.DEBUG + "INFO" -> Severity.INFO + "WARN" -> Severity.WARN + "ERROR" -> Severity.ERROR + "FATAL" -> Severity.FATAL + else -> Severity.INFO + } + + val attributes = Attributes.builder() + .put("log.message", message) + .put("log.level", level) + .apply { + if (exceptionType != null) { + put("exception.type", exceptionType) + } + if (exceptionMessage != null) { + put("exception.message", exceptionMessage) + } + if (exceptionStacktrace != null) { + put("exception.stacktrace", exceptionStacktrace) + } + } + .build() + + val logRecordBuilder = telemetry.getLogger() + logRecordBuilder.setAllAttributes(attributes) + logRecordBuilder.setSeverity(severity) + logRecordBuilder.setBody(message) + logRecordBuilder.setTimestamp(Instant.now()) + logRecordBuilder.emit() + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt new file mode 100644 index 0000000000..2d2cb20026 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt @@ -0,0 +1,35 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import com.squareup.wire.internal.toUnmodifiableMap +import java.util.UUID + +/** + * Purpose: Fields to be included in each individual Otel event. + * These can change during runtime. + */ +internal class OtelFieldsPerEvent( + private val platformProvider: IOtelPlatformProvider, +) { + fun getAttributes(): Map { + val attributes: MutableMap = mutableMapOf() + + attributes["log.record.uid"] = recordId.toString() + + attributes + .putIfValueNotNull("ossdk.app_id", platformProvider.appId) + .putIfValueNotNull("ossdk.onesignal_id", platformProvider.onesignalId) + .putIfValueNotNull("ossdk.push_subscription_id", platformProvider.pushSubscriptionId) + + // Use platform-agnostic attribute name (works for both Android and iOS) + attributes["app.state"] = platformProvider.appState + attributes["process.uptime"] = platformProvider.processUptime.toString() + attributes["thread.name"] = platformProvider.currentThreadName + + return attributes.toUnmodifiableMap() + } + + // idempotency so the backend can filter on duplicate events + // https://opentelemetry.io/docs/specs/semconv/general/logs/#general-log-identification-attributes + private val recordId: UUID get() = UUID.randomUUID() +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt new file mode 100644 index 0000000000..8021535f67 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt @@ -0,0 +1,42 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import com.squareup.wire.internal.toUnmodifiableMap + +/** + * Purpose: Fields to be included in every Otel request that goes out. + * Requirements: Only include fields that can NOT change during runtime, + * as these are only fetched once. (Calculated fields are ok) + */ +internal class OtelFieldsTopLevel( + private val platformProvider: IOtelPlatformProvider, +) { + suspend fun getAttributes(): Map { + val attributes: MutableMap = + mutableMapOf( + "ossdk.install_id" to platformProvider.getInstallId(), + "ossdk.sdk_base" to platformProvider.sdkBase, + "ossdk.sdk_base_version" to platformProvider.sdkBaseVersion, + "ossdk.app_package_id" to platformProvider.appPackageId, + "ossdk.app_version" to platformProvider.appVersion, + "device.manufacturer" to platformProvider.deviceManufacturer, + "device.model.identifier" to platformProvider.deviceModel, + "os.name" to platformProvider.osName, + "os.version" to platformProvider.osVersion, + "os.build_id" to platformProvider.osBuildId, + ) + + attributes + .putIfValueNotNull("ossdk.sdk_wrapper", platformProvider.sdkWrapper) + .putIfValueNotNull("ossdk.sdk_wrapper_version", platformProvider.sdkWrapperVersion) + + return attributes.toUnmodifiableMap() + } +} + +internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { + if (value != null) { + this[key] = value + } + return this +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt new file mode 100644 index 0000000000..aa99748589 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt @@ -0,0 +1,50 @@ +package com.onesignal.otel.config + +import io.opentelemetry.contrib.disk.buffering.exporters.LogRecordToDiskExporter +import io.opentelemetry.contrib.disk.buffering.storage.impl.FileLogRecordStorage +import io.opentelemetry.contrib.disk.buffering.storage.impl.FileStorageConfiguration +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import java.io.File +import kotlin.time.Duration.Companion.hours + +internal class OtelConfigCrashFile { + internal object SdkLoggerProviderConfig { + // NOTE: Only use such as small maxFileAgeForWrite for + // crashes, as we want to send them as soon as possible + // without having to wait too long for buffers. + private const val MAX_FILE_AGE_FOR_WRITE_MILLIS = 2_000L + + fun getFileLogRecordStorage( + rootDir: String, + minFileAgeForReadMillis: Long + ): FileLogRecordStorage = + FileLogRecordStorage.create( + File(rootDir), + FileStorageConfiguration + .builder() + .setMaxFileAgeForWriteMillis(MAX_FILE_AGE_FOR_WRITE_MILLIS) + .setMinFileAgeForReadMillis(minFileAgeForReadMillis) + .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds) + .build() + ) + + fun create( + resource: io.opentelemetry.sdk.resources.Resource, + rootDir: String, + minFileAgeForReadMillis: Long, + ): SdkLoggerProvider { + val logToDiskExporter = + LogRecordToDiskExporter + .builder(getFileLogRecordStorage(rootDir, minFileAgeForReadMillis)) + .build() + return SdkLoggerProvider + .builder() + .setResource(resource) + .addLogRecordProcessor( + BatchLogRecordProcessor.builder(logToDiskExporter).build() + ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) + .build() + } + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt new file mode 100644 index 0000000000..b6d877dda8 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt @@ -0,0 +1,57 @@ +package com.onesignal.otel.config + +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import java.time.Duration + +internal class OtelConfigRemoteOneSignal { + 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 + + fun otlpHttpLogRecordExporter( + headers: Map, + endpoint: String, + ): LogRecordExporter { + val builder = OtlpHttpLogRecordExporter.builder() + headers.forEach { builder.addHeader(it.key, it.value) } + builder + .setEndpoint(endpoint) + .setTimeout(Duration.ofSeconds(EXPORTER_TIMEOUT_SECONDS)) + return builder.build() + } + } + + object SdkLoggerProviderConfig { + 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, apiBaseUrl) + ) + ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) + .build() + } + + object HttpRecordBatchExporter { + fun create(extraHttpHeaders: Map, appId: String, apiBaseUrl: String) = + LogRecordExporterConfig.otlpHttpLogRecordExporter( + extraHttpHeaders, + buildEndpoint(apiBaseUrl, appId) + ) + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt new file mode 100644 index 0000000000..f54b3d5590 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt @@ -0,0 +1,58 @@ +package com.onesignal.otel.config + +import io.opentelemetry.sdk.logs.LogLimits +import io.opentelemetry.sdk.logs.LogRecordProcessor +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.resources.ResourceBuilder +import io.opentelemetry.semconv.ServiceAttributes +import java.time.Duration + +internal fun ResourceBuilder.putAll(attributes: Map): ResourceBuilder { + attributes.forEach { this.put(it.key, it.value) } + return this +} + +internal class OtelConfigShared { + object ResourceConfig { + fun create(attributes: Map): Resource = + Resource + .getDefault() + .toBuilder() + .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") + .putAll(attributes) + .build() + } + + object LogRecordProcessorConfig { + private const val MAX_QUEUE_SIZE = 100 + private const val MAX_EXPORT_BATCH_SIZE = 100 + private const val EXPORTER_TIMEOUT_SECONDS = 30L + private const val SCHEDULE_DELAY_SECONDS = 1L + + fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor = + BatchLogRecordProcessor + .builder(logRecordExporter) + .setMaxQueueSize(MAX_QUEUE_SIZE) + .setMaxExportBatchSize(MAX_EXPORT_BATCH_SIZE) + .setExporterTimeout(Duration.ofSeconds(EXPORTER_TIMEOUT_SECONDS)) + .setScheduleDelay(Duration.ofSeconds(SCHEDULE_DELAY_SECONDS)) + .build() + } + + object LogLimitsConfig { + private const val MAX_NUMBER_OF_ATTRIBUTES = 128 + + // We want a high value max length as the exception.stacktrace + // value can be lengthly. + private const val MAX_ATTRIBUTE_VALUE_LENGTH = 32000 + + fun logLimits(): LogLimits = + LogLimits + .builder() + .setMaxNumberOfAttributes(MAX_NUMBER_OF_ATTRIBUTES) + .setMaxAttributeValueLength(MAX_ATTRIBUTE_VALUE_LENGTH) + .build() + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt new file mode 100644 index 0000000000..b7b5027ee8 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt @@ -0,0 +1,21 @@ +package com.onesignal.otel.crash + +/** + * Platform-agnostic interface for ANR (Application Not Responding) detection. + * + * ANRs occur when the main thread is blocked for too long (typically >5 seconds on Android). + * Unlike crashes, ANRs don't throw exceptions - they're detected by monitoring thread responsiveness. + */ +interface IOtelAnrDetector { + /** + * Starts monitoring for ANRs. + * This should be called early in the app lifecycle, ideally right after crash handler initialization. + */ + fun start() + + /** + * Stops monitoring for ANRs. + * Should be called when the app is shutting down or when monitoring is no longer needed. + */ + fun stop() +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt new file mode 100644 index 0000000000..9581c069ea --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt @@ -0,0 +1,127 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import kotlinx.coroutines.runBlocking + +/** + * Purpose: Writes any crashes involving OneSignal to a file where they can + * later be send to OneSignal to help improve reliability. + * NOTE: For future refactors, code is written assuming this is a singleton + * + * This should be initialized as early as possible, before any other initialization + * that might crash. All fields must be pre-populated before initialization. + */ +internal class OtelCrashHandler( + private val crashReporter: IOtelCrashReporter, + private val logger: IOtelLogger, +) : Thread.UncaughtExceptionHandler, com.onesignal.otel.IOtelCrashHandler { + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() + + @Volatile + private var initialized = false + + override fun initialize() { + if (initialized) { + logger.warn("OtelCrashHandler already initialized, skipping") + return + } + logger.info("OtelCrashHandler: Setting up uncaught exception handler...") + existingHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + initialized = true + logger.info("OtelCrashHandler: ✅ Successfully initialized and registered as default uncaught exception handler") + } + + override fun 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 + // handler and was faulty in a specific way. + synchronized(seenThrowables) { + if (seenThrowables.contains(throwable)) { + logger.warn("OtelCrashHandler: Ignoring duplicate throwable instance") + return + } + seenThrowables.add(throwable) + } + + logger.info("OtelCrashHandler: Uncaught exception detected - ${throwable.javaClass.simpleName}: ${throwable.message}") + + // Check if this is an ANR exception (though standalone ANR detector already handles ANRs) + // This would only catch ANRs if they're thrown as exceptions, which is rare + val isAnr = throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) || + throwable.message?.contains("Application Not Responding", ignoreCase = true) == true + + // NOTE: Future improvements: + // - Catch anything we may throw and print only to logcat + // - Send a stop command to OneSignalCrashUploader, give a bit of time to finish + // and then call existingHandler. This way the app doesn't have to open a 2nd + // time to get the crash report and should help prevent duplicated reports. + // NOTE: ANRs are typically detected by the standalone OtelAnrDetector, which only + // reports OneSignal-related ANRs. This handler would only catch ANRs if they're + // thrown as exceptions (unlikely), and we still check if OneSignal is at fault. + if (!isAnr && !isOneSignalAtFault(throwable)) { + logger.debug("OtelCrashHandler: Crash is not OneSignal-related, delegating to existing handler") + existingHandler?.uncaughtException(thread, throwable) + return + } + + if (isAnr) { + logger.info("OtelCrashHandler: ANR exception caught (unusual - ANRs are usually detected by standalone detector)") + } + + logger.info("OtelCrashHandler: OneSignal-related crash detected, saving crash report...") + + /** + * NOTE: The order and running sequentially is important as: + * The existingHandler.uncaughtException can immediately terminate the + * process, either directly (if this is Android's + * KillApplicationHandler) OR the app's handler / 3rd party SDK (either + * directly or more likely, by it calling Android's + * KillApplicationHandler). + * Given this, we can't parallelize the existingHandler work with ours. + * The safest thing is to try to finish our work as fast as possible + * (including ensuring our logging write buffers are flushed) then call + * the existingHandler so any crash handlers the app also has gets the + * crash even too. + * + * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for + * Process.killProcess, which KillApplicationHandler calls. + */ + try { + runBlocking { crashReporter.saveCrash(thread, throwable) } + logger.info("OtelCrashHandler: Crash report saved successfully") + } catch (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) + } +} + +/** + * Checks if a throwable's stack trace indicates OneSignal is at fault. + * Centralized logic used by both crash handler and ANR detector. + */ +internal fun isOneSignalAtFault(throwable: Throwable): Boolean = + isOneSignalAtFault(throwable.stackTrace) + +/** + * Helper function to check if a stack trace indicates OneSignal is at fault. + * Centralized logic used by both crash handler and ANR detector. + * Made public so it can be accessed from core module. + */ +fun isOneSignalAtFault(stackTrace: Array): Boolean = + stackTrace.any { it.className.startsWith("com.onesignal") } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt new file mode 100644 index 0000000000..b875f906cb --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt @@ -0,0 +1,63 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity +import java.time.Instant + +internal class OtelCrashReporter( + private val openTelemetry: IOtelOpenTelemetryCrash, + private val logger: IOtelLogger, +) : com.onesignal.otel.IOtelCrashReporter { + companion object { + private const val OTEL_EXCEPTION_TYPE = "exception.type" + private const val OTEL_EXCEPTION_MESSAGE = "exception.message" + private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" + private const val OTEL_EXCEPTION_THREAD_NAME = "ossdk.exception.thread.name" + } + + override suspend fun saveCrash(thread: Thread, throwable: Throwable) { + try { + logger.info("OtelCrashReporter: Starting to save crash report for ${throwable.javaClass.simpleName}") + + val attributes = + Attributes + .builder() + .put(OTEL_EXCEPTION_MESSAGE, throwable.message ?: "") + .put(OTEL_EXCEPTION_STACKTRACE, throwable.stackTraceToString()) + .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name) + // This matches the top level thread.name today, but it may not + // always if things are refactored to use a different thread. + .put(OTEL_EXCEPTION_THREAD_NAME, thread.name) + .build() + + logger.debug("OtelCrashReporter: Creating log record with attributes...") + openTelemetry + .getLogger() + .setAllAttributes(attributes) + .setSeverity(Severity.FATAL) + .setTimestamp(Instant.now()) + .emit() + + logger.debug("OtelCrashReporter: Flushing crash report to disk...") + openTelemetry.forceFlush() + + // Note: forceFlush() returns CompletableResultCode which is async + // We wait for it in the implementation, so if we get here, it succeeded + logger.info("OtelCrashReporter: ✅ Crash report saved and flushed successfully to disk") + } catch (e: RuntimeException) { + // If we fail to log the crash, at least try to log the failure + logger.error("OtelCrashReporter: Failed to save crash report: ${e.message} - ${e.javaClass.simpleName}") + throw e // Re-throw so caller knows it failed + } catch (e: java.io.IOException) { + // Handle IO errors specifically + logger.error("OtelCrashReporter: IO error saving crash report: ${e.message}") + throw e + } catch (e: IllegalStateException) { + // Handle illegal state errors + logger.error("OtelCrashReporter: Illegal state error saving crash report: ${e.message}") + throw e + } + } +} diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt new file mode 100644 index 0000000000..d9091b8a32 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt @@ -0,0 +1,91 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.IOtelPlatformProvider +import com.onesignal.otel.config.OtelConfigCrashFile +import io.opentelemetry.sdk.logs.data.LogRecordData +import kotlinx.coroutines.delay +import java.util.concurrent.TimeUnit + +/** + * Purpose: This reads a local crash report files created by OneSignal's + * crash handler and sends them to OneSignal on the app's next start. + * + * This is fully platform-agnostic and can be used in KMP projects. + * All platform-specific values are injected through IOtelPlatformProvider. + * + * Dependencies (all platform-agnostic): + * - IOtelOpenTelemetryRemote: For network export (created via OtelFactory) + * - IOtelPlatformProvider: Injects all platform values (Android/iOS) + * - IOtelLogger: Platform logging interface (Android/iOS) + * + * Usage: + * ```kotlin + * val uploader = OtelFactory.createCrashUploader(platformProvider, logger) + * coroutineScope.launch { + * uploader.start() + * } + * ``` + */ +class OtelCrashUploader( + private val openTelemetryRemote: IOtelOpenTelemetryRemote, + private val platformProvider: IOtelPlatformProvider, + private val logger: IOtelLogger, +) { + companion object { + const val SEND_TIMEOUT_SECONDS = 30L + } + + private fun getReports() = + OtelConfigCrashFile.SdkLoggerProviderConfig + .getFileLogRecordStorage( + platformProvider.crashStoragePath, + platformProvider.minFileAgeForReadMillis + ).iterator() + + /** + * Starts the crash uploader process. + * This will periodically check for crash reports on disk and upload them to OneSignal. + * If remote logging is disabled (NONE level), this function returns immediately without doing anything. + */ + suspend fun start() { + val remoteLogLevel = platformProvider.remoteLogLevel + if (remoteLogLevel == null || remoteLogLevel == "NONE") { + logger.info("OtelCrashUploader: remote logging disabled (level: $remoteLogLevel)") + return + } + + logger.info("OtelCrashUploader: starting") + internalStart() + } + + /** + * NOTE: sendCrashReports is called twice for the these reasons: + * 1. We want to send crash reports as soon as possible. + * - App may crash quickly after starting a 2nd time. + * 2. Reports could be delayed until the 2nd start after a crash + * - Otel doesn't let you read a file it could be writing so we must + * wait a minium amount of time after a crash to ensure we get the + * report from the last crash. + */ + suspend fun internalStart() { + sendCrashReports(getReports()) + delay(platformProvider.minFileAgeForReadMillis) + sendCrashReports(getReports()) + } + + private fun sendCrashReports(reports: Iterator>) { + val networkExporter = openTelemetryRemote.logExporter + var failed = false + // NOTE: next() will delete the previous report, so we only want to send + // another one if there isn't an issue making network calls. + while (reports.hasNext() && !failed) { + val future = networkExporter.export(reports.next()) + logger.debug("Sending OneSignal crash report") + val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + failed = !result.isSuccess + logger.debug("Done OneSignal crash report, failed: $failed") + } + } +} diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt new file mode 100644 index 0000000000..5bc57abf30 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt @@ -0,0 +1,181 @@ +package com.onesignal.otel + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.LogRecordBuilder +import kotlinx.coroutines.runBlocking + +class OneSignalOpenTelemetryTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + + fun setupDefaultMocks() { + coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id" + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "5.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0.0" + every { mockPlatformProvider.deviceManufacturer } returns "TestManufacturer" + every { mockPlatformProvider.deviceModel } returns "TestModel" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "13" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns null + every { mockPlatformProvider.sdkWrapperVersion } returns null + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.appIdForHeaders } returns "test-app-id" + every { mockPlatformProvider.onesignalId } returns "test-onesignal-id" + every { mockPlatformProvider.pushSubscriptionId } returns "test-subscription-id" + every { mockPlatformProvider.appState } returns "foreground" + every { mockPlatformProvider.processUptime } returns 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 { + clearMocks(mockPlatformProvider) + setupDefaultMocks() + } + + // ===== Remote Telemetry Tests ===== + + test("createRemoteTelemetry should return IOtelOpenTelemetryRemote") { + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + remoteTelemetry.shouldBeInstanceOf() + } + + test("remote telemetry should have logExporter") { + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + remoteTelemetry.logExporter shouldNotBe null + } + + test("remote telemetry getLogger should return LogRecordBuilder") { + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + runBlocking { + val logger = remoteTelemetry.getLogger() + logger.shouldBeInstanceOf() + } + } + + test("remote telemetry forceFlush should not throw") { + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + runBlocking { + // Should not throw + remoteTelemetry.forceFlush() + } + } + + // ===== Crash Local Telemetry Tests ===== + + test("createCrashLocalTelemetry should return IOtelOpenTelemetryCrash") { + // Use temp directory for crash storage + val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis() + java.io.File(tempDir).mkdirs() + every { mockPlatformProvider.crashStoragePath } returns tempDir + + try { + val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + crashTelemetry.shouldBeInstanceOf() + } finally { + java.io.File(tempDir).deleteRecursively() + } + } + + test("crash telemetry getLogger should return LogRecordBuilder") { + val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis() + java.io.File(tempDir).mkdirs() + every { mockPlatformProvider.crashStoragePath } returns tempDir + + try { + val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + runBlocking { + val logger = crashTelemetry.getLogger() + logger.shouldBeInstanceOf() + } + } finally { + java.io.File(tempDir).deleteRecursively() + } + } + + // ===== LogRecordBuilder Extension Tests ===== + + test("setAllAttributes with Map should set all string attributes") { + val mockBuilder = mockk(relaxed = true) + val attributes = mapOf( + "key1" to "value1", + "key2" to "value2" + ) + + mockBuilder.setAllAttributes(attributes) + + io.mockk.verify { mockBuilder.setAttribute("key1", "value1") } + io.mockk.verify { mockBuilder.setAttribute("key2", "value2") } + } + + test("setAllAttributes with Attributes should handle different types") { + val mockBuilder = mockk(relaxed = true) + val attributes = Attributes.builder() + .put("string.key", "string-value") + .put("long.key", 123L) + .put("double.key", 45.67) + .put("boolean.key", true) + .build() + + mockBuilder.setAllAttributes(attributes) + + io.mockk.verify { mockBuilder.setAttribute("string.key", "string-value") } + io.mockk.verify { mockBuilder.setAttribute("long.key", 123L) } + io.mockk.verify { mockBuilder.setAttribute("double.key", 45.67) } + io.mockk.verify { mockBuilder.setAttribute("boolean.key", true) } + } + + // ===== SDK Caching Tests ===== + + test("remote telemetry should cache SDK instance") { + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + runBlocking { + val logger1 = remoteTelemetry.getLogger() + val logger2 = remoteTelemetry.getLogger() + + // Both calls should succeed (SDK is cached internally) + logger1 shouldNotBe null + logger2 shouldNotBe null + } + } + + // ===== Integration with Factory Tests ===== + + test("factory should create independent instances") { + val remote1 = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + val remote2 = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + remote1 shouldNotBe remote2 + } + + test("factory should work with null optional fields") { + every { mockPlatformProvider.appId } returns null + every { mockPlatformProvider.onesignalId } returns null + every { mockPlatformProvider.pushSubscriptionId } returns null + every { mockPlatformProvider.sdkWrapper } returns null + every { mockPlatformProvider.sdkWrapperVersion } returns null + + // Should not throw + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + remoteTelemetry shouldNotBe null + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt new file mode 100644 index 0000000000..56f2ce5cc4 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt @@ -0,0 +1,204 @@ +package com.onesignal.otel + +import com.onesignal.otel.crash.OtelCrashUploader +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk + +class OtelFactoryTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + + beforeEach { + // Setup default values + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0" + every { mockPlatformProvider.deviceManufacturer } returns "Test" + every { mockPlatformProvider.deviceModel } returns "TestDevice" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "10" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns null + every { mockPlatformProvider.sdkWrapperVersion } returns null + every { mockPlatformProvider.appId } returns null + every { mockPlatformProvider.onesignalId } returns null + every { mockPlatformProvider.pushSubscriptionId } returns null + every { mockPlatformProvider.appState } returns "foreground" + every { mockPlatformProvider.processUptime } returns 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" + } + + // ===== createCrashHandler Tests ===== + + test("createCrashHandler should return IOtelCrashHandler") { + // When + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + // Then + handler.shouldBeInstanceOf() + } + + test("createCrashHandler should create handler with correct dependencies") { + // When + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + // Then + handler shouldNotBe null + // Handler should be initializable + handler.initialize() + } + + test("createCrashHandler should create handler that can be initialized multiple times") { + // Given + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + // When + handler.initialize() + handler.initialize() // Should not throw + + // Then - no exception thrown + } + + // ===== createCrashUploader Tests ===== + + test("createCrashUploader should return OtelCrashUploader") { + // When + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + + // Then + uploader shouldNotBe null + uploader.shouldBeInstanceOf() + } + + test("createCrashUploader should create uploader with correct dependencies") { + // When + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + + // Then + uploader shouldNotBe null + } + + // ===== createRemoteTelemetry Tests ===== + + test("createRemoteTelemetry should return IOtelOpenTelemetryRemote") { + // When + val telemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + // Then + telemetry shouldNotBe null + telemetry.shouldBeInstanceOf() + } + + test("createRemoteTelemetry should have logExporter") { + // When + val telemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + // Then + telemetry.logExporter shouldNotBe null + } + + // ===== createCrashLocalTelemetry Tests ===== + + test("createCrashLocalTelemetry should return IOtelOpenTelemetryCrash") { + // When + val telemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + // Then + telemetry shouldNotBe null + telemetry.shouldBeInstanceOf() + } + + test("createCrashLocalTelemetry should be different instance from remote") { + // When + val localTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + + // Then + localTelemetry shouldNotBe remoteTelemetry + } + + // ===== createCrashReporter Tests ===== + + test("createCrashReporter should return IOtelCrashReporter") { + // Given + val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + // When + val reporter = OtelFactory.createCrashReporter(crashTelemetry, mockLogger) + + // Then + reporter shouldNotBe null + reporter.shouldBeInstanceOf() + } + + test("createCrashReporter should work with different telemetry instances") { + // Given + val crashTelemetry1 = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + val crashTelemetry2 = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + + // When + val reporter1 = OtelFactory.createCrashReporter(crashTelemetry1, mockLogger) + val reporter2 = OtelFactory.createCrashReporter(crashTelemetry2, mockLogger) + + // Then + reporter1 shouldNotBe null + reporter2 shouldNotBe null + reporter1 shouldNotBe reporter2 + } + + // ===== Integration Tests ===== + + test("createCrashHandler uses platform provider values correctly") { + // Given + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.onesignalId } returns "test-onesignal-id" + + // When + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + + // Then + handler shouldNotBe null + handler.initialize() // Should work with provided values + } + + test("createCrashUploader uses platform provider values correctly") { + // Given + every { mockPlatformProvider.appId } returns "test-app-id" + every { mockPlatformProvider.crashStoragePath } returns "/custom/path" + + // When + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + + // Then + uploader shouldNotBe null + } + + test("all factory methods work with null appId") { + // Given + every { mockPlatformProvider.appId } returns null + + // When & Then - should not throw + val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger) + handler shouldNotBe null + + val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger) + uploader shouldNotBe null + + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) + remoteTelemetry shouldNotBe null + + val localTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider) + localTelemetry shouldNotBe null + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt new file mode 100644 index 0000000000..16b195754e --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt @@ -0,0 +1,145 @@ +package com.onesignal.otel + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.api.logs.Severity +import kotlinx.coroutines.runBlocking + +class OtelLoggingHelperTest : FunSpec({ + val mockTelemetry = mockk(relaxed = true) + val mockLogRecordBuilder = mockk(relaxed = true) + + beforeEach { + coEvery { mockTelemetry.getLogger() } returns mockLogRecordBuilder + } + + test("logToOtel should set correct severity for VERBOSE level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "VERBOSE", "test message") + } + + severitySlot.captured shouldBe Severity.TRACE + } + + test("logToOtel should set correct severity for DEBUG level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "DEBUG", "test message") + } + + severitySlot.captured shouldBe Severity.DEBUG + } + + test("logToOtel should set correct severity for INFO level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "INFO", "test message") + } + + severitySlot.captured shouldBe Severity.INFO + } + + test("logToOtel should set correct severity for WARN level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "WARN", "test message") + } + + severitySlot.captured shouldBe Severity.WARN + } + + test("logToOtel should set correct severity for ERROR level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "ERROR", "test message") + } + + severitySlot.captured shouldBe Severity.ERROR + } + + test("logToOtel should set correct severity for FATAL level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "FATAL", "test message") + } + + severitySlot.captured shouldBe Severity.FATAL + } + + test("logToOtel should default to INFO for unknown level") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "UNKNOWN", "test message") + } + + severitySlot.captured shouldBe Severity.INFO + } + + test("logToOtel should set body with message") { + val bodySlot = slot() + every { mockLogRecordBuilder.setBody(capture(bodySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "INFO", "my test message") + } + + bodySlot.captured shouldBe "my test message" + } + + test("logToOtel should emit the log record") { + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "INFO", "test message") + } + + verify { mockLogRecordBuilder.emit() } + } + + test("logToOtel should include exception attributes when provided") { + runBlocking { + OtelLoggingHelper.logToOtel( + telemetry = mockTelemetry, + level = "ERROR", + message = "error occurred", + exceptionType = "java.lang.RuntimeException", + exceptionMessage = "something went wrong", + exceptionStacktrace = "at com.test.Class.method(Class.kt:10)" + ) + } + + coVerify { mockTelemetry.getLogger() } + verify { mockLogRecordBuilder.emit() } + } + + test("logToOtel should handle case-insensitive log levels") { + val severitySlot = slot() + every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder + + runBlocking { + OtelLoggingHelper.logToOtel(mockTelemetry, "error", "test message") + } + + severitySlot.captured shouldBe Severity.ERROR + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt new file mode 100644 index 0000000000..6fec492830 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt @@ -0,0 +1,69 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class OtelFieldsPerEventTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + val fields = OtelFieldsPerEvent(mockPlatformProvider) + + fun setupDefaultMocks( + appId: String? = "test-app-id", + onesignalId: String? = "test-onesignal-id", + pushSubscriptionId: String? = "test-subscription-id", + appState: String = "foreground", + processUptime: Long = 100, + threadName: String = "main-thread" + ) { + every { mockPlatformProvider.appId } returns appId + every { mockPlatformProvider.onesignalId } returns onesignalId + every { mockPlatformProvider.pushSubscriptionId } returns pushSubscriptionId + every { mockPlatformProvider.appState } returns appState + every { mockPlatformProvider.processUptime } returns processUptime + every { mockPlatformProvider.currentThreadName } returns threadName + } + + beforeEach { clearMocks(mockPlatformProvider) } + + test("getAttributes should include all per-event fields when all values present") { + setupDefaultMocks() + + val attributes = fields.getAttributes() + + attributes.keys shouldContain "log.record.uid" + attributes["log.record.uid"] shouldNotBe null + attributes["ossdk.app_id"] shouldBe "test-app-id" + attributes["ossdk.onesignal_id"] shouldBe "test-onesignal-id" + attributes["ossdk.push_subscription_id"] shouldBe "test-subscription-id" + attributes["app.state"] shouldBe "foreground" + attributes["process.uptime"] shouldBe "100" + attributes["thread.name"] shouldBe "main-thread" + } + + test("getAttributes should exclude null optional fields") { + setupDefaultMocks(appId = null, onesignalId = null, pushSubscriptionId = null, appState = "background") + + val attributes = fields.getAttributes() + + attributes.keys shouldNotContain "ossdk.app_id" + attributes.keys shouldNotContain "ossdk.onesignal_id" + attributes.keys shouldNotContain "ossdk.push_subscription_id" + attributes["app.state"] shouldBe "background" + } + + test("getAttributes should generate unique record IDs on each call") { + setupDefaultMocks() + + val uid1 = fields.getAttributes()["log.record.uid"] + val uid2 = fields.getAttributes()["log.record.uid"] + + uid1 shouldNotBe uid2 + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt new file mode 100644 index 0000000000..6f6463475b --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt @@ -0,0 +1,78 @@ +package com.onesignal.otel.attributes + +import com.onesignal.otel.IOtelPlatformProvider +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking + +class OtelFieldsTopLevelTest : FunSpec({ + val mockPlatformProvider = mockk(relaxed = true) + val fields = OtelFieldsTopLevel(mockPlatformProvider) + + fun setupDefaultMocks( + installId: String = "test-install-id", + sdkWrapper: String? = null, + sdkWrapperVersion: String? = null + ) { + coEvery { mockPlatformProvider.getInstallId() } returns installId + every { mockPlatformProvider.sdkBase } returns "android" + every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0" + every { mockPlatformProvider.appPackageId } returns "com.test.app" + every { mockPlatformProvider.appVersion } returns "1.0" + every { mockPlatformProvider.deviceManufacturer } returns "TestManufacturer" + every { mockPlatformProvider.deviceModel } returns "TestModel" + every { mockPlatformProvider.osName } returns "Android" + every { mockPlatformProvider.osVersion } returns "10" + every { mockPlatformProvider.osBuildId } returns "TEST123" + every { mockPlatformProvider.sdkWrapper } returns sdkWrapper + every { mockPlatformProvider.sdkWrapperVersion } returns sdkWrapperVersion + } + + beforeEach { clearMocks(mockPlatformProvider) } + + test("getAttributes should include all required top-level fields") { + setupDefaultMocks() + + runBlocking { + val attributes = fields.getAttributes() + + attributes["ossdk.install_id"] shouldBe "test-install-id" + attributes["ossdk.sdk_base"] shouldBe "android" + attributes["ossdk.sdk_base_version"] shouldBe "1.0.0" + attributes["ossdk.app_package_id"] shouldBe "com.test.app" + attributes["ossdk.app_version"] shouldBe "1.0" + attributes["device.manufacturer"] shouldBe "TestManufacturer" + attributes["device.model.identifier"] shouldBe "TestModel" + attributes["os.name"] shouldBe "Android" + attributes["os.version"] shouldBe "10" + attributes["os.build_id"] shouldBe "TEST123" + } + } + + test("getAttributes should include wrapper fields when present") { + setupDefaultMocks(sdkWrapper = "unity", sdkWrapperVersion = "2.0.0") + + runBlocking { + val attributes = fields.getAttributes() + + attributes["ossdk.sdk_wrapper"] shouldBe "unity" + attributes["ossdk.sdk_wrapper_version"] shouldBe "2.0.0" + } + } + + test("getAttributes should exclude null wrapper fields") { + setupDefaultMocks(sdkWrapper = null, sdkWrapperVersion = null) + + runBlocking { + val attributes = fields.getAttributes() + + attributes.keys shouldNotContain "ossdk.sdk_wrapper" + attributes.keys shouldNotContain "ossdk.sdk_wrapper_version" + } + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt new file mode 100644 index 0000000000..f4f8daaf18 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt @@ -0,0 +1,136 @@ +package com.onesignal.otel.config + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.opentelemetry.semconv.ServiceAttributes + +class OtelConfigTest : FunSpec({ + + // ===== OtelConfigShared.ResourceConfig Tests ===== + + test("ResourceConfig should create resource with service name") { + val resource = OtelConfigShared.ResourceConfig.create(emptyMap()) + + resource.attributes.get(ServiceAttributes.SERVICE_NAME) shouldBe "OneSignalDeviceSDK" + } + + test("ResourceConfig should include custom attributes") { + val customAttributes = mapOf( + "custom.key1" to "value1", + "custom.key2" to "value2" + ) + + val resource = OtelConfigShared.ResourceConfig.create(customAttributes) + + resource.attributes.get(ServiceAttributes.SERVICE_NAME) shouldBe "OneSignalDeviceSDK" + resource.attributes.asMap().entries.any { it.key.key == "custom.key1" } shouldBe true + resource.attributes.asMap().entries.any { it.key.key == "custom.key2" } shouldBe true + } + + test("ResourceConfig should handle empty attributes map") { + val resource = OtelConfigShared.ResourceConfig.create(emptyMap()) + + resource shouldNotBe null + resource.attributes.get(ServiceAttributes.SERVICE_NAME) shouldBe "OneSignalDeviceSDK" + } + + // ===== OtelConfigShared.LogLimitsConfig Tests ===== + + test("LogLimitsConfig should create valid log limits") { + val logLimits = OtelConfigShared.LogLimitsConfig.logLimits() + + logLimits shouldNotBe null + logLimits.maxNumberOfAttributes shouldBe 128 + logLimits.maxAttributeValueLength shouldBe 32000 + } + + // ===== OtelConfigShared.LogRecordProcessorConfig Tests ===== + + test("LogRecordProcessorConfig should create batch processor") { + val mockExporter = io.mockk.mockk(relaxed = true) + + val processor = OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor(mockExporter) + + processor shouldNotBe null + } + + // ===== OtelConfigRemoteOneSignal Tests ===== + + 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, apiBaseUrl) + + exporter shouldNotBe null + } + + test("LogRecordExporterConfig should create OTLP HTTP exporter") { + val headers = mapOf("Authorization" to "Bearer token") + val endpoint = "https://example.com/v1/logs" + + val exporter = OtelConfigRemoteOneSignal.LogRecordExporterConfig.otlpHttpLogRecordExporter( + headers, + endpoint + ) + + exporter shouldNotBe null + } + + test("SdkLoggerProviderConfig should create logger provider") { + val resource = OtelConfigShared.ResourceConfig.create(emptyMap()) + val headers = mapOf("X-OneSignal-App-Id" to "test-app-id") + + val provider = OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( + resource, + headers, + "test-app-id", + "https://api.onesignal.com", + ) + + provider shouldNotBe null + } + + // ===== OtelConfigCrashFile Tests ===== + + test("OtelConfigCrashFile should create file log storage") { + val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis() + java.io.File(tempDir).mkdirs() + + try { + val storage = OtelConfigCrashFile.SdkLoggerProviderConfig.getFileLogRecordStorage( + tempDir, + 5000L + ) + + storage shouldNotBe null + } finally { + java.io.File(tempDir).deleteRecursively() + } + } + + test("OtelConfigCrashFile should create logger provider") { + val resource = OtelConfigShared.ResourceConfig.create(emptyMap()) + val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis() + java.io.File(tempDir).mkdirs() + + try { + val provider = OtelConfigCrashFile.SdkLoggerProviderConfig.create( + resource, + tempDir, + 5000L + ) + + provider shouldNotBe null + } finally { + java.io.File(tempDir).deleteRecursively() + } + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt new file mode 100644 index 0000000000..2572c2f162 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt @@ -0,0 +1,169 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify + +class OtelCrashHandlerTest : FunSpec({ + val mockCrashReporter = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + + fun createFreshHandler() = OtelCrashHandler(mockCrashReporter, mockLogger) + + beforeEach { + clearMocks(mockCrashReporter, mockLogger) + } + + test("initialize should set up uncaught exception handler") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val crashHandler = createFreshHandler() + + crashHandler.initialize() + + Thread.getDefaultUncaughtExceptionHandler() shouldBe crashHandler + verify { mockLogger.info(match { it.contains("Setting up uncaught exception handler") }) } + verify { mockLogger.info(match { it.contains("Successfully initialized") }) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("initialize should not initialize twice") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val crashHandler = createFreshHandler() + + crashHandler.initialize() + crashHandler.initialize() + + verify(exactly = 1) { mockLogger.warn("OtelCrashHandler already initialized, skipping") } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should not process non-OneSignal crashes") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val mockHandler = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(mockHandler) + val crashHandler = createFreshHandler() + crashHandler.initialize() + + val throwable = RuntimeException("Non-OneSignal crash") + val thread = Thread.currentThread() + + crashHandler.uncaughtException(thread, throwable) + + coVerify(exactly = 0) { mockCrashReporter.saveCrash(any(), any()) } + verify { mockHandler.uncaughtException(thread, throwable) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should process OneSignal crashes") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val mockHandler = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(mockHandler) + val crashHandler = createFreshHandler() + crashHandler.initialize() + + val throwable = RuntimeException("OneSignal crash").apply { + stackTrace = arrayOf( + StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10) + ) + } + val thread = Thread.currentThread() + + coEvery { mockCrashReporter.saveCrash(any(), any()) } returns Unit + + crashHandler.uncaughtException(thread, throwable) + + coVerify(exactly = 1) { mockCrashReporter.saveCrash(thread, throwable) } + verify { mockHandler.uncaughtException(thread, throwable) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should not process same throwable twice") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val crashHandler = createFreshHandler() + crashHandler.initialize() + + val throwable = RuntimeException("OneSignal crash").apply { + stackTrace = arrayOf( + StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10) + ) + } + val thread = Thread.currentThread() + + coEvery { mockCrashReporter.saveCrash(any(), any()) } returns Unit + + crashHandler.uncaughtException(thread, throwable) + crashHandler.uncaughtException(thread, throwable) + + coVerify(exactly = 1) { mockCrashReporter.saveCrash(any(), any()) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + test("uncaughtException should handle crash reporter failures gracefully") { + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + val mockHandler = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(mockHandler) + val crashHandler = createFreshHandler() + crashHandler.initialize() + + val throwable = RuntimeException("OneSignal crash").apply { + stackTrace = arrayOf( + StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10) + ) + } + val thread = Thread.currentThread() + + coEvery { mockCrashReporter.saveCrash(any(), any()) } throws RuntimeException("Reporter failed") + + crashHandler.uncaughtException(thread, throwable) + + verify { mockLogger.error(match { it.contains("Failed to save crash report") }) } + verify { mockHandler.uncaughtException(thread, throwable) } + + Thread.setDefaultUncaughtExceptionHandler(originalHandler) + } + + // ===== isOneSignalAtFault Tests ===== + + test("isOneSignalAtFault should return true for OneSignal stack traces") { + val stackTrace = arrayOf( + StackTraceElement("com.onesignal.core.SomeClass", "method", "File.kt", 10) + ) + + isOneSignalAtFault(stackTrace) shouldBe true + } + + test("isOneSignalAtFault should return false for non-OneSignal stack traces") { + val stackTrace = arrayOf( + StackTraceElement("com.example.app.SomeClass", "method", "File.kt", 10) + ) + + isOneSignalAtFault(stackTrace) shouldBe false + } + + test("isOneSignalAtFault should return false for empty stack traces") { + val stackTrace = emptyArray() + + isOneSignalAtFault(stackTrace) shouldBe false + } + + test("isOneSignalAtFault with throwable should check throwable stack trace") { + val throwable = RuntimeException("test").apply { + stackTrace = arrayOf( + StackTraceElement("com.onesignal.SomeClass", "method", "File.kt", 10) + ) + } + + isOneSignalAtFault(throwable) shouldBe true + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt new file mode 100644 index 0000000000..3b9e27c77c --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt @@ -0,0 +1,146 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelCrashReporter +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryCrash +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.api.logs.Severity +import io.opentelemetry.sdk.common.CompletableResultCode +import kotlinx.coroutines.runBlocking + +class OtelCrashReporterTest : FunSpec({ + val mockOpenTelemetry = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + val mockLogRecordBuilder = mockk(relaxed = true) + val mockCompletableResult = mockk(relaxed = true) + + fun setupDefaultMocks() { + coEvery { mockOpenTelemetry.getLogger() } returns mockLogRecordBuilder + coEvery { mockOpenTelemetry.forceFlush() } returns mockCompletableResult + every { mockLogRecordBuilder.setSeverity(any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.setTimestamp(any()) } returns mockLogRecordBuilder + every { mockLogRecordBuilder.emit() } returns Unit + } + + beforeEach { + clearMocks(mockOpenTelemetry, mockLogger, mockLogRecordBuilder, mockCompletableResult) + setupDefaultMocks() + } + + test("should implement IOtelCrashReporter interface") { + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + + crashReporter.shouldBeInstanceOf() + } + + test("saveCrash should get logger and emit log record") { + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + coVerify(exactly = 1) { mockOpenTelemetry.getLogger() } + coVerify(exactly = 1) { mockOpenTelemetry.forceFlush() } + verify { mockLogRecordBuilder.setSeverity(Severity.FATAL) } + verify { mockLogRecordBuilder.emit() } + } + + test("saveCrash should log info messages") { + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + verify { mockLogger.info(match { it.contains("Starting to save crash report") }) } + verify { mockLogger.info(match { it.contains("Crash report saved and flushed successfully") }) } + } + + test("saveCrash should handle null exception message") { + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException() // No message + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + coVerify { mockOpenTelemetry.getLogger() } + verify { mockLogRecordBuilder.emit() } + } + + test("saveCrash should re-throw RuntimeException on failure") { + coEvery { mockOpenTelemetry.getLogger() } throws RuntimeException("OpenTelemetry failed") + + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + shouldThrow { + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + } + + verify { mockLogger.error(match { it.contains("Failed to save crash report") }) } + } + + test("saveCrash should re-throw IOException on IO failure") { + coEvery { mockOpenTelemetry.getLogger() } throws java.io.IOException("IO failed") + + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + shouldThrow { + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + } + + verify { mockLogger.error(match { it.contains("IO error saving crash report") }) } + } + + test("saveCrash should re-throw IllegalStateException") { + coEvery { mockOpenTelemetry.getLogger() } throws IllegalStateException("Illegal state") + + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + // Note: IllegalStateException extends RuntimeException, so it gets caught by the RuntimeException handler + shouldThrow { + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + } + + verify { mockLogger.error(match { it.contains("Failed to save crash report") }) } + } + + test("saveCrash should set timestamp") { + val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger) + val throwable = RuntimeException("Test crash") + val thread = Thread.currentThread() + + runBlocking { + crashReporter.saveCrash(thread, throwable) + } + + verify { mockLogRecordBuilder.setTimestamp(any()) } + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt new file mode 100644 index 0000000000..4f46ef30d9 --- /dev/null +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt @@ -0,0 +1,89 @@ +package com.onesignal.otel.crash + +import com.onesignal.otel.IOtelLogger +import com.onesignal.otel.IOtelOpenTelemetryRemote +import com.onesignal.otel.IOtelPlatformProvider +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import kotlinx.coroutines.runBlocking +import java.io.File + +class OtelCrashUploaderTest : FunSpec({ + val mockRemoteTelemetry = mockk(relaxed = true) + val mockPlatformProvider = mockk(relaxed = true) + val mockLogger = mockk(relaxed = true) + val mockExporter = mockk(relaxed = true) + + // Use temp directory for tests that need file system access + fun createTempDir(): String { + val tempDir = File(System.getProperty("java.io.tmpdir"), "otel-test-${System.currentTimeMillis()}") + tempDir.mkdirs() + return tempDir.absolutePath + } + + fun setupDefaultMocks( + remoteLogLevel: String? = "ERROR", + crashStoragePath: String? = null, + minFileAgeForReadMillis: Long = 0L // Use 0 to avoid delays in tests + ) { + val path = crashStoragePath ?: createTempDir() + every { mockPlatformProvider.remoteLogLevel } returns remoteLogLevel + every { mockPlatformProvider.crashStoragePath } returns path + every { mockPlatformProvider.minFileAgeForReadMillis } returns minFileAgeForReadMillis + every { mockRemoteTelemetry.logExporter } returns mockExporter + every { mockExporter.export(any()) } returns CompletableResultCode.ofSuccess() + } + + beforeEach { + clearMocks(mockRemoteTelemetry, mockPlatformProvider, mockLogger, mockExporter) + } + + test("should create uploader with dependencies") { + setupDefaultMocks() + + val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger) + + uploader shouldNotBe null + } + + test("start should return immediately when remote logging is disabled (null)") { + setupDefaultMocks(remoteLogLevel = null) + + val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger) + + runBlocking { uploader.start() } + + verify { mockLogger.info("OtelCrashUploader: remote logging disabled (level: null)") } + } + + test("start should return immediately when remote logging is NONE") { + setupDefaultMocks(remoteLogLevel = "NONE") + + val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger) + + runBlocking { uploader.start() } + + verify { mockLogger.info("OtelCrashUploader: remote logging disabled (level: NONE)") } + } + + test("start should proceed when remote logging is enabled") { + setupDefaultMocks(remoteLogLevel = "ERROR") + + val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger) + + runBlocking { uploader.start() } + + verify { mockLogger.info("OtelCrashUploader: starting") } + } + + test("SEND_TIMEOUT_SECONDS should be 30 seconds") { + OtelCrashUploader.SEND_TIMEOUT_SECONDS shouldBe 30L + } +}) diff --git a/OneSignalSDK/settings.gradle b/OneSignalSDK/settings.gradle index 76fb5755e6..3cdfa40b21 100644 --- a/OneSignalSDK/settings.gradle +++ b/OneSignalSDK/settings.gradle @@ -30,3 +30,4 @@ include ':OneSignal:in-app-messages' include ':OneSignal:location' include ':OneSignal:notifications' include ':OneSignal:testhelpers' +include ':OneSignal:otel' diff --git a/examples/demo/app/build.gradle.kts b/examples/demo/app/build.gradle.kts index 8aea101410..9dbda6eca6 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 2cfe743f3b..7df8fec384 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 6c3ef7f6cf..0655dc843c 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 0244a29bc4..b21f952b6f 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") } From 02316a065ede064745cf4976eecf12113048a5f8 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 4 Mar 2026 11:49:50 -0500 Subject: [PATCH 2/2] fix: replace OneSignalDispatchers with suspendifyOnThread for 5.6-main compatibility OneSignalDispatchers does not exist in 5.6-main. Use the equivalent suspendifyOnThread utility that is available in this branch. Made-with: Cursor --- .../debug/internal/crash/OneSignalCrashUploaderWrapper.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1d197ce797..2f9f7c9c3a 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,6 +1,6 @@ package com.onesignal.debug.internal.crash -import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger @@ -46,7 +46,7 @@ internal class OneSignalCrashUploaderWrapper( @Suppress("TooGenericExceptionCaught") override fun start() { if (!OtelSdkSupport.isSupported) return - OneSignalDispatchers.launchOnIO { + suspendifyOnThread { try { uploader.start() } catch (t: Throwable) {