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..2f9f7c9c3a
--- /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.suspendifyOnThread
+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
+ suspendifyOnThread {
+ 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")
}