diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml
index 3f07ef1a13..d80e7a8aac 100644
--- a/OneSignalSDK/detekt/detekt-baseline-core.xml
+++ b/OneSignalSDK/detekt/detekt-baseline-core.xml
@@ -36,21 +36,28 @@
ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _buildUserService: IRebuildUserService
ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityBackend: IIdentityBackendService
ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityModelStore: IdentityModelStore
+ ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityVerificationService: IdentityVerificationService
+ ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _jwtTokenStore: JwtTokenStore
ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _newRecordState: NewRecordsState
ConstructorParameterNaming:InfluenceDataRepository.kt$InfluenceDataRepository$private val _configModelStore: ConfigModelStore
ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _applicationService: IApplicationService
ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _configModelStore: ConfigModelStore
ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _sessionService: ISessionService
ConstructorParameterNaming:InstallIdService.kt$InstallIdService$private val _prefs: IPreferencesService
+ ConstructorParameterNaming:JwtTokenStore.kt$JwtTokenStore$private val _prefs: IPreferencesService
ConstructorParameterNaming:LanguageContext.kt$LanguageContext$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _identityModelStore: IdentityModelStore
+ ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _identityVerificationService: IdentityVerificationService
ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _application: IApplicationService
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _configModelStore: ConfigModelStore
+ ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _consistencyManager: IConsistencyManager
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _deviceService: IDeviceService
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityModelStore: IdentityModelStore
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityOperationExecutor: IdentityOperationExecutor
+ ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityVerificationService: IdentityVerificationService
+ ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _jwtTokenStore: JwtTokenStore
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _languageContext: ILanguageContext
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _subscriptionsModelStore: SubscriptionModelStore
@@ -62,6 +69,8 @@
ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _time: ITime
ConstructorParameterNaming:OSDatabase.kt$OSDatabase$private val _outcomeTableProvider: OutcomeTableProvider
ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _configModelStore: ConfigModelStore
+ ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _identityVerificationService: IdentityVerificationService
+ ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _jwtTokenStore: JwtTokenStore
ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _newRecordState: NewRecordsState
ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _operationModelStore: OperationModelStore
ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _time: ITime
@@ -81,6 +90,7 @@
ConstructorParameterNaming:PreferencesService.kt$PreferencesService$private val _applicationService: IApplicationService
ConstructorParameterNaming:PreferencesService.kt$PreferencesService$private val _time: ITime
ConstructorParameterNaming:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$private val _configModelStore: ConfigModelStore
+ ConstructorParameterNaming:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$private val _identityModelStore: IdentityModelStore
ConstructorParameterNaming:RebuildUserService.kt$RebuildUserService$private val _configModelStore: ConfigModelStore
ConstructorParameterNaming:RebuildUserService.kt$RebuildUserService$private val _identityModelStore: IdentityModelStore
ConstructorParameterNaming:RebuildUserService.kt$RebuildUserService$private val _propertiesModelStore: PropertiesModelStore
@@ -93,6 +103,8 @@
ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _buildUserService: IRebuildUserService
ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _configModelStore: ConfigModelStore
ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _identityModelStore: IdentityModelStore
+ ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _identityVerificationService: IdentityVerificationService
+ ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _jwtTokenStore: JwtTokenStore
ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _newRecordState: NewRecordsState
ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _subscriptionsModelStore: SubscriptionModelStore
@@ -123,6 +135,8 @@
ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _configModelStore: ConfigModelStore
ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _consistencyManager: IConsistencyManager
ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _deviceService: IDeviceService
+ ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _identityVerificationService: IdentityVerificationService
+ ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _jwtTokenStore: JwtTokenStore
ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _newRecordState: NewRecordsState
ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService
ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _subscriptionModelStore: SubscriptionModelStore
@@ -134,6 +148,8 @@
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _buildUserService: IRebuildUserService
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _consistencyManager: IConsistencyManager
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _identityModelStore: IdentityModelStore
+ ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _identityVerificationService: IdentityVerificationService
+ ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _jwtTokenStore: JwtTokenStore
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _newRecordState: NewRecordsState
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _userBackend: IUserBackendService
@@ -158,9 +174,6 @@
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: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
@@ -174,6 +187,7 @@
InstanceOfCheckForException:HttpClient.kt$HttpClient$t is UnknownHostException
LongMethod:ApplicationService.kt$ApplicationService$override suspend fun waitUntilSystemConditionsAvailable(): Boolean
LongMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams()
+ LongMethod:FeatureFlagsBackendService.kt$FeatureFlagsBackendService$override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome
LongMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse
LongMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse
LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse
@@ -193,15 +207,18 @@
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:CreateSubscriptionOperation.kt$CreateSubscriptionOperation$(appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus)
+ LongParameterList:ICustomEventBackendService.kt$ICustomEventBackendService$( appId: String, onesignalId: String, externalId: String?, timestamp: Long, eventName: String, eventProperties: String?, metadata: CustomEventMetadata, jwt: String? = null, )
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:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, jwt: String? = null, )
+ 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, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, private val _consistencyManager: IConsistencyManager, )
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:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, )
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: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, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, )
+ LongParameterList:UpdateSubscriptionOperation.kt$UpdateSubscriptionOperation$(appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus)
+ LongParameterList:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, )
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
@@ -230,6 +247,7 @@
MagicNumber:OSDatabase.kt$OSDatabase$8
MagicNumber:OSDatabase.kt$OSDatabase$9
MagicNumber:OneSignalDispatchers.kt$OneSignalDispatchers$1024
+ MagicNumber:OneSignalImp.kt$OneSignalImp$8
MagicNumber:OperationRepo.kt$OperationRepo$1_000
MagicNumber:OutcomeEventsController.kt$OutcomeEventsController$1000
MagicNumber:PermissionsActivity.kt$PermissionsActivity$23
@@ -261,6 +279,7 @@
MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$4
MagicNumber:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$99
MagicNumber:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$404
+ MatchingDeclarationName:ExecutorsIvExtensions.kt$IvBackendParams
MemberNameEqualsClassName:OneSignal.kt$OneSignal$private val oneSignal: IOneSignal by lazy { OneSignalImp() }
NestedBlockDepth:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse
NestedBlockDepth:InfluenceManager.kt$InfluenceManager$private fun attemptSessionUpgrade( entryAction: AppEntryAction, directId: String? = null, )
@@ -282,21 +301,28 @@
RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e
ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution
ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean
- ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean
ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model?
+ ReturnCount:ExecutorsIvExtensions.kt$internal fun resolveIvBackendParams( op: Operation, onesignalId: String, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, ): IvBackendParams
+ ReturnCount:ExecutorsIvExtensions.kt$internal fun resolveIvJwt( op: Operation, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, ): String?
+ ReturnCount:FeatureFlagsBackendService.kt$FeatureFlagsBackendService$override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome
ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse
ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse
ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean
ReturnCount:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private suspend fun loginUser(loginUserOp: LoginUserFromSubscriptionOperation): ExecutionResponse
ReturnCount:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse
+ ReturnCount:LogoutHelper.kt$LogoutHelper$internal fun switchUser(): LogoutEnqueueContext?
ReturnCount:Model.kt$Model$protected fun getOptBigDecimalProperty( name: String, create: (() -> BigDecimal?)? = null, ): BigDecimal?
ReturnCount:Model.kt$Model$protected fun getOptDoubleProperty( name: String, create: (() -> Double?)? = null, ): Double?
ReturnCount:Model.kt$Model$protected fun getOptFloatProperty( name: String, create: (() -> Float?)? = null, ): Float?
ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int?
ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long?
ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T?
+ ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( 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:OperationRepo.kt$OperationRepo$private fun shouldSuppressAnonymousOp(op: Operation): Boolean
+ ReturnCount:OperationRepoIvExtensions.kt$internal fun OperationRepo.handleFailUnauthorized( startingOp: OperationRepo.OperationQueueItem, ops: List<OperationRepo.OperationQueueItem>, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, ): Boolean
+ ReturnCount:OperationRepoIvExtensions.kt$internal fun OperationRepo.hasValidJwtIfRequired( jwtTokenStore: JwtTokenStore, op: com.onesignal.core.internal.operations.Operation, ivBehaviorActive: Boolean, ): 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
@@ -321,11 +347,13 @@
SwallowedException:JSONUtils.kt$JSONUtils$t: Throwable
SwallowedException:PermissionsActivity.kt$PermissionsActivity$e: ClassNotFoundException
SwallowedException:PreferencesService.kt$PreferencesService$ex: Exception
+ SwallowedException:PreferencesService.kt$PreferencesService$t: Throwable
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:FeatureFlagsRefreshService.kt$FeatureFlagsRefreshService$e: Exception
TooGenericExceptionCaught:HttpClient.kt$HttpClient$e: Throwable
TooGenericExceptionCaught:HttpClient.kt$HttpClient$t: Throwable
TooGenericExceptionCaught:JSONUtils.kt$JSONUtils$t: Throwable
@@ -335,6 +363,7 @@
TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable
TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable
TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$ex: Exception
+ TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$t: Throwable
TooGenericExceptionCaught:SyncJobService.kt$SyncJobService$e: Exception
TooGenericExceptionCaught:ThreadUtils.kt$e: Exception
TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable
@@ -413,10 +442,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
@@ -434,8 +459,6 @@
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
@@ -549,8 +572,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, )
@@ -605,13 +626,16 @@
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 IllegalStateException("'initWithContext failed' before 'updateUserJwt'")
+ UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'addUserJwtInvalidatedListener'")
UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'login'")
UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'logout'")
+ UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'removeUserJwtInvalidatedListener'")
+ UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'updateUserJwt'")
UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use")
UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.")
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt
index e20ddfc2ac..6b38326c82 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt
@@ -131,6 +131,34 @@ interface IOneSignal {
*/
fun logout()
+ /**
+ * Update the JWT bearer token associated with [externalId]. Use this when your backend
+ * has issued a new JWT for an already-logged-in user (e.g. in response to a previous
+ * [IUserJwtInvalidatedListener.onUserJwtInvalidated] callback). Stores the JWT and
+ * wakes the operation queue so any deferred ops can dispatch with the fresh token.
+ *
+ * @param externalId The external ID the JWT belongs to.
+ * @param token The new JWT bearer token issued by your backend.
+ */
+ fun updateUserJwt(
+ externalId: String,
+ token: String,
+ )
+
+ /**
+ * Subscribe a listener for JWT-invalidated events. Fires on a background thread when
+ * the SDK detects that the stored JWT for a user is no longer valid (typically after
+ * a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
+ * their backend and supplying it via [updateUserJwt].
+ *
+ * Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
+ * event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
+ */
+ fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)
+
+ /** Unsubscribe a listener previously registered via [addUserJwtInvalidatedListener]. */
+ fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)
+
// Suspend versions of property accessors and methods to avoid blocking threads
/**
@@ -226,4 +254,16 @@ interface IOneSignal {
* Logout the current user (suspend version).
*/
suspend fun logoutSuspend()
+
+ /**
+ * Update the JWT bearer token associated with [externalId] (suspend version). Suspends
+ * until SDK initialization is complete, then stores the JWT and wakes the operation queue.
+ *
+ * @param externalId The external ID the JWT belongs to.
+ * @param token The new JWT bearer token issued by your backend.
+ */
+ suspend fun updateUserJwtSuspend(
+ externalId: String,
+ token: String,
+ )
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt
new file mode 100644
index 0000000000..9161380add
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt
@@ -0,0 +1,26 @@
+package com.onesignal
+
+/**
+ * Implement this interface and provide an instance to
+ * [IOneSignal.addUserJwtInvalidatedListener] to be notified when the SDK has
+ * detected that the JWT for a user is no longer valid (typically a 401 from
+ * the OneSignal backend on a request signed with that JWT).
+ *
+ * Threading: delivered on a background dispatcher
+ * (`OneSignalDispatchers.launchOnDefault`). Implementations should not assume a
+ * specific thread and should re-dispatch to the UI thread if needed.
+ *
+ * Pure pub/sub: only listeners subscribed at the time of the invalidation
+ * receive the event. Subscribe early (e.g. in `Application.onCreate`) to avoid
+ * missing cold-start 401s.
+ */
+fun interface IUserJwtInvalidatedListener {
+ /**
+ * Called when the JWT is invalidated for [UserJwtInvalidatedEvent.externalId].
+ * Apps should use this signal to fetch a fresh JWT from their backend and
+ * supply it via [IOneSignal.updateUserJwt].
+ *
+ * @param event Describes which user's JWT was invalidated.
+ */
+ fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent)
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt
index 708bbe08f8..ed049b5506 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt
@@ -343,6 +343,39 @@ object OneSignal {
@JvmStatic
fun logout() = oneSignal.logout()
+ /**
+ * Update the JWT bearer token associated with [externalId]. Use this when your backend
+ * has issued a new JWT for an already-logged-in user (e.g. in response to a previous
+ * [IUserJwtInvalidatedListener.onUserJwtInvalidated] callback). Stores the JWT and
+ * wakes the operation queue so any deferred ops can dispatch with the fresh token.
+ *
+ * @param externalId The external ID the JWT belongs to.
+ * @param token The new JWT bearer token issued by your backend.
+ */
+ @JvmStatic
+ fun updateUserJwt(
+ externalId: String,
+ token: String,
+ ) = oneSignal.updateUserJwt(externalId, token)
+
+ /**
+ * Subscribe a listener for JWT-invalidated events. Fires on a background thread when
+ * the SDK detects that the stored JWT for a user is no longer valid (typically after
+ * a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
+ * their backend and supplying it via [updateUserJwt].
+ *
+ * Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
+ * event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
+ */
+ @JvmStatic
+ fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
+ oneSignal.addUserJwtInvalidatedListener(listener)
+
+ /** Unsubscribe a listener previously registered via [addUserJwtInvalidatedListener]. */
+ @JvmStatic
+ fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
+ oneSignal.removeUserJwtInvalidatedListener(listener)
+
private val oneSignal: IOneSignal by lazy {
OneSignalImp()
}
@@ -405,6 +438,21 @@ object OneSignal {
oneSignal.logoutSuspend()
}
+ /**
+ * Update the JWT bearer token associated with [externalId] without blocking the calling
+ * thread. Suspend-safe version of [updateUserJwt].
+ *
+ * @param externalId The external ID the JWT belongs to.
+ * @param token The new JWT bearer token issued by your backend.
+ */
+ @JvmStatic
+ suspend fun updateUserJwtSuspend(
+ externalId: String,
+ token: String,
+ ) {
+ oneSignal.updateUserJwtSuspend(externalId, token)
+ }
+
/**
* Used to retrieve services from the SDK when constructor dependency injection is not an
* option.
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt
new file mode 100644
index 0000000000..6d6f2bad6a
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt
@@ -0,0 +1,9 @@
+package com.onesignal
+
+/**
+ * The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated].
+ * Delivery occurs on a background thread.
+ */
+class UserJwtInvalidatedEvent(
+ val externalId: String,
+)
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 9998fd213e..b66559e7db 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
@@ -13,6 +13,7 @@ import com.onesignal.core.internal.background.impl.BackgroundManager
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.config.impl.ConfigModelStoreListener
import com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.database.IDatabaseProvider
import com.onesignal.core.internal.database.impl.DatabaseProvider
import com.onesignal.core.internal.device.IDeviceService
@@ -45,6 +46,7 @@ import com.onesignal.location.ILocationManager
import com.onesignal.location.internal.MisconfiguredLocationManager
import com.onesignal.notifications.INotificationsManager
import com.onesignal.notifications.internal.MisconfiguredNotificationsManager
+import com.onesignal.user.internal.jwt.JwtTokenStore
internal class CoreModule : IModule {
override fun register(builder: ServiceBuilder) {
@@ -68,6 +70,11 @@ internal class CoreModule : IModule {
builder.register().provides()
builder.register().provides()
+ builder.register().provides()
+ builder.register()
+ .provides()
+ .provides()
+
// Operations
builder.register().provides()
builder.register()
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 dfaaa027dc..ff9f967fc4 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
@@ -71,8 +71,7 @@ internal class ParamsBackendService(
return ParamsObject(
googleProjectNumber = responseJson.safeString("android_sender_id"),
enterprise = responseJson.safeBool("enterp"),
- // TODO: New
- useIdentityVerification = responseJson.safeBool("require_ident_auth"),
+ useIdentityVerification = responseJson.safeBool("jwt_required"),
notificationChannels = responseJson.optJSONArray("chnl_lst"),
firebaseAnalytics = responseJson.safeBool("fba"),
restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"),
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 616fa1916b..acc89a26ca 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
@@ -2,6 +2,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 com.onesignal.user.internal.jwt.JwtRequirement
import org.json.JSONArray
import org.json.JSONObject
@@ -236,13 +237,18 @@ class ConfigModel : Model() {
setBooleanProperty(::enterprise.name, value)
}
- /**
- * Whether SMS auth hash should be used.
- */
- var useIdentityVerification: Boolean
- get() = getBooleanProperty(::useIdentityVerification.name) { false }
+ /** Mirrors backend `jwt_required`. Pre-HYDRATE callers see [JwtRequirement.UNKNOWN]. */
+ internal var useIdentityVerification: JwtRequirement
+ get() = JwtRequirement.fromBoolean(getOptBooleanProperty(::useIdentityVerification.name))
set(value) {
- setBooleanProperty(::useIdentityVerification.name, value)
+ setOptBooleanProperty(
+ ::useIdentityVerification.name,
+ when (value) {
+ JwtRequirement.UNKNOWN -> null
+ JwtRequirement.NOT_REQUIRED -> false
+ JwtRequirement.REQUIRED -> true
+ },
+ )
}
/**
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 e15b6faa07..25e069ebbd 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
@@ -10,6 +10,7 @@ import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.jwt.JwtRequirement
import com.onesignal.user.internal.subscriptions.ISubscriptionManager
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
@@ -82,10 +83,10 @@ internal class ConfigModelStoreListener(
config.fcmParams.projectId = params.fcmParams.projectId
config.fcmParams.appId = params.fcmParams.appId
config.fcmParams.apiKey = params.fcmParams.apiKey
+ config.useIdentityVerification = JwtRequirement.fromBoolean(params.useIdentityVerification ?: false)
// these are only copied from the backend params when the backend has set them.
params.enterprise?.let { config.enterprise = it }
- params.useIdentityVerification?.let { config.useIdentityVerification = it }
params.firebaseAnalytics?.let { config.firebaseAnalytics = it }
params.restoreTTLFilter?.let { config.restoreTTLFilter = it }
params.clearGroupOnSummaryClick?.let { config.clearGroupOnSummaryClick = it }
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt
new file mode 100644
index 0000000000..8af1ae17a8
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt
@@ -0,0 +1,75 @@
+package com.onesignal.core.internal.config.impl
+
+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.core.internal.features.FeatureFlag
+import com.onesignal.core.internal.features.IFeatureManager
+import com.onesignal.core.internal.startup.IStartableService
+import com.onesignal.user.internal.jwt.JwtRequirement
+
+/**
+ * Single source of truth for Identity Verification gating, and for forwarding HYDRATE events to
+ * the [com.onesignal.core.internal.operations.IOperationRepo] post-HYDRATE choreography.
+ *
+ * Gate state is derived on read from the injected [IFeatureManager] (rollout flag) and
+ * [ConfigModelStore] (customer `jwt_required`); nothing is duplicated here. UNKNOWN
+ * (pre-HYDRATE) reads as `false` for both gates, which is the safe default.
+ *
+ * Invariant `ivBehaviorActive == true ⇒ newCodePathsRun == true` holds because both are derived
+ * from the same `useIdentityVerification` field.
+ *
+ * Consumers (e.g. OperationRepo) wire post-HYDRATE behavior via [setOnJwtConfigHydratedHandler];
+ * the handler fires once per HYDRATE with `ivRequired = useIdentityVerification == REQUIRED`.
+ */
+class IdentityVerificationService(
+ private val featureManager: IFeatureManager,
+ private val configModelStore: ConfigModelStore,
+) : IStartableService, ISingletonModelStoreChangeHandler {
+ /** Whether IV-specific behavior (JWT attachment, auth error handling) applies. UNKNOWN reads as `false`. */
+ val ivBehaviorActive: Boolean
+ get() = configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED
+
+ /** Whether new IV-related code paths should run. `featureFlag_IV_ON || jwt_required == REQUIRED`. */
+ val newCodePathsRun: Boolean
+ get() = featureManager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) || ivBehaviorActive
+
+ private val handlerLock = Any()
+ private var onJwtConfigHydrated: ((ivRequired: Boolean) -> Unit)? = null
+
+ /**
+ * Register a handler invoked once per HYDRATE of the config model. Used by OperationRepo to
+ * release pre-HYDRATE deferral and (when IV is required) purge anonymous queued ops.
+ * Pass `null` to clear.
+ */
+ fun setOnJwtConfigHydratedHandler(handler: ((ivRequired: Boolean) -> Unit)?) {
+ synchronized(handlerLock) {
+ onJwtConfigHydrated = handler
+ }
+ }
+
+ override fun start() {
+ configModelStore.subscribe(this)
+ }
+
+ override fun onModelReplaced(
+ model: ConfigModel,
+ tag: String,
+ ) {
+ if (tag != ModelChangeTags.HYDRATE) return
+ // Snapshot the handler under the lock, then invoke outside — never hold the lock
+ // across user-supplied code.
+ val handler = synchronized(handlerLock) { onJwtConfigHydrated }
+ handler?.invoke(model.useIdentityVerification == JwtRequirement.REQUIRED)
+ }
+
+ override fun onModelUpdated(
+ args: ModelChangedArgs,
+ tag: String,
+ ) {
+ // Remote params arrive as full-model replacements (HYDRATE); individual property
+ // updates are not expected for useIdentityVerification.
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt
index 324421a71a..c7fb69804b 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt
@@ -3,7 +3,7 @@ package com.onesignal.core.internal.features
/**
* Controls when remote config changes for a feature are applied.
*/
-internal enum class FeatureActivationMode {
+enum class FeatureActivationMode {
/**
* Apply config changes immediately during the current app run.
*/
@@ -20,18 +20,22 @@ internal enum class FeatureActivationMode {
*
* [key] values are **lowercase** strings as returned from remote config / Turbine `features` arrays.
*/
-internal enum class FeatureFlag(
+enum class FeatureFlag(
val key: String,
val activationMode: FeatureActivationMode
) {
// Threading mode is selected once per app startup to avoid mixed-mode behavior mid-session.
- //
// Remote key (lowercase) must match backend / Turbine flag id.
- //
SDK_BACKGROUND_THREADING(
"sdk_background_threading",
FeatureActivationMode.APP_STARTUP
),
+
+ /** JWT signing of SDK requests. IMMEDIATE so a kill-switch doesn't need a cold start. */
+ SDK_IDENTITY_VERIFICATION(
+ "sdk_identity_verification",
+ FeatureActivationMode.IMMEDIATE
+ ),
;
fun isEnabledIn(enabledKeys: Set): Boolean {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt
index e9b25c002c..41f54c68a8 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt
@@ -10,7 +10,7 @@ import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.debug.internal.logging.Logging
import kotlinx.serialization.json.JsonObject
-internal interface IFeatureManager {
+interface IFeatureManager {
fun isEnabled(feature: FeatureFlag): Boolean
/**
@@ -163,6 +163,10 @@ internal class FeatureManager(
enabled = enabled,
source = "FeatureManager:${feature.activationMode}"
)
+
+ // SDK_IDENTITY_VERIFICATION has no side effect: IdentityVerificationService
+ // reads featureStates directly via isEnabled() at gate-check time.
+ FeatureFlag.SDK_IDENTITY_VERIFICATION -> {}
}
}
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 d1ea2036c2..2b6e1267f4 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
@@ -159,17 +159,10 @@ internal class HttpClient(
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
+ // H E A D E R S — must be set before any body write below. `getOutputStream()`
+ // (and `setFixedLengthStreamingMode`) commit the request line + headers to the
+ // wire; `setRequestProperty` after that point either throws IllegalStateException
+ // or is silently dropped, depending on the HttpURLConnection implementation.
if (headers?.cacheKey != null) {
val eTag =
@@ -195,6 +188,20 @@ internal class HttpClient(
con.setRequestProperty("OneSignal-Session-Duration", headers.sessionDuration.toString())
}
+ if (headers?.jwt != null) {
+ con.setRequestProperty("Authorization", "Bearer ${headers.jwt}")
+ }
+
+ 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)
+ }
+
// Network request is made from getResponseCode()
httpResponse = con.responseCode
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt
index f566fd04fc..8a0f3e7c95 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt
@@ -17,4 +17,9 @@ data class OptionalHeaders(
* Used to track delay between session start and request
*/
val sessionDuration: Long? = null,
+ /**
+ * JWT bearer token for identity verification. When non-null, sent as
+ * `Authorization: Bearer ` on the request.
+ */
+ val jwt: String? = null,
)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt
index 76f51994ab..708c715d6f 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt
@@ -16,6 +16,23 @@ abstract class Operation(name: String) : Model() {
setStringProperty(::name.name, value)
}
+ /**
+ * externalId of the user this operation was enqueued for. Captured at construction
+ * time so the op stays bound to its original user even if the current user changes
+ * before the op executes. Null for anonymous users.
+ */
+ var externalId: String?
+ get() = getOptStringProperty(::externalId.name)
+ internal set(value) { // `internal` so subclass constructors can assign at construction time
+ setOptStringProperty(::externalId.name, value)
+ }
+
+ /**
+ * Whether this operation requires a valid JWT when Identity Verification is active.
+ * Subclasses may override to `false` for endpoints that don't require auth.
+ */
+ open val requiresJwt: Boolean get() = true
+
init {
this.name = name
}
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 7237b1f164..c1a8db7ba2 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
@@ -2,7 +2,9 @@ package com.onesignal.core.internal.operations.impl
import com.onesignal.common.IDManager
import com.onesignal.common.threading.WaiterWithValue
+import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.GroupComparisonType
import com.onesignal.core.internal.operations.IOperationExecutor
@@ -12,6 +14,8 @@ import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.core.internal.time.ITime
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.jwt.JwtRequirement
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.LoginUserOperation
import com.onesignal.user.internal.operations.impl.states.NewRecordsState
import kotlinx.coroutines.CompletableDeferred
@@ -30,7 +34,10 @@ internal class OperationRepo(
private val _configModelStore: ConfigModelStore,
private val _time: ITime,
private val _newRecordState: NewRecordsState,
+ private val _jwtTokenStore: JwtTokenStore,
+ private val _identityVerificationService: IdentityVerificationService,
) : IOperationRepo, IStartableService {
+
internal class OperationQueueItem(
val operation: Operation,
var waiter: WaiterWithValue? = null, // waiter may transfer during operation de-dupe
@@ -104,6 +111,12 @@ internal class OperationRepo(
override fun start() {
paused = false
+ // Wire post-HYDRATE choreography. Constructor injection of IOperationRepo into
+ // IdentityVerificationService would create a cycle, so the service exposes a setter
+ // that we call here.
+ _identityVerificationService.setOnJwtConfigHydratedHandler { ivRequired ->
+ onJwtConfigHydrated(ivRequired)
+ }
scope.launch {
// load saved operations first then start processing the queue to ensure correct operation order
loadSavedOperations()
@@ -122,6 +135,8 @@ internal class OperationRepo(
operation: Operation,
flush: Boolean,
) {
+ if (shouldSuppressAnonymousOp(operation)) return
+
Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)")
operation.id = UUID.randomUUID().toString()
@@ -134,6 +149,8 @@ internal class OperationRepo(
operation: Operation,
flush: Boolean,
): Boolean {
+ if (shouldSuppressAnonymousOp(operation)) return false
+
Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)")
operation.id = UUID.randomUUID().toString()
@@ -144,6 +161,26 @@ internal class OperationRepo(
return waiter.waitForWake()
}
+ /**
+ * Drop anonymous (externalId == null) operations at enqueue time when IV is required —
+ * they cannot be authenticated and would otherwise sit in the queue forever, blocked by
+ * `hasValidJwtIfRequired`. LoginUserOperation is exempt because it's enqueued
+ * intentionally during logout and purged later by [removeOperationsWithoutExternalId]
+ * if needed. Outer-gated on `newCodePathsRun` so Phase 1 customers stay byte-for-byte
+ * on the legacy enqueue path.
+ */
+ private fun shouldSuppressAnonymousOp(op: Operation): Boolean {
+ if (!_identityVerificationService.newCodePathsRun) return false
+ if (op is LoginUserOperation) return false
+ val suppress =
+ _configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED &&
+ op.externalId == null
+ if (suppress) {
+ Logging.debug("OperationRepo: suppressing anonymous op under IV-required: $op")
+ }
+ return suppress
+ }
+
/**
* Only used inside this class, adds OperationQueueItem to queue
* WARNING: Never set flush=true until budget rules are added, even for internal use!
@@ -203,9 +240,10 @@ internal class OperationRepo(
} else {
queue.add(queueItem)
}
- }
- if (addToStore) {
- _operationModelStore.add(queueItem.operation)
+ // Inside the lock so queue.add + store.add are atomic vs. the IO-side purge.
+ if (addToStore) {
+ _operationModelStore.add(queueItem.operation)
+ }
}
waiter.wake(LoopWaiterMessage(flush, 0))
@@ -225,7 +263,10 @@ internal class OperationRepo(
}
val ops = getNextOps(executeBucket)
- Logging.debug("processQueueForever:ops:\n$ops")
+ if (Logging.atLogLevel(LogLevel.DEBUG)) {
+ val queueSnapshotForLogging = synchronized(queue) { queue.toList() }
+ Logging.debug("processQueueForever:ops:\n$ops\nqueue(${queueSnapshotForLogging.size}):\n$queueSnapshotForLogging")
+ }
if (ops != null) {
executeOperations(ops)
@@ -244,6 +285,52 @@ internal class OperationRepo(
waiter.wake(LoopWaiterMessage(false))
}
+ /**
+ * Drops queued operations whose externalId is null. Called by the IV-aware HYDRATE
+ * choreography in [OperationRepoIvExtensions] when `jwt_required` becomes REQUIRED
+ * to evict anon ops that can no longer execute.
+ */
+ internal fun removeOperationsWithoutExternalId() {
+ val removedIds: List =
+ synchronized(queue) {
+ val anonymous = queue.filter { it.operation.externalId == null }
+ anonymous.forEach { it.waiter?.wake(false) }
+ queue.removeAll(anonymous)
+ // IV=ON never transfers anonymous state; clear existingOnesignalId so the
+ // executor takes the createUser (upsert) path. The merge-anon-into-identified
+ // path can't dispatch under IV — anon user creation requires a JWT-less call
+ // the backend rejects — and a stale local-id existingOnesignalId would leave
+ // canStartExecute=false forever, deadlocking the queue.
+ queue.forEach { item ->
+ val op = item.operation
+ if (op is LoginUserOperation && op.existingOnesignalId != null) {
+ Logging.debug("OperationRepo: cleared existingOnesignalId on LoginUserOperation (was ${op.existingOnesignalId})")
+ op.existingOnesignalId = null
+ }
+ }
+ Logging.debug("OperationRepo: removeOperationsWithoutExternalId removed ${anonymous.size} of ${anonymous.size + queue.size} operations")
+ anonymous.map { it.operation.id }
+ }
+ // Persistent store removal outside the queue lock; ModelStore has its own locking.
+ removedIds.forEach { _operationModelStore.remove(it) }
+ }
+
+ /**
+ * Post-HYDRATE maintenance: scheduled on IO so it runs *after* `loadSavedOperations`
+ * populates the queue (fix for an earlier race where the purge ran against an empty
+ * in-memory queue on cold start). Force-execute always fires to release the pre-HYDRATE
+ * deferral in [getNextOps]. Invoked via the handler registered in [start].
+ */
+ internal fun onJwtConfigHydrated(ivRequired: Boolean) {
+ suspendifyOnIO {
+ awaitInitialized()
+ if (ivRequired) {
+ removeOperationsWithoutExternalId()
+ }
+ forceExecuteOperations()
+ }
+ }
+
/**
* Waits until a new operation is enqueued, then wait an additional
* amount of time afterwards, so operations can be grouped/batched.
@@ -305,14 +392,27 @@ internal class OperationRepo(
ops.forEach { _operationModelStore.remove(it.operation.id) }
ops.forEach { it.waiter?.wake(true) }
}
- ExecutionResult.FAIL_UNAUTHORIZED, // TODO: Need to provide callback for app to reset JWT. For now, fail with no retry.
+ ExecutionResult.FAIL_UNAUTHORIZED -> {
+ // Outer gate: dispatch to IV extension only on new code paths.
+ val handled =
+ _identityVerificationService.newCodePathsRun &&
+ handleFailUnauthorized(
+ startingOp,
+ ops,
+ _jwtTokenStore,
+ _identityVerificationService.ivBehaviorActive,
+ )
+ if (!handled) {
+ // IV inactive or anon op: drop and wake waiters, matching FAIL_NORETRY.
+ Logging.warn("Operation execution failed without retry: $operations")
+ dropAndWake(ops)
+ }
+ }
ExecutionResult.FAIL_NORETRY,
ExecutionResult.FAIL_CONFLICT,
-> {
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) }
+ dropAndWake(ops)
}
ExecutionResult.SUCCESS_STARTING_ONLY -> {
// remove the starting operation from the store and wake any waiters, then
@@ -372,13 +472,16 @@ internal class OperationRepo(
}
} catch (e: Throwable) {
Logging.log(LogLevel.ERROR, "Error attempting to execute operation: $ops", e)
-
- // 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) }
+ dropAndWake(ops)
}
}
+ /** Drop ops from the persistent store and wake any waiters with `false` (failure). */
+ private fun dropAndWake(ops: List) {
+ ops.forEach { _operationModelStore.remove(it.operation.id) }
+ ops.forEach { it.waiter?.wake(false) }
+ }
+
/**
* Wait which ever is longer, retryAfterSeconds returned by the server,
* or based on the retry count.
@@ -415,12 +518,28 @@ internal class OperationRepo(
}
internal fun getNextOps(bucketFilter: Int): List? {
+ // Pre-HYDRATE deferral: wait until we know whether IV is required before dispatching
+ // any op, otherwise we could send an unsigned request when the customer has IV enabled.
+ // `isInitializedWithRemote` would be wrong here: pre-IV SDKs persisted it as `true`
+ // without ever reading `jwt_required`, so cached config from an upgrade looks
+ // "initialized" while `useIdentityVerification` is still UNKNOWN. Gating on UNKNOWN
+ // directly is correct because the IV-aware backend ships alongside this SDK — every
+ // successful HYDRATE will populate the field with REQUIRED or NOT_REQUIRED.
+ if (_configModelStore.model.useIdentityVerification == JwtRequirement.UNKNOWN) {
+ return null
+ }
+
+ // Snapshot gate state once per pass so all queue items see the same IV view.
+ val newCodePathsRun = _identityVerificationService.newCodePathsRun
+ val ivBehaviorActive = _identityVerificationService.ivBehaviorActive
return synchronized(queue) {
val startingOp =
queue.firstOrNull {
it.operation.canStartExecute &&
_newRecordState.canAccess(it.operation.applyToRecordId) &&
- it.bucket <= bucketFilter
+ it.bucket <= bucketFilter &&
+ // Outer gate: skip IV JWT check entirely on old code path.
+ (!newCodePathsRun || hasValidJwtIfRequired(_jwtTokenStore, it.operation, ivBehaviorActive))
}
if (startingOp != null) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt
new file mode 100644
index 0000000000..34a108fdbc
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepoIvExtensions.kt
@@ -0,0 +1,68 @@
+package com.onesignal.core.internal.operations.impl
+
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.jwt.JwtTokenStore
+
+/**
+ * IV-specific behavior layered onto [OperationRepo]. Base-class dispatch sites are gated on
+ * `IdentityVerificationService.newCodePathsRun`; the caller passes `ivBehaviorActive` through so
+ * these extensions can short-circuit and stay inert for Phase 3 users (new code path on, IV
+ * behavior off).
+ */
+
+/**
+ * Returns `true` if [op] may execute given current IV state.
+ *
+ * - IV behavior inactive → always `true` (no gating; new paths run but behavior is same as old).
+ * - Op opts out via `requiresJwt = false` → `true`.
+ * - Anonymous op while IV active → `false` (can't authenticate without an externalId).
+ * - Otherwise → `true` iff a JWT is currently stored for the op's externalId.
+ */
+internal fun OperationRepo.hasValidJwtIfRequired(
+ jwtTokenStore: JwtTokenStore,
+ op: com.onesignal.core.internal.operations.Operation,
+ ivBehaviorActive: Boolean,
+): Boolean {
+ if (!ivBehaviorActive || !op.requiresJwt) return true
+ val externalId = op.externalId ?: return false
+ return jwtTokenStore.getJwt(externalId) != null
+}
+
+/**
+ * Handles a [com.onesignal.core.internal.operations.ExecutionResult.FAIL_UNAUTHORIZED] response
+ * when IV behavior is active. Invalidates the JWT for the failing op's externalId
+ * and re-queues the ops (waiter wake with `false` so `enqueueAndWait`
+ * callers don't hang).
+ *
+ * Returns `true` if IV-specific handling was applied (caller should stop processing this result),
+ * or `false` when IV behavior is inactive or the op is anonymous (caller falls back to default
+ * drop-on-fail handling).
+ */
+internal fun OperationRepo.handleFailUnauthorized(
+ startingOp: OperationRepo.OperationQueueItem,
+ ops: List,
+ jwtTokenStore: JwtTokenStore,
+ ivBehaviorActive: Boolean,
+): Boolean {
+ if (!ivBehaviorActive) return false
+ val externalId = startingOp.operation.externalId ?: return false
+
+ // Schedules an async fire of onUserJwtInvalidated to subscribers via
+ // OneSignalDispatchers.launchOnDefault — the developer-facing listener invocation is
+ // NOT ordered with respect to the waiter.wake below; awaiting `enqueueAndWait` callers
+ // may resume before, after, or concurrent with the listener.
+ jwtTokenStore.invalidateJwt(externalId)
+ Logging.info(
+ "Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. " +
+ "Operations re-queued.",
+ )
+ // Wake enqueueAndWait callers; re-queue with waiter = null because the original waiter
+ // is already woken.
+ ops.forEach { it.waiter?.wake(false) }
+ synchronized(queue) {
+ ops.reversed().forEach {
+ queue.add(0, OperationRepo.OperationQueueItem(it.operation, waiter = null, bucket = it.bucket, retries = it.retries))
+ }
+ }
+ return true
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt
index f4d4b92a5d..84fab69449 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt
@@ -204,6 +204,9 @@ object PreferenceOneSignalKeys {
*/
const val PREFS_OS_LOCATION_SHARED = "OS_LOCATION_SHARED"
+ /** (String) JSON object mapping externalId -> JWT token. */
+ const val PREFS_OS_JWT_TOKENS = "PREFS_OS_JWT_TOKENS"
+
// Permissions
/**
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt
index 35f95fac5f..8b2694bcb7 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt
@@ -240,6 +240,7 @@ internal class TrackGooglePurchase(
TrackPurchaseOperation(
_configModelStore.model.appId,
_identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
newAsExisting,
BigDecimal(0),
purchasesToReport,
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 29fca0b410..9694d617f2 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
@@ -2,6 +2,7 @@ package com.onesignal.internal
import android.content.Context
import com.onesignal.IOneSignal
+import com.onesignal.IUserJwtInvalidatedListener
import com.onesignal.common.AndroidUtils
import com.onesignal.common.DeviceUtils
import com.onesignal.common.OneSignalUtils
@@ -16,6 +17,7 @@ import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.application.impl.ApplicationService
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.features.FeatureFlag
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.operations.IOperationRepo
@@ -37,6 +39,7 @@ import com.onesignal.user.internal.LoginHelper
import com.onesignal.user.internal.LogoutHelper
import com.onesignal.user.internal.UserSwitcher
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.properties.PropertiesModelStore
import com.onesignal.user.internal.resolveAppId
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
@@ -142,6 +145,8 @@ internal class OneSignalImp(
private val propertiesModelStore: PropertiesModelStore by lazy { services.getService() }
private val subscriptionModelStore: SubscriptionModelStore by lazy { services.getService() }
private val preferencesService: IPreferencesService by lazy { services.getService() }
+ private val jwtTokenStore: JwtTokenStore by lazy { services.getService() }
+ private val identityVerificationService: IdentityVerificationService by lazy { services.getService() }
private val listOfModules =
listOf(
"com.onesignal.notifications.NotificationsModule",
@@ -220,6 +225,7 @@ internal class OneSignalImp(
userSwitcher = userSwitcher,
operationRepo = operationRepo,
configModel = configModel,
+ jwtTokenStore = jwtTokenStore,
lock = loginLogoutLock,
)
}
@@ -230,6 +236,8 @@ internal class OneSignalImp(
userSwitcher = userSwitcher,
operationRepo = operationRepo,
configModel = configModel,
+ subscriptionModelStore = subscriptionModelStore,
+ identityVerificationService = identityVerificationService,
lock = loginLogoutLock,
)
}
@@ -381,7 +389,7 @@ internal class OneSignalImp(
externalId: String,
jwtBearerToken: String?,
) {
- Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)")
+ Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})")
if (isBackgroundThreadingEnabled) {
waitForInit(operationName = "login")
@@ -428,6 +436,47 @@ internal class OneSignalImp(
}
}
+ override fun updateUserJwt(
+ externalId: String,
+ token: String,
+ ) {
+ Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: ...${token.takeLast(8)})")
+
+ if (isBackgroundThreadingEnabled) {
+ waitForInit(operationName = "updateUserJwt")
+ } else {
+ if (!isInitialized) {
+ throw IllegalStateException("Must call 'initWithContext' before 'updateUserJwt'")
+ }
+ }
+
+ jwtTokenStore.putJwt(externalId, token)
+ // Wake the queue so any deferred ops can dispatch with the fresh token.
+ operationRepo.forceExecuteOperations()
+ }
+
+ override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
+ if (isBackgroundThreadingEnabled) {
+ waitForInit(operationName = "addUserJwtInvalidatedListener")
+ } else {
+ if (!isInitialized) {
+ throw IllegalStateException("Must call 'initWithContext' before 'addUserJwtInvalidatedListener'")
+ }
+ }
+ jwtTokenStore.addUserJwtInvalidatedListener(listener)
+ }
+
+ override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
+ if (isBackgroundThreadingEnabled) {
+ waitForInit(operationName = "removeUserJwtInvalidatedListener")
+ } else {
+ if (!isInitialized) {
+ throw IllegalStateException("Must call 'initWithContext' before 'removeUserJwtInvalidatedListener'")
+ }
+ }
+ jwtTokenStore.removeUserJwtInvalidatedListener(listener)
+ }
+
override fun hasService(c: Class): Boolean = services.hasService(c)
override fun getService(c: Class): T = services.getService(c)
@@ -657,7 +706,7 @@ internal class OneSignalImp(
externalId: String,
jwtBearerToken: String?,
) = withContext(runtimeIoDispatcher) {
- Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)")
+ Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})")
suspendUntilInit(operationName = "login")
@@ -669,6 +718,22 @@ internal class OneSignalImp(
loginHelper.enqueueLogin(context)
}
+ override suspend fun updateUserJwtSuspend(
+ externalId: String,
+ token: String,
+ ) = withContext(runtimeIoDispatcher) {
+ Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: ...${token.takeLast(8)})")
+
+ suspendUntilInit(operationName = "updateUserJwt")
+
+ if (!isInitialized) {
+ throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'")
+ }
+
+ jwtTokenStore.putJwt(externalId, token)
+ operationRepo.forceExecuteOperations()
+ }
+
override suspend fun logoutSuspend() =
withContext(runtimeIoDispatcher) {
Logging.log(LogLevel.DEBUG, "logoutSuspend()")
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 1fcdcd8641..344f7702ed 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
@@ -44,7 +44,14 @@ internal class SessionListener(
override fun onSessionStarted() {
_propertiesModelStore.model.timezone = TimeUtils.getTimeZoneId()
- _operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId), true)
+ _operationRepo.enqueue(
+ TrackSessionStartOperation(
+ _configModelStore.model.appId,
+ _identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
+ ),
+ true,
+ )
}
override fun onSessionActive() {
@@ -60,7 +67,12 @@ internal class SessionListener(
}
_operationRepo.enqueue(
- TrackSessionEndOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, durationInSeconds),
+ TrackSessionEndOperation(
+ _configModelStore.model.appId,
+ _identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
+ durationInSeconds,
+ ),
)
suspendifyOnIO {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt
index be55228756..e742369f5c 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt
@@ -75,7 +75,9 @@ internal class UserModule : IModule {
builder.register().provides()
builder.register().provides()
builder.register().provides()
- builder.register().provides()
+ builder.register()
+ .provides()
+ .provides()
builder.register().provides()
builder.register().provides()
builder.register().provides()
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt
index 6743e405fd..b86f366db5 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt
@@ -4,13 +4,16 @@ import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtRequirement
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.LoginUserOperation
-class LoginHelper(
+internal class LoginHelper(
private val identityModelStore: IdentityModelStore,
private val userSwitcher: UserSwitcher,
private val operationRepo: IOperationRepo,
private val configModel: ConfigModel,
+ private val jwtTokenStore: JwtTokenStore,
private val lock: Any,
) {
internal data class LoginEnqueueContext(
@@ -35,17 +38,37 @@ class LoginHelper(
val currentOneSignalId = identityModelStore.model.onesignalId
if (currentExternalId == externalId) {
+ // Same-user refresh path (e.g. login(sameId, freshJwt) after a 401). Store the
+ // fresh token and wake the queue so any ops deferred by `hasValidJwtIfRequired`
+ // dispatch immediately — symmetric with `updateUserJwt`. putJwt no-ops on null.
+ if (jwtBearerToken != null) {
+ jwtTokenStore.putJwt(externalId, jwtBearerToken)
+ operationRepo.forceExecuteOperations()
+ }
return null
}
- // TODO: Set JWT Token for all future requests.
+ // Store the JWT before the LoginUserOperation enqueues so that when the op
+ // dispatches, the JWT lookup in `hasValidJwtIfRequired` already succeeds.
+ // putJwt no-ops on null.
+ jwtTokenStore.putJwt(externalId, jwtBearerToken)
userSwitcher.createAndSwitchToNewUser { identityModel, _ ->
identityModel.externalId = externalId
}
val newOneSignalId = identityModelStore.model.onesignalId
+ // Under IV-required, the merge-anon-into-identified path can't dispatch — the
+ // anon user was never created server-side (no JWT) so the local-id reference
+ // would deadlock LoginUserOperation.canStartExecute. Skip the link entirely so
+ // the executor takes the createUser (upsert) path.
val existingOneSignalId =
- if (currentExternalId == null) currentOneSignalId else null
+ if (configModel.useIdentityVerification == JwtRequirement.REQUIRED) {
+ null
+ } else if (currentExternalId == null) {
+ currentOneSignalId
+ } else {
+ null
+ }
return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId)
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt
index e0017d5b3d..1f58533369 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt
@@ -1,15 +1,19 @@
package com.onesignal.user.internal
import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.operations.LoginUserOperation
+import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
-class LogoutHelper(
+internal class LogoutHelper(
private val identityModelStore: IdentityModelStore,
private val userSwitcher: UserSwitcher,
private val operationRepo: IOperationRepo,
private val configModel: ConfigModel,
+ private val subscriptionModelStore: SubscriptionModelStore,
+ private val identityVerificationService: IdentityVerificationService,
private val lock: Any,
) {
internal data class LogoutEnqueueContext(
@@ -29,11 +33,26 @@ class LogoutHelper(
return null
}
+ // Outer gate: dispatch to IV extension only on new code paths. The extension's
+ // inner gate (ivBehaviorActive) keeps Phase 3 users on the legacy logout flow.
+ val handled =
+ identityVerificationService.newCodePathsRun &&
+ switchUserIv(
+ userSwitcher,
+ subscriptionModelStore,
+ configModel,
+ identityVerificationService.ivBehaviorActive,
+ )
+ if (handled) {
+ // IV-required: subscription is internally disabled and the user-switch
+ // suppressed backend op enqueue. Don't enqueue anonymous LoginUserOperation —
+ // the anonymous user cannot authenticate without a JWT.
+ return null
+ }
+
// Create new device-scoped user (clears external ID)
userSwitcher.createAndSwitchToNewUser()
- // TODO: remove JWT Token for all future requests.
-
return LogoutEnqueueContext(configModel.appId, identityModelStore.model.onesignalId)
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt
new file mode 100644
index 0000000000..f0ff49f1f4
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt
@@ -0,0 +1,44 @@
+package com.onesignal.user.internal
+
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
+
+/**
+ * IV-specific behavior for [LogoutHelper]. The base-class call site dispatches via
+ * `if (newCodePathsRun) switchUserIv(...) else legacyLogout()`; this extension
+ * internally short-circuits on `ivBehaviorActive` to keep Phase 3 users (new code path on,
+ * IV behavior off) on the legacy logout flow.
+ */
+
+/**
+ * Performs the IV-aware logout user-switch when [ivBehaviorActive] is true.
+ *
+ * Order matters and is intentional (mirrors reference branches #2599 and #2613):
+ * 1. Set `isDisabledInternally = true` on the CURRENT push subscription with the default
+ * NORMAL tag. This propagates through [com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener.getUpdateOperation],
+ * which reads the still-current OLD identity and enqueues an `UpdateSubscriptionOperation`
+ * carrying `(enabled = false, status = UNSUBSCRIBE)` — letting the backend know this device's
+ * push subscription is unsubscribing as the user logs out. The OLD user's JWT is still valid
+ * here, so the op dispatches successfully.
+ * 2. Switch to the new device-scoped (anonymous) user via
+ * [UserSwitcher.createAndSwitchToNewUser] with `suppressBackendOperation = true` so the
+ * subscription replacement does NOT propagate to listeners — the new anonymous user has no
+ * JWT and any create-subscription op for it would 401 indefinitely.
+ *
+ * Returns `true` when IV-specific handling was applied (caller skips legacy enqueue),
+ * or `false` when IV behavior is inactive (caller falls through to the legacy logout).
+ */
+internal fun switchUserIv(
+ userSwitcher: UserSwitcher,
+ subscriptionModelStore: SubscriptionModelStore,
+ configModel: ConfigModel,
+ ivBehaviorActive: Boolean,
+): Boolean {
+ if (!ivBehaviorActive) return false
+
+ configModel.pushSubscriptionId?.let { pushSubId ->
+ subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true }
+ }
+ userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true)
+ return true
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt
index 5fba367b1a..f1401e031a 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt
@@ -173,6 +173,7 @@ class UserSwitcher(
LoginUserFromSubscriptionOperation(
configModel.appId,
identityModelStore.model.onesignalId,
+ identityModelStore.model.externalId,
legacyPlayerId,
),
)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt
index 4278d8002b..b59dd71917 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt
@@ -18,6 +18,7 @@ interface IIdentityBackendService {
aliasLabel: String,
aliasValue: String,
identities: Map,
+ jwt: String? = null,
): Map
/**
@@ -35,6 +36,7 @@ interface IIdentityBackendService {
aliasLabel: String,
aliasValue: String,
aliasLabelToDelete: String,
+ jwt: String? = null,
)
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt
index 7bcf23fdb2..45089fcb13 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt
@@ -22,6 +22,7 @@ interface ISubscriptionBackendService {
aliasLabel: String,
aliasValue: String,
subscription: SubscriptionObject,
+ jwt: String? = null,
): Pair?
/**
@@ -35,6 +36,7 @@ interface ISubscriptionBackendService {
appId: String,
subscriptionId: String,
subscription: SubscriptionObject,
+ jwt: String? = null,
): RywData?
/**
@@ -46,6 +48,7 @@ interface ISubscriptionBackendService {
suspend fun deleteSubscription(
appId: String,
subscriptionId: String,
+ jwt: String? = null,
)
/**
@@ -61,11 +64,15 @@ interface ISubscriptionBackendService {
subscriptionId: String,
aliasLabel: String,
aliasValue: String,
+ jwt: String? = null,
)
/**
* Given an existing subscription, retrieve all identities associated to it.
*
+ * Note: this endpoint is not used when `jwt_required == true`; the v4→v5 migration path it
+ * supports is explicitly blocked under IV. See [LoginUserFromSubscriptionOperationExecutor].
+ *
* @param appId The ID of the OneSignal application this subscription exists under.
* @param subscriptionId The ID of the subscription to retrieve identities for.
*
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt
index 4cec114b5a..7cc509d450 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt
@@ -24,6 +24,7 @@ interface IUserBackendService {
identities: Map,
subscriptions: List,
properties: Map,
+ jwt: String? = null,
): CreateUserResponse
// TODO: Change to send only the push subscription, optimally
@@ -48,6 +49,7 @@ interface IUserBackendService {
properties: PropertiesObject,
refreshDeviceMetadata: Boolean,
propertyiesDelta: PropertiesDeltasObject,
+ jwt: String? = null,
): RywData?
/**
@@ -65,6 +67,7 @@ interface IUserBackendService {
appId: String,
aliasLabel: String,
aliasValue: String,
+ jwt: String? = null,
): CreateUserResponse
}
@@ -81,4 +84,11 @@ class CreateUserResponse(
* The subscriptions for the user.
*/
val subscriptions: List,
+ /**
+ * Read-your-write data for IAM fetch consistency, when the backend supplies it.
+ * Populated under Identity Verification so [com.onesignal.inAppMessages] can await
+ * the user record's propagation before fetching IAMs. `null` for non-IV apps and
+ * older backends that don't include `ryw_token` in the response.
+ */
+ val rywData: com.onesignal.common.consistency.RywData? = null,
)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt
index adfff7bdc9..1bfd8c14af 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt
@@ -4,6 +4,7 @@ import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.putMap
import com.onesignal.common.toMap
import com.onesignal.core.internal.http.IHttpClient
+import com.onesignal.core.internal.http.impl.OptionalHeaders
import com.onesignal.user.internal.backend.IIdentityBackendService
import org.json.JSONObject
@@ -15,12 +16,14 @@ internal class IdentityBackendService(
aliasLabel: String,
aliasValue: String,
identities: Map,
+ jwt: String?,
): Map {
val requestJSONObject =
JSONObject()
.put("identity", JSONObject().putMap(identities))
- val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue/identity", requestJSONObject)
+ val response =
+ _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue/identity", requestJSONObject, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
@@ -36,8 +39,10 @@ internal class IdentityBackendService(
aliasLabel: String,
aliasValue: String,
aliasLabelToDelete: String,
+ jwt: String?,
) {
- val response = _httpClient.delete("apps/$appId/users/by/$aliasLabel/$aliasValue/identity/$aliasLabelToDelete")
+ val response =
+ _httpClient.delete("apps/$appId/users/by/$aliasLabel/$aliasValue/identity/$aliasLabelToDelete", OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt
index ff3745b32b..5c971df6e5 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt
@@ -1,5 +1,6 @@
package com.onesignal.user.internal.backend.impl
+import com.onesignal.common.consistency.RywData
import com.onesignal.common.expandJSONArray
import com.onesignal.common.putJSONArray
import com.onesignal.common.putMap
@@ -8,6 +9,7 @@ import com.onesignal.common.safeBool
import com.onesignal.common.safeDouble
import com.onesignal.common.safeInt
import com.onesignal.common.safeJSONObject
+import com.onesignal.common.safeLong
import com.onesignal.common.safeString
import com.onesignal.common.toMap
import com.onesignal.user.internal.backend.CreateUserResponse
@@ -55,7 +57,12 @@ object JSONConverter {
return@expandJSONArray null
}
- return CreateUserResponse(respIdentities, respProperties, respSubscriptions)
+ // Backend may include `ryw_token` (and optional `ryw_delay`) under Identity Verification
+ // so InAppMessagesManager can gate IAM fetch on read-your-write consistency.
+ val rywToken = jsonObject.safeString("ryw_token")
+ val rywData = rywToken?.let { RywData(it, jsonObject.safeLong("ryw_delay")) }
+
+ return CreateUserResponse(respIdentities, respProperties, respSubscriptions, rywData)
}
fun convertToJSON(properties: PropertiesObject): JSONObject {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt
index a2266d4d36..995e45393e 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt
@@ -7,6 +7,7 @@ import com.onesignal.common.safeLong
import com.onesignal.common.safeString
import com.onesignal.common.toMap
import com.onesignal.core.internal.http.IHttpClient
+import com.onesignal.core.internal.http.impl.OptionalHeaders
import com.onesignal.user.internal.backend.ISubscriptionBackendService
import com.onesignal.user.internal.backend.SubscriptionObject
import org.json.JSONObject
@@ -19,11 +20,13 @@ internal class SubscriptionBackendService(
aliasLabel: String,
aliasValue: String,
subscription: SubscriptionObject,
+ jwt: String?,
): Pair? {
val jsonSubscription = JSONConverter.convertToJSON(subscription)
val requestJSON = JSONObject().put("subscription", jsonSubscription)
- val response = _httpClient.post("apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions", requestJSON)
+ val response =
+ _httpClient.post("apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions", requestJSON, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
@@ -50,12 +53,13 @@ internal class SubscriptionBackendService(
appId: String,
subscriptionId: String,
subscription: SubscriptionObject,
+ jwt: String?,
): RywData? {
val requestJSON =
JSONObject()
.put("subscription", JSONConverter.convertToJSON(subscription))
- val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId", requestJSON)
+ val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId", requestJSON, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
@@ -76,8 +80,9 @@ internal class SubscriptionBackendService(
override suspend fun deleteSubscription(
appId: String,
subscriptionId: String,
+ jwt: String?,
) {
- val response = _httpClient.delete("apps/$appId/subscriptions/$subscriptionId")
+ val response = _httpClient.delete("apps/$appId/subscriptions/$subscriptionId", OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
@@ -89,12 +94,13 @@ internal class SubscriptionBackendService(
subscriptionId: String,
aliasLabel: String,
aliasValue: String,
+ jwt: String?,
) {
val requestJSON =
JSONObject()
.put("identity", JSONObject().put(aliasLabel, aliasValue))
- val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId/owner", requestJSON)
+ val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId/owner", requestJSON, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt
index 1a1514018f..464c4963da 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt
@@ -6,6 +6,7 @@ import com.onesignal.common.putMap
import com.onesignal.common.safeLong
import com.onesignal.common.safeString
import com.onesignal.core.internal.http.IHttpClient
+import com.onesignal.core.internal.http.impl.OptionalHeaders
import com.onesignal.user.internal.backend.CreateUserResponse
import com.onesignal.user.internal.backend.IUserBackendService
import com.onesignal.user.internal.backend.PropertiesDeltasObject
@@ -21,6 +22,7 @@ internal class UserBackendService(
identities: Map,
subscriptions: List,
properties: Map,
+ jwt: String?,
): CreateUserResponse {
val requestJSON = JSONObject()
@@ -39,7 +41,7 @@ internal class UserBackendService(
requestJSON.put("refresh_device_metadata", true)
- val response = _httpClient.post("apps/$appId/users", requestJSON)
+ val response = _httpClient.post("apps/$appId/users", requestJSON, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
@@ -55,6 +57,7 @@ internal class UserBackendService(
properties: PropertiesObject,
refreshDeviceMetadata: Boolean,
propertyiesDelta: PropertiesDeltasObject,
+ jwt: String?,
): RywData? {
val jsonObject =
JSONObject()
@@ -68,7 +71,7 @@ internal class UserBackendService(
jsonObject.put("deltas", JSONConverter.convertToJSON(propertyiesDelta))
}
- val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue", jsonObject)
+ val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue", jsonObject, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
@@ -90,8 +93,9 @@ internal class UserBackendService(
appId: String,
aliasLabel: String,
aliasValue: String,
+ jwt: String?,
): CreateUserResponse {
- val response = _httpClient.get("apps/$appId/users/by/$aliasLabel/$aliasValue")
+ val response = _httpClient.get("apps/$appId/users/by/$aliasLabel/$aliasValue", OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt
index a9f42bcfe1..22442072e5 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt
@@ -52,6 +52,7 @@ class RebuildUserService(
CreateSubscriptionOperation(
appId,
onesignalId,
+ identityModel.externalId,
pushSubscription.id,
pushSubscription.type,
pushSubscription.optedIn,
@@ -60,7 +61,7 @@ class RebuildUserService(
),
)
}
- operations.add(RefreshUserOperation(appId, onesignalId))
+ operations.add(RefreshUserOperation(appId, onesignalId, identityModel.externalId))
return operations
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt
index 92474635ab..8c624f1f76 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt
@@ -20,5 +20,6 @@ interface ICustomEventBackendService {
eventName: String,
eventProperties: String?,
metadata: CustomEventMetadata,
+ jwt: String? = null,
): ExecutionResponse
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt
index 096fa67456..b6ff7e7033 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt
@@ -3,6 +3,7 @@ package com.onesignal.user.internal.customEvents.impl
import com.onesignal.common.DateUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.http.IHttpClient
+import com.onesignal.core.internal.http.impl.OptionalHeaders
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
@@ -21,6 +22,7 @@ internal class CustomEventBackendService(
eventName: String,
eventProperties: String?,
metadata: CustomEventMetadata,
+ jwt: String?,
): ExecutionResponse {
val body = JSONObject()
body.put("name", eventName)
@@ -42,7 +44,7 @@ internal class CustomEventBackendService(
body.put("payload", payload)
val jsonObject = JSONObject().put("events", JSONArray().put(body))
- val response = httpClient.post("apps/$appId/custom_events", jsonObject)
+ val response = httpClient.post("apps/$appId/custom_events", jsonObject, OptionalHeaders(jwt = jwt))
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt
new file mode 100644
index 0000000000..4126029005
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt
@@ -0,0 +1,12 @@
+package com.onesignal.user.internal.jwt
+
+/**
+ * SDK-internal notifications from [JwtTokenStore] about JWT state changes for an [externalId].
+ * Fires when a JWT is added/refreshed via `putJwt` or stale entries are pruned. The
+ * developer-facing 401-invalidation event is delivered separately via
+ * [com.onesignal.IUserJwtInvalidatedListener] (see [JwtTokenStore.addUserJwtInvalidatedListener]).
+ */
+interface IJwtUpdateListener {
+ /** Fired when a JWT was added or refreshed (`putJwt`), or when stale entries are pruned. */
+ fun onJwtUpdated(externalId: String)
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtRequirement.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtRequirement.kt
new file mode 100644
index 0000000000..3f21c4c0f4
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtRequirement.kt
@@ -0,0 +1,31 @@
+package com.onesignal.user.internal.jwt
+
+/**
+ * Customer-side JWT requirement, mirrored from the backend `jwt_required` remote param.
+ * Explicit [UNKNOWN] so callers can distinguish pre-HYDRATE (no value yet) from
+ * [NOT_REQUIRED] (customer opted out).
+ *
+ * Represents only the customer-config side of Identity Verification; do not confuse
+ * with [com.onesignal.core.internal.features.FeatureFlag.SDK_IDENTITY_VERIFICATION] (our
+ * SDK-side rollout switch).
+ */
+internal enum class JwtRequirement {
+ /** Remote params have not been fetched yet. Treat as non-IV until known. */
+ UNKNOWN,
+
+ /** Customer config `jwt_required=false`. No JWT signing. */
+ NOT_REQUIRED,
+
+ /** Customer config `jwt_required=true`. IV-specific behavior active. */
+ REQUIRED,
+ ;
+
+ companion object {
+ fun fromBoolean(value: Boolean?): JwtRequirement =
+ when (value) {
+ null -> UNKNOWN
+ false -> NOT_REQUIRED
+ true -> REQUIRED
+ }
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt
new file mode 100644
index 0000000000..6e53c4a045
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt
@@ -0,0 +1,147 @@
+package com.onesignal.user.internal.jwt
+
+import com.onesignal.IUserJwtInvalidatedListener
+import com.onesignal.UserJwtInvalidatedEvent
+import com.onesignal.common.events.EventProducer
+import com.onesignal.common.threading.OneSignalDispatchers
+import com.onesignal.core.internal.preferences.IPreferencesService
+import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
+import com.onesignal.core.internal.preferences.PreferenceStores
+import com.onesignal.debug.internal.logging.Logging
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * Persistent store mapping externalId -> JWT. Multi-user so ops queued under a previous user
+ * can still resolve their JWT at execution time. Storage is unconditional; *usage* of JWTs is
+ * gated on `IdentityVerificationService.ivBehaviorActive`.
+ *
+ * Notifies two distinct audiences on JWT changes:
+ * - SDK-internal subscribers via [IJwtUpdateListener] ([addInternalUpdateListener]).
+ * - Developer-facing subscribers via [IUserJwtInvalidatedListener]
+ * ([addUserJwtInvalidatedListener]). Pure pub/sub: only listeners subscribed at the time
+ * of [invalidateJwt] receive the event. Matches iOS — no buffering for late subscribers.
+ */
+class JwtTokenStore(
+ private val _prefs: IPreferencesService,
+) {
+ private val tokens: MutableMap = mutableMapOf()
+ private var isLoaded: Boolean = false
+ private val internalUpdateListeners = EventProducer()
+ private val publicInvalidatedListeners = EventProducer()
+
+ fun addInternalUpdateListener(listener: IJwtUpdateListener) = internalUpdateListeners.subscribe(listener)
+
+ fun removeInternalUpdateListener(listener: IJwtUpdateListener) = internalUpdateListeners.unsubscribe(listener)
+
+ fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
+ publicInvalidatedListeners.subscribe(listener)
+
+ fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
+ publicInvalidatedListeners.unsubscribe(listener)
+
+ fun getJwt(externalId: String): String? {
+ synchronized(tokens) {
+ ensureLoaded()
+ return tokens[externalId]
+ }
+ }
+
+ /** Null [jwt] is a no-op; call [invalidateJwt] to remove a token. */
+ fun putJwt(
+ externalId: String,
+ jwt: String?,
+ ) {
+ if (jwt == null) return
+ val changed: Boolean
+ synchronized(tokens) {
+ ensureLoaded()
+ changed = tokens[externalId] != jwt
+ tokens[externalId] = jwt
+ if (changed) {
+ persist()
+ }
+ }
+ if (changed) {
+ internalUpdateListeners.fire { it.onJwtUpdated(externalId) }
+ }
+ }
+
+ /**
+ * Removes the JWT for [externalId] and notifies developer-facing subscribers via
+ * [IUserJwtInvalidatedListener]. Surfaced to the developer as "your JWT is no
+ * longer valid; please refresh." Don't call from internal cleanup paths (logout, user
+ * switch) — use a different mechanism if you need to clear without notifying the app.
+ */
+ fun invalidateJwt(externalId: String) {
+ val existed: Boolean
+ synchronized(tokens) {
+ ensureLoaded()
+ existed = tokens.remove(externalId) != null
+ if (existed) {
+ persist()
+ }
+ }
+ if (existed) {
+ // Dispatch developer-facing event on a background thread so the SDK's internal
+ // thread (op-repo / HYDRATE paths) doesn't run app code synchronously.
+ // Per-subscriber try/catch so one throwing listener doesn't break others or
+ // propagate up into the operation queue (would otherwise drop the failing op).
+ OneSignalDispatchers.launchOnDefault {
+ publicInvalidatedListeners.fire { listener ->
+ runCatching { listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) }
+ .onFailure { ex ->
+ Logging.warn("JwtTokenStore: IUserJwtInvalidatedListener threw for externalId=$externalId", ex)
+ }
+ }
+ }
+ }
+ }
+
+ /** Drops JWTs whose externalId isn't in [activeIds]. Call on cold start to bound growth. */
+ fun pruneToExternalIds(activeIds: Set) {
+ val removed: Set
+ synchronized(tokens) {
+ ensureLoaded()
+ val toRemove = tokens.keys - activeIds
+ removed = toRemove.toSet()
+ if (removed.isNotEmpty()) {
+ tokens.keys.removeAll(removed)
+ persist()
+ }
+ }
+ for (externalId in removed) {
+ internalUpdateListeners.fire { it.onJwtUpdated(externalId) }
+ }
+ }
+
+ /** Caller must hold `synchronized(tokens)`. */
+ private fun ensureLoaded() {
+ if (isLoaded) return
+ val json =
+ _prefs.getString(
+ PreferenceStores.ONESIGNAL,
+ PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
+ )
+ if (json != null) {
+ try {
+ val obj = JSONObject(json)
+ for (key in obj.keys()) {
+ tokens[key] = obj.getString(key)
+ }
+ } catch (e: JSONException) {
+ Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh: ${e.message}")
+ }
+ }
+ isLoaded = true
+ }
+
+ /** Caller must hold `synchronized(tokens)`. */
+ private fun persist() {
+ _prefs.saveString(
+ PreferenceStores.ONESIGNAL,
+ PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
+ JSONObject(tokens.toMap()).toString(),
+ )
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt
index 35484f670d..c1335a1b25 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt
@@ -88,9 +88,10 @@ class CreateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.CR
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.subscriptionId = subscriptionId
this.type = type
this.enabled = enabled
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt
index 1595a6de2b..cfb1bf0bac 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt
@@ -43,9 +43,10 @@ class DeleteAliasOperation() : Operation(IdentityOperationExecutor.DELETE_ALIAS)
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, label: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, label: String) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.label = label
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt
index 14c9aee448..8a5cc6bb52 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt
@@ -44,9 +44,10 @@ class DeleteSubscriptionOperation() : Operation(SubscriptionOperationExecutor.DE
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId)
override val applyToRecordId: String get() = subscriptionId
- constructor(appId: String, onesignalId: String, subscriptionId: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.subscriptionId = subscriptionId
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt
index f88ae3c568..4819c3248f 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt
@@ -44,9 +44,10 @@ class DeleteTagOperation() : Operation(UpdateUserOperationExecutor.DELETE_TAG) {
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, key: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, key: String) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.key = key
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt
index 9597283f75..65b26765ce 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt
@@ -28,7 +28,8 @@ class LoginUserFromSubscriptionOperation() : Operation(LoginUserFromSubscription
}
/**
- * The optional external ID of this newly logged-in user. Must be unique for the [appId].
+ * The subscription ID used to look up the user to log in as. Typically a v4 player ID being
+ * migrated to v5.
*/
var subscriptionId: String
get() = getStringProperty(::subscriptionId.name)
@@ -42,9 +43,10 @@ class LoginUserFromSubscriptionOperation() : Operation(LoginUserFromSubscription
override val canStartExecute: Boolean = true
override val applyToRecordId: String get() = subscriptionId
- constructor(appId: String, onesignalId: String, subscriptionId: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.subscriptionId = subscriptionId
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt
index ca2f979450..54a20d03f6 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt
@@ -32,15 +32,6 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) {
setStringProperty(::onesignalId.name, value)
}
- /**
- * The optional external ID of this newly logged-in user. Must be unique for the [appId].
- */
- var externalId: String?
- get() = getOptStringProperty(::externalId.name)
- private set(value) {
- setOptStringProperty(::externalId.name, value)
- }
-
/**
* The user ID of an existing user the [externalId] will be attempted to be associated to first.
* When null (or non-null but unsuccessful), a new user will be upserted. This ID *may* be locally generated
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt
index 953cbe7b9c..5f57cbac79 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt
@@ -35,9 +35,10 @@ class RefreshUserOperation() : Operation(RefreshUserOperationExecutor.REFRESH_US
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
}
override fun translateIds(map: Map) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt
index c88c23e46f..3bf5fd678b 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt
@@ -53,9 +53,10 @@ class SetAliasOperation() : Operation(IdentityOperationExecutor.SET_ALIAS) {
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, label: String, value: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, label: String, value: String) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.label = label
this.value = value
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt
index 2aff9c174a..0be614b9eb 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt
@@ -52,9 +52,10 @@ class SetPropertyOperation() : Operation(UpdateUserOperationExecutor.SET_PROPERT
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, property: String, value: Any?) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, property: String, value: Any?) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.property = property
this.value = value
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt
index 88bfa06eda..505b79506c 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt
@@ -53,9 +53,10 @@ class SetTagOperation() : Operation(UpdateUserOperationExecutor.SET_TAG) {
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, key: String, value: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, key: String, value: String) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.key = key
this.value = value
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt
index b510a4fd3f..04956e1877 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt
@@ -30,15 +30,6 @@ class TrackCustomEventOperation() : Operation(CustomEventOperationExecutor.CUSTO
setStringProperty(::onesignalId.name, value)
}
- /**
- * The optional external ID of current logged-in user. Must be unique for the [appId].
- */
- var externalId: String?
- get() = getOptStringProperty(::externalId.name)
- private set(value) {
- setOptStringProperty(::externalId.name, value)
- }
-
/**
* The timestamp when the custom event was created.
*/
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt
index 78da8cfb0a..7abb7170be 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt
@@ -65,9 +65,10 @@ class TrackPurchaseOperation() : Operation(UpdateUserOperationExecutor.TRACK_PUR
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, treatNewAsExisting: Boolean, amountSpent: BigDecimal, purchases: List) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, treatNewAsExisting: Boolean, amountSpent: BigDecimal, purchases: List) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.treatNewAsExisting = treatNewAsExisting
this.amountSpent = amountSpent
this.purchases = purchases
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt
index 940051e91b..c0dfce68cc 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt
@@ -43,9 +43,10 @@ class TrackSessionEndOperation() : Operation(UpdateUserOperationExecutor.TRACK_S
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String, sessionTime: Long) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, sessionTime: Long) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.sessionTime = sessionTime
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt
index e5b9e0f29c..5b1285f5f7 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt
@@ -34,9 +34,10 @@ class TrackSessionStartOperation() : Operation(UpdateUserOperationExecutor.TRACK
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId)
override val applyToRecordId: String get() = onesignalId
- constructor(appId: String, onesignalId: String) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
}
override fun translateIds(map: Map) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt
index 54aa3bae27..c0d227260e 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt
@@ -50,10 +50,11 @@ class TransferSubscriptionOperation() : Operation(SubscriptionOperationExecutor.
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId)
override val applyToRecordId: String get() = subscriptionId
- constructor(appId: String, subscriptionId: String, onesignalId: String) : this() {
+ constructor(appId: String, subscriptionId: String, onesignalId: String, externalId: String?) : this() {
this.appId = appId
this.subscriptionId = subscriptionId
this.onesignalId = onesignalId
+ this.externalId = externalId
}
override fun translateIds(map: Map) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt
index 57f17a29f5..51ea11282b 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt
@@ -87,9 +87,10 @@ class UpdateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.UP
override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId)
override val applyToRecordId: String get() = subscriptionId
- constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() {
+ constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() {
this.appId = appId
this.onesignalId = onesignalId
+ this.externalId = externalId
this.subscriptionId = subscriptionId
this.type = type
this.enabled = enabled
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt
index 2e1046e6c6..03e1265c50 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt
@@ -6,6 +6,7 @@ import com.onesignal.common.NetworkUtils
import com.onesignal.common.OneSignalUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.application.IApplicationService
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.device.IDeviceService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
@@ -13,12 +14,15 @@ import com.onesignal.core.internal.operations.IOperationExecutor
import com.onesignal.core.internal.operations.Operation
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.TrackCustomEventOperation
internal class CustomEventOperationExecutor(
private val customEventBackendService: ICustomEventBackendService,
private val applicationService: IApplicationService,
private val deviceService: IDeviceService,
+ private val jwtTokenStore: JwtTokenStore,
+ private val identityVerificationService: IdentityVerificationService,
) : IOperationExecutor {
override val operations: List
get() = listOf(CUSTOM_EVENT)
@@ -40,6 +44,7 @@ internal class CustomEventOperationExecutor(
try {
when (operation) {
is TrackCustomEventOperation -> {
+ val jwt = resolveJwt(operation, jwtTokenStore, identityVerificationService)
customEventBackendService.sendCustomEvent(
operation.appId,
operation.onesignalId,
@@ -48,6 +53,7 @@ internal class CustomEventOperationExecutor(
operation.eventName,
operation.eventProperties,
eventMetadataJson,
+ jwt,
)
}
}
@@ -57,6 +63,8 @@ internal class CustomEventOperationExecutor(
return when (responseType) {
NetworkUtils.ResponseStatusType.RETRYABLE ->
ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds)
+ NetworkUtils.ResponseStatusType.UNAUTHORIZED ->
+ ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds)
else ->
ExecutionResponse(ExecutionResult.FAIL_NORETRY)
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/ExecutorsIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/ExecutorsIvExtensions.kt
new file mode 100644
index 0000000000..4eb85e02ce
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/ExecutorsIvExtensions.kt
@@ -0,0 +1,117 @@
+package com.onesignal.user.internal.operations.impl.executors
+
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
+import com.onesignal.core.internal.operations.Operation
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.backend.IdentityConstants
+import com.onesignal.user.internal.jwt.JwtTokenStore
+
+/**
+ * IV-specific parameter resolution for operation executors. Base-class call sites dispatch via
+ * `if (newCodePathsRun) resolveIvBackendParams(...) else IvBackendParams.legacyFor(...)`;
+ * the inner `ivBehaviorActive` check keeps Phase 3 users (new code path on, IV behavior off)
+ * on legacy alias/jwt values so they exercise the dispatch without any behavioral change.
+ *
+ * `ivBehaviorActive` is passed in by the executor (read from the injected
+ * `IdentityVerificationService`) — extension functions are not classes and cannot inject.
+ */
+
+/** Alias + JWT triplet passed into backend calls. */
+internal data class IvBackendParams(
+ val aliasLabel: String,
+ val aliasValue: String,
+ val jwt: String?,
+) {
+ companion object {
+ /** Values used when the new code path is off or IV behavior is inactive. Byte-for-byte identical to pre-IV behavior. */
+ fun legacyFor(onesignalId: String) = IvBackendParams(IdentityConstants.ONESIGNAL_ID, onesignalId, null)
+ }
+}
+
+/**
+ * Combined outer + inner gate for executors that just want "the right alias/JWT for this op."
+ * Replaces the call-site `if (newCodePathsRun) resolveIvBackendParams(...) else legacyFor(...)`
+ * boilerplate. Phase 1 (newCodePathsRun=false) skips the new code path entirely and returns
+ * legacy values directly, preserving the rollout-safety property that Phase 1 traffic does not
+ * exercise the IV resolution code.
+ */
+internal fun resolveBackendParams(
+ op: Operation,
+ onesignalId: String,
+ jwtTokenStore: JwtTokenStore,
+ identityVerificationService: IdentityVerificationService,
+): IvBackendParams =
+ if (identityVerificationService.newCodePathsRun) {
+ resolveIvBackendParams(op, onesignalId, jwtTokenStore, identityVerificationService.ivBehaviorActive)
+ } else {
+ IvBackendParams.legacyFor(onesignalId)
+ }
+
+/**
+ * Resolves alias + JWT for a backend call. Intended to be called only when
+ * `newCodePathsRun` is true; base-class dispatch handles the outer gate. Most executors should
+ * call [resolveBackendParams] which encapsulates the outer gate.
+ *
+ * - IV behavior inactive (Phase 3) → legacy values; no alias switch, no JWT attach.
+ * - IV behavior active + op has externalId → `external_id` alias + JWT from [JwtTokenStore].
+ * Note: `hasValidJwtIfRequired` already keeps anon ops and missing-JWT ops out of dispatch while
+ * IV is active, so the null-externalId / missing-JWT branches below are defensive only.
+ *
+ * [onesignalId] is passed explicitly because the base [Operation] class doesn't expose it;
+ * concrete executors all have their own onesignalId field on the operation they're about to send.
+ */
+internal fun resolveIvBackendParams(
+ op: Operation,
+ onesignalId: String,
+ jwtTokenStore: JwtTokenStore,
+ ivBehaviorActive: Boolean,
+): IvBackendParams {
+ if (!ivBehaviorActive) return IvBackendParams.legacyFor(onesignalId)
+ val externalId = op.externalId
+ if (externalId == null) {
+ Logging.error("IV active but op has null externalId; falling back to onesignal_id")
+ return IvBackendParams.legacyFor(onesignalId)
+ }
+ return IvBackendParams(IdentityConstants.EXTERNAL_ID, externalId, jwtTokenStore.getJwt(externalId))
+}
+
+/**
+ * Combined outer + inner gate for executors that need only a JWT (no alias switch). Replaces
+ * the call-site `if (newCodePathsRun) resolveIvJwt(...) else null` boilerplate. Phase 1
+ * (newCodePathsRun=false) skips the new code path entirely and returns null directly,
+ * preserving the rollout-safety property that Phase 1 traffic does not exercise IV resolution.
+ */
+internal fun resolveJwt(
+ op: Operation,
+ jwtTokenStore: JwtTokenStore,
+ identityVerificationService: IdentityVerificationService,
+): String? =
+ if (identityVerificationService.newCodePathsRun) {
+ resolveIvJwt(op, jwtTokenStore, identityVerificationService.ivBehaviorActive)
+ } else {
+ null
+ }
+
+/**
+ * Resolves a JWT-only parameter (used by endpoints that don't take alias label/value, e.g.
+ * subscription update/delete, custom events). Same inner gating as [resolveIvBackendParams].
+ * Intended to be called only when `newCodePathsRun` is true; most executors should call
+ * [resolveJwt] which encapsulates the outer gate.
+ */
+internal fun resolveIvJwt(
+ op: Operation,
+ jwtTokenStore: JwtTokenStore,
+ ivBehaviorActive: Boolean,
+): String? {
+ if (!ivBehaviorActive) return null
+ val externalId = op.externalId ?: return null
+ return jwtTokenStore.getJwt(externalId)
+}
+
+/**
+ * LoginUserFromSubscription path is blocked under IV — the backend endpoint the v4→v5 upgrade path
+ * relies on is not allowed when `jwt_required == true`. Returns `true` when the executor should
+ * short-circuit to `FAIL_NORETRY`. Called only when `newCodePathsRun` is true; Phase 3 users
+ * (behavior inactive) fall through and use the legacy migration path.
+ */
+internal fun shouldFailLoginUserFromSubscription(ivBehaviorActive: Boolean): Boolean = ivBehaviorActive
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt
index 104fe9569f..424690bb36 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt
@@ -3,15 +3,16 @@ package com.onesignal.user.internal.operations.impl.executors
import com.onesignal.common.NetworkUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.IOperationExecutor
import com.onesignal.core.internal.operations.Operation
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.backend.IIdentityBackendService
-import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.DeleteAliasOperation
import com.onesignal.user.internal.operations.SetAliasOperation
import com.onesignal.user.internal.operations.impl.states.NewRecordsState
@@ -21,6 +22,8 @@ internal class IdentityOperationExecutor(
private val _identityModelStore: IdentityModelStore,
private val _buildUserService: IRebuildUserService,
private val _newRecordState: NewRecordsState,
+ private val _jwtTokenStore: JwtTokenStore,
+ private val _identityVerificationService: IdentityVerificationService,
) : IOperationExecutor {
override val operations: List
get() = listOf(SET_ALIAS, DELETE_ALIAS)
@@ -44,12 +47,14 @@ internal class IdentityOperationExecutor(
val lastOperation = operations.last()
if (lastOperation is SetAliasOperation) {
+ val params = resolveBackendParams(lastOperation, lastOperation.onesignalId, _jwtTokenStore, _identityVerificationService)
try {
_identityBackend.setAlias(
lastOperation.appId,
- IdentityConstants.ONESIGNAL_ID,
- lastOperation.onesignalId,
+ params.aliasLabel,
+ params.aliasValue,
mapOf(lastOperation.label to lastOperation.value),
+ params.jwt,
)
// ensure the now created alias is in the model as long as the user is still current.
@@ -87,12 +92,14 @@ internal class IdentityOperationExecutor(
}
}
} else if (lastOperation is DeleteAliasOperation) {
+ val params = resolveBackendParams(lastOperation, lastOperation.onesignalId, _jwtTokenStore, _identityVerificationService)
try {
_identityBackend.deleteAlias(
lastOperation.appId,
- IdentityConstants.ONESIGNAL_ID,
- lastOperation.onesignalId,
+ params.aliasLabel,
+ params.aliasValue,
lastOperation.label,
+ params.jwt,
)
// ensure the now deleted alias is not in the model as long as the user is still current.
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt
index 84093eeccb..76d5551854 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt
@@ -3,6 +3,7 @@ package com.onesignal.user.internal.operations.impl.executors
import com.onesignal.common.NetworkUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.IOperationExecutor
@@ -20,6 +21,7 @@ internal class LoginUserFromSubscriptionOperationExecutor(
private val _subscriptionBackend: ISubscriptionBackendService,
private val _identityModelStore: IdentityModelStore,
private val _propertiesModelStore: PropertiesModelStore,
+ private val _identityVerificationService: IdentityVerificationService,
) : IOperationExecutor {
override val operations: List
get() = listOf(LOGIN_USER_FROM_SUBSCRIPTION_USER)
@@ -27,6 +29,13 @@ internal class LoginUserFromSubscriptionOperationExecutor(
override suspend fun execute(operations: List): ExecutionResponse {
Logging.debug("LoginUserFromSubscriptionOperationExecutor(operation: $operations)")
+ // The getIdentityFromSubscription endpoint isn't allowed when `jwt_required == true`; the
+ // v4→v5 migration path this executor exists for only applies to non-IV apps. Drop on IV.
+ if (_identityVerificationService.newCodePathsRun && shouldFailLoginUserFromSubscription(_identityVerificationService.ivBehaviorActive)) {
+ Logging.warn("LoginUserFromSubscriptionOperation is not supported when identity verification is enabled. Dropping.")
+ return ExecutionResponse(ExecutionResult.FAIL_NORETRY)
+ }
+
if (operations.size > 1) {
throw Exception("Only supports one operation! Attempted operations:\n$operations")
}
@@ -74,7 +83,7 @@ internal class LoginUserFromSubscriptionOperationExecutor(
return ExecutionResponse(
ExecutionResult.SUCCESS,
idTranslations,
- listOf(RefreshUserOperation(loginUserOp.appId, backendOneSignalId)),
+ listOf(RefreshUserOperation(loginUserOp.appId, backendOneSignalId, loginUserOp.externalId)),
)
} catch (ex: BackendException) {
val responseType = NetworkUtils.getResponseStatusType(ex.statusCode)
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt
index 46968b3e71..0d9e975f0f 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt
@@ -8,10 +8,13 @@ import com.onesignal.common.NetworkUtils
import com.onesignal.common.OneSignalUtils
import com.onesignal.common.RootToolsInternalMethods
import com.onesignal.common.TimeUtils
+import com.onesignal.common.consistency.enums.IamFetchRywTokenKey
+import com.onesignal.common.consistency.models.IConsistencyManager
import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.device.IDeviceService
import com.onesignal.core.internal.language.ILanguageContext
import com.onesignal.core.internal.operations.ExecutionResponse
@@ -24,6 +27,7 @@ import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.backend.SubscriptionObject
import com.onesignal.user.internal.backend.SubscriptionObjectType
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.CreateSubscriptionOperation
import com.onesignal.user.internal.operations.DeleteSubscriptionOperation
import com.onesignal.user.internal.operations.LoginUserOperation
@@ -47,6 +51,9 @@ internal class LoginUserOperationExecutor(
private val _subscriptionsModelStore: SubscriptionModelStore,
private val _configModelStore: ConfigModelStore,
private val _languageContext: ILanguageContext,
+ private val _jwtTokenStore: JwtTokenStore,
+ private val _identityVerificationService: IdentityVerificationService,
+ private val _consistencyManager: IConsistencyManager,
) : IOperationExecutor {
override val operations: List
get() = listOf(LOGIN_USER)
@@ -74,10 +81,18 @@ internal class LoginUserOperationExecutor(
if (!containsSubscriptionOperation && loginUserOp.externalId == null) {
return ExecutionResponse(ExecutionResult.FAIL_NORETRY)
}
- if (loginUserOp.existingOnesignalId == null || loginUserOp.externalId == null) {
+ if (loginUserOp.existingOnesignalId == null || loginUserOp.externalId == null ||
+ _identityVerificationService.ivBehaviorActive
+ ) {
// When there is no existing user to attempt to associate with the externalId provided, we go right to
// createUser. If there is no externalId provided this is an insert, if there is this will be an
// "upsert with retrieval" as the user may already exist.
+ //
+ // Under IV, also skip the optimistic SetAliasOperation: that inline op identifies the
+ // target user by `onesignal_id = existingOnesignalId`, but IV's alias-resolution would
+ // rewrite the call to identify by the new (not-yet-registered) `external_id`, producing
+ // a 404 or idempotent success against the wrong user. createUser's identities-map path
+ // handles the merge correctly through backend upsert semantics.
return createUser(loginUserOp, operations)
} else {
// before we create a user we attempt to associate the user defined by existingOnesignalId with the
@@ -89,6 +104,7 @@ internal class LoginUserOperationExecutor(
SetAliasOperation(
loginUserOp.appId,
loginUserOp.existingOnesignalId!!,
+ loginUserOp.externalId,
IdentityConstants.EXTERNAL_ID,
loginUserOp.externalId!!,
),
@@ -168,7 +184,9 @@ internal class LoginUserOperationExecutor(
try {
val subscriptionList = subscriptions.toList()
- val response = _userBackend.createUser(createUserOperation.appId, identities, subscriptionList.map { it.second }, properties)
+ val jwt = resolveJwt(createUserOperation, _jwtTokenStore, _identityVerificationService)
+ val response =
+ _userBackend.createUser(createUserOperation.appId, identities, subscriptionList.map { it.second }, properties, jwt)
val idTranslations = mutableMapOf()
// Add the "local-to-backend" ID translation to the IdentifierTranslator for any operations that were
// *not* executed but still reference the locally-generated IDs.
@@ -220,10 +238,20 @@ internal class LoginUserOperationExecutor(
backendSubscriptions.remove(backendSubscription)
}
+ // Forward the create-user RYW data to ConsistencyManager so InAppMessagesManager
+ // can fetch IAMs once the user record has propagated. Gated on newCodePathsRun:
+ // Phase 1 users don't await RYW (legacy IAM fetch path), so storing it would be
+ // a no-op anyway. Backend only sends rywData under IV.
+ if (_identityVerificationService.newCodePathsRun) {
+ response.rywData?.let { rywData ->
+ _consistencyManager.setRywData(backendOneSignalId, IamFetchRywTokenKey.USER, rywData)
+ }
+ }
+
val wasPossiblyAnUpsert = identities.isNotEmpty()
val followUpOperations =
if (wasPossiblyAnUpsert) {
- listOf(RefreshUserOperation(createUserOperation.appId, backendOneSignalId))
+ listOf(RefreshUserOperation(createUserOperation.appId, backendOneSignalId, createUserOperation.externalId))
} else {
null
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt
index d7bfa0f671..3d3784b0db 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt
@@ -5,6 +5,7 @@ import com.onesignal.common.TimeUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.IOperationExecutor
@@ -12,11 +13,11 @@ import com.onesignal.core.internal.operations.Operation
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.backend.IUserBackendService
-import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.backend.SubscriptionObjectType
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.identity.IdentityModel
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.RefreshUserOperation
import com.onesignal.user.internal.operations.impl.states.NewRecordsState
import com.onesignal.user.internal.properties.PropertiesModel
@@ -34,6 +35,8 @@ internal class RefreshUserOperationExecutor(
private val _configModelStore: ConfigModelStore,
private val _buildUserService: IRebuildUserService,
private val _newRecordState: NewRecordsState,
+ private val _jwtTokenStore: JwtTokenStore,
+ private val _identityVerificationService: IdentityVerificationService,
) : IOperationExecutor {
override val operations: List
get() = listOf(REFRESH_USER)
@@ -54,12 +57,14 @@ internal class RefreshUserOperationExecutor(
}
private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse {
+ val params = resolveBackendParams(op, op.onesignalId, _jwtTokenStore, _identityVerificationService)
try {
val response =
_userBackend.getUser(
op.appId,
- IdentityConstants.ONESIGNAL_ID,
- op.onesignalId,
+ params.aliasLabel,
+ params.aliasValue,
+ params.jwt,
)
if (op.onesignalId != _identityModelStore.model.onesignalId) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt
index 81ab0bb687..3d240636a5 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt
@@ -14,6 +14,7 @@ import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.device.IDeviceService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
@@ -22,10 +23,10 @@ import com.onesignal.core.internal.operations.Operation
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.backend.ISubscriptionBackendService
-import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.backend.SubscriptionObject
import com.onesignal.user.internal.backend.SubscriptionObjectType
import com.onesignal.user.internal.builduser.IRebuildUserService
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.CreateSubscriptionOperation
import com.onesignal.user.internal.operations.DeleteSubscriptionOperation
import com.onesignal.user.internal.operations.TransferSubscriptionOperation
@@ -44,6 +45,8 @@ internal class SubscriptionOperationExecutor(
private val _buildUserService: IRebuildUserService,
private val _newRecordState: NewRecordsState,
private val _consistencyManager: IConsistencyManager,
+ private val _jwtTokenStore: JwtTokenStore,
+ private val _identityVerificationService: IdentityVerificationService,
) : IOperationExecutor {
override val operations: List
get() = listOf(CREATE_SUBSCRIPTION, UPDATE_SUBSCRIPTION, DELETE_SUBSCRIPTION, TRANSFER_SUBSCRIPTION)
@@ -107,12 +110,14 @@ internal class SubscriptionOperationExecutor(
AndroidUtils.getAppVersion(_applicationService.appContext),
)
+ val params = resolveBackendParams(createOperation, createOperation.onesignalId, _jwtTokenStore, _identityVerificationService)
val result =
_subscriptionBackend.createSubscription(
createOperation.appId,
- IdentityConstants.ONESIGNAL_ID,
- createOperation.onesignalId,
+ params.aliasLabel,
+ params.aliasValue,
subscription,
+ params.jwt,
) ?: return ExecutionResponse(ExecutionResult.SUCCESS)
val backendSubscriptionId = result.first
@@ -190,7 +195,8 @@ internal class SubscriptionOperationExecutor(
AndroidUtils.getAppVersion(_applicationService.appContext),
)
- val rywData = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription)
+ val jwt = resolveJwt(lastOperation, _jwtTokenStore, _identityVerificationService)
+ val rywData = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription, jwt)
if (rywData != null) {
_consistencyManager.setRywData(startingOperation.onesignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywData)
@@ -203,6 +209,8 @@ internal class SubscriptionOperationExecutor(
return when (responseType) {
NetworkUtils.ResponseStatusType.RETRYABLE ->
ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds)
+ NetworkUtils.ResponseStatusType.UNAUTHORIZED ->
+ ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds)
NetworkUtils.ResponseStatusType.MISSING -> {
if (ex.statusCode == 404 &&
listOf(
@@ -220,6 +228,7 @@ internal class SubscriptionOperationExecutor(
CreateSubscriptionOperation(
lastOperation.appId,
lastOperation.onesignalId,
+ lastOperation.externalId,
lastOperation.subscriptionId,
lastOperation.type,
lastOperation.enabled,
@@ -239,12 +248,14 @@ internal class 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
private suspend fun transferSubscription(startingOperation: TransferSubscriptionOperation): ExecutionResponse {
+ val params = resolveBackendParams(startingOperation, startingOperation.onesignalId, _jwtTokenStore, _identityVerificationService)
try {
_subscriptionBackend.transferSubscription(
startingOperation.appId,
startingOperation.subscriptionId,
- IdentityConstants.ONESIGNAL_ID,
- startingOperation.onesignalId,
+ params.aliasLabel,
+ params.aliasValue,
+ params.jwt,
)
} catch (ex: BackendException) {
val responseType = NetworkUtils.getResponseStatusType(ex.statusCode)
@@ -252,6 +263,8 @@ internal class SubscriptionOperationExecutor(
return when (responseType) {
NetworkUtils.ResponseStatusType.RETRYABLE ->
ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds)
+ NetworkUtils.ResponseStatusType.UNAUTHORIZED ->
+ ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds)
else ->
ExecutionResponse(ExecutionResult.FAIL_NORETRY)
}
@@ -275,8 +288,9 @@ internal class SubscriptionOperationExecutor(
}
private suspend fun deleteSubscription(op: DeleteSubscriptionOperation): ExecutionResponse {
+ val jwt = resolveJwt(op, _jwtTokenStore, _identityVerificationService)
try {
- _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId)
+ _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId, jwt)
// remove the subscription model as a HYDRATE in case for some reason it still exists.
_subscriptionModelStore.remove(op.subscriptionId, ModelChangeTags.HYDRATE)
@@ -299,6 +313,8 @@ internal class SubscriptionOperationExecutor(
}
NetworkUtils.ResponseStatusType.RETRYABLE ->
ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds)
+ NetworkUtils.ResponseStatusType.UNAUTHORIZED ->
+ ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds)
else ->
ExecutionResponse(ExecutionResult.FAIL_NORETRY)
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt
index e529035ec1..f611c60ea5 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt
@@ -6,6 +6,7 @@ import com.onesignal.common.consistency.enums.IamFetchRywTokenKey
import com.onesignal.common.consistency.models.IConsistencyManager
import com.onesignal.common.exceptions.BackendException
import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.IOperationExecutor
@@ -13,12 +14,12 @@ import com.onesignal.core.internal.operations.Operation
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.backend.IUserBackendService
-import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.backend.PropertiesDeltasObject
import com.onesignal.user.internal.backend.PropertiesObject
import com.onesignal.user.internal.backend.PurchaseObject
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.DeleteTagOperation
import com.onesignal.user.internal.operations.SetPropertyOperation
import com.onesignal.user.internal.operations.SetTagOperation
@@ -35,6 +36,8 @@ internal class UpdateUserOperationExecutor(
private val _buildUserService: IRebuildUserService,
private val _newRecordState: NewRecordsState,
private val _consistencyManager: IConsistencyManager,
+ private val _jwtTokenStore: JwtTokenStore,
+ private val _identityVerificationService: IdentityVerificationService,
) : IOperationExecutor {
override val operations: List
get() = listOf(SET_TAG, DELETE_TAG, SET_PROPERTY, TRACK_SESSION_START, TRACK_SESSION_END, TRACK_PURCHASE)
@@ -137,15 +140,20 @@ internal class UpdateUserOperationExecutor(
}
if (appId != null && onesignalId != null) {
+ // Grouped update ops all belong to the same user (queue grouping is per-user), so
+ // pulling externalId from `operations.first()` is safe and represents the batch.
+ val firstOp = operations.first()
+ val params = resolveBackendParams(firstOp, onesignalId, _jwtTokenStore, _identityVerificationService)
try {
val rywData =
_userBackend.updateUser(
appId,
- IdentityConstants.ONESIGNAL_ID,
- onesignalId,
+ params.aliasLabel,
+ params.aliasValue,
propertiesObject,
refreshDeviceMetadata,
deltasObject,
+ params.jwt,
)
if (rywData != null) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt
index 90a565a5a2..6a45235f1a 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt
@@ -27,9 +27,9 @@ internal class IdentityModelStoreListener(
newValue: Any?,
): Operation {
return if (newValue != null && newValue is String) {
- SetAliasOperation(_configModelStore.model.appId, model.onesignalId, property, newValue)
+ SetAliasOperation(_configModelStore.model.appId, model.onesignalId, model.externalId, property, newValue)
} else {
- DeleteAliasOperation(_configModelStore.model.appId, model.onesignalId, property)
+ DeleteAliasOperation(_configModelStore.model.appId, model.onesignalId, model.externalId, property)
}
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt
index d020c5cc66..ca1b698df7 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt
@@ -4,6 +4,7 @@ import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.core.internal.operations.Operation
import com.onesignal.core.internal.operations.listeners.SingletonModelStoreListener
+import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.operations.DeleteTagOperation
import com.onesignal.user.internal.operations.SetPropertyOperation
import com.onesignal.user.internal.operations.SetTagOperation
@@ -14,6 +15,7 @@ internal class PropertiesModelStoreListener(
store: PropertiesModelStore,
opRepo: IOperationRepo,
private val _configModelStore: ConfigModelStore,
+ private val _identityModelStore: IdentityModelStore,
) : SingletonModelStoreListener(store, opRepo) {
override fun getReplaceOperation(model: PropertiesModel): Operation? {
// when the property model is replaced, nothing to do on the backend. Already handled via login process.
@@ -36,14 +38,15 @@ internal class PropertiesModelStoreListener(
return null
}
+ val externalId = _identityModelStore.model.externalId
if (path.startsWith(PropertiesModel::tags.name)) {
return if (newValue != null && newValue is String) {
- SetTagOperation(_configModelStore.model.appId, model.onesignalId, property, newValue)
+ SetTagOperation(_configModelStore.model.appId, model.onesignalId, externalId, property, newValue)
} else {
- DeleteTagOperation(_configModelStore.model.appId, model.onesignalId, property)
+ DeleteTagOperation(_configModelStore.model.appId, model.onesignalId, externalId, property)
}
}
- return SetPropertyOperation(_configModelStore.model.appId, model.onesignalId, property, newValue)
+ return SetPropertyOperation(_configModelStore.model.appId, model.onesignalId, externalId, property, newValue)
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt
index f0002940e9..c210193e69 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt
@@ -23,6 +23,7 @@ internal class SubscriptionModelStoreListener(
return CreateSubscriptionOperation(
_configModelStore.model.appId,
_identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
model.id,
model.type,
enabledAndStatus.first,
@@ -32,7 +33,12 @@ internal class SubscriptionModelStoreListener(
}
override fun getRemoveOperation(model: SubscriptionModel): Operation {
- return DeleteSubscriptionOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, model.id)
+ return DeleteSubscriptionOperation(
+ _configModelStore.model.appId,
+ _identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
+ model.id,
+ )
}
override fun getUpdateOperation(
@@ -46,6 +52,7 @@ internal class SubscriptionModelStoreListener(
return UpdateSubscriptionOperation(
_configModelStore.model.appId,
_identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
model.id,
model.type,
enabledAndStatus.first,
@@ -56,6 +63,13 @@ internal class SubscriptionModelStoreListener(
companion object {
fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair {
+ // Internal-disabled subscription (e.g. the post-logout anonymous user under IV)
+ // must not generate backend ops; report as disabled+unsubscribe regardless of
+ // optedIn/status. See [SubscriptionModel.isDisabledInternally].
+ if (model.isDisabledInternally) {
+ return Pair(false, SubscriptionStatus.UNSUBSCRIBE)
+ }
+
val status: SubscriptionStatus
val enabled: Boolean
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt
index 7b04d7981e..98e6f719db 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt
@@ -32,6 +32,7 @@ class UserRefreshService(
RefreshUserOperation(
_configModelStore.model.appId,
_identityModelStore.model.onesignalId,
+ _identityModelStore.model.externalId,
),
)
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt
index c7bde3aae8..a3d1bcfe0b 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt
@@ -92,6 +92,23 @@ class SubscriptionModel : Model() {
setBooleanProperty(::optedIn.name, value)
}
+ /**
+ * Internal-only flag (not surfaced via the public API) used to suppress backend
+ * subscription operations for this model. Set to `true` on logout under Identity
+ * Verification: the new device-scoped (anonymous) user can't authenticate without
+ * a JWT, so the SDK must not generate create-subscription ops for it. The
+ * [SubscriptionModelStoreListener] honors this flag by short-circuiting to
+ * `(enabled = false, status = UNSUBSCRIBE)` regardless of [optedIn] / [status].
+ *
+ * Defaults to `false`. On the next login, [com.onesignal.user.internal.UserSwitcher]
+ * creates a fresh model that does not carry this flag, restoring the real state.
+ */
+ var isDisabledInternally: Boolean
+ get() = getBooleanProperty(::isDisabledInternally.name) { false }
+ set(value) {
+ setBooleanProperty(::isDisabledInternally.name, value)
+ }
+
var type: SubscriptionType
get() = getEnumProperty(::type.name)
set(value) {
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/config/impl/IdentityVerificationServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/config/impl/IdentityVerificationServiceTests.kt
new file mode 100644
index 0000000000..0265fa0806
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/config/impl/IdentityVerificationServiceTests.kt
@@ -0,0 +1,133 @@
+package com.onesignal.core.internal.config.impl
+
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.features.FeatureFlag
+import com.onesignal.core.internal.features.IFeatureManager
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.jwt.JwtRequirement
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.verify
+
+class IdentityVerificationServiceTests : FunSpec({
+ beforeEach { Logging.logLevel = LogLevel.NONE }
+
+ fun makeService(
+ featureFlagOn: Boolean = false,
+ requirement: JwtRequirement = JwtRequirement.UNKNOWN,
+ ): Triple {
+ val featureManager = mockk()
+ every { featureManager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) } returns featureFlagOn
+
+ val configModel = mockk(relaxed = true)
+ every { configModel.useIdentityVerification } returns requirement
+
+ val configModelStore = mockk()
+ every { configModelStore.model } returns configModel
+ every { configModelStore.subscribe(any()) } just runs
+
+ val service = IdentityVerificationService(featureManager, configModelStore)
+ return Triple(service, configModel, configModelStore)
+ }
+
+ test("start subscribes to ConfigModelStore") {
+ val (service, _, configModelStore) = makeService()
+
+ service.start()
+
+ verify(exactly = 1) { configModelStore.subscribe(service) }
+ }
+
+ // --- Gate derivation -------------------------------------------------------------------
+
+ test("flag off + UNKNOWN: both gates false (safe pre-HYDRATE default)") {
+ val (service, _, _) = makeService(featureFlagOn = false, requirement = JwtRequirement.UNKNOWN)
+ service.newCodePathsRun shouldBe false
+ service.ivBehaviorActive shouldBe false
+ }
+
+ test("flag off + NOT_REQUIRED: both gates false") {
+ val (service, _, _) = makeService(featureFlagOn = false, requirement = JwtRequirement.NOT_REQUIRED)
+ service.newCodePathsRun shouldBe false
+ service.ivBehaviorActive shouldBe false
+ }
+
+ test("ERROR STATE — flag off + REQUIRED: both gates true (customer config wins)") {
+ val (service, _, _) = makeService(featureFlagOn = false, requirement = JwtRequirement.REQUIRED)
+ service.newCodePathsRun shouldBe true
+ service.ivBehaviorActive shouldBe true
+ }
+
+ test("flag on + UNKNOWN: newCodePathsRun true, ivBehaviorActive false") {
+ val (service, _, _) = makeService(featureFlagOn = true, requirement = JwtRequirement.UNKNOWN)
+ service.newCodePathsRun shouldBe true
+ service.ivBehaviorActive shouldBe false
+ }
+
+ test("flag on + NOT_REQUIRED: newCodePathsRun true, ivBehaviorActive false (Phase 3)") {
+ val (service, _, _) = makeService(featureFlagOn = true, requirement = JwtRequirement.NOT_REQUIRED)
+ service.newCodePathsRun shouldBe true
+ service.ivBehaviorActive shouldBe false
+ }
+
+ test("flag on + REQUIRED: both gates true (full IV)") {
+ val (service, _, _) = makeService(featureFlagOn = true, requirement = JwtRequirement.REQUIRED)
+ service.newCodePathsRun shouldBe true
+ service.ivBehaviorActive shouldBe true
+ }
+
+ test("gates are derived on read — config flip is reflected without explicit update") {
+ val (service, configModel, _) = makeService(featureFlagOn = false, requirement = JwtRequirement.UNKNOWN)
+ service.ivBehaviorActive shouldBe false
+
+ every { configModel.useIdentityVerification } returns JwtRequirement.REQUIRED
+
+ service.ivBehaviorActive shouldBe true
+ service.newCodePathsRun shouldBe true
+ }
+
+ // --- HYDRATE forwarding ----------------------------------------------------------------
+
+ test("HYDRATE with REQUIRED invokes registered handler with ivRequired=true") {
+ val (service, model, _) = makeService(requirement = JwtRequirement.REQUIRED)
+ var fired: Boolean? = null
+ service.setOnJwtConfigHydratedHandler { fired = it }
+
+ service.onModelReplaced(model, ModelChangeTags.HYDRATE)
+
+ fired shouldBe true
+ }
+
+ test("HYDRATE with NOT_REQUIRED invokes registered handler with ivRequired=false") {
+ val (service, model, _) = makeService(requirement = JwtRequirement.NOT_REQUIRED)
+ var fired: Boolean? = null
+ service.setOnJwtConfigHydratedHandler { fired = it }
+
+ service.onModelReplaced(model, ModelChangeTags.HYDRATE)
+
+ fired shouldBe false
+ }
+
+ test("non-HYDRATE replacement is ignored") {
+ val (service, model, _) = makeService(requirement = JwtRequirement.REQUIRED)
+ var fired: Boolean? = null
+ service.setOnJwtConfigHydratedHandler { fired = it }
+
+ service.onModelReplaced(model, ModelChangeTags.NORMAL)
+
+ fired shouldBe null
+ }
+
+ test("no handler registered: HYDRATE is a no-op (no NPE)") {
+ val (service, model, _) = makeService(requirement = JwtRequirement.REQUIRED)
+ // Don't register a handler.
+ service.onModelReplaced(model, ModelChangeTags.HYDRATE)
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt
index 54cf41b3a7..358e2ed424 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt
@@ -15,4 +15,9 @@ class FeatureFlagTests : FunSpec({
test("SDK_BACKGROUND_THREADING uses the expected remote key") {
FeatureFlag.SDK_BACKGROUND_THREADING.key shouldBe "sdk_background_threading"
}
+
+ test("SDK_IDENTITY_VERIFICATION uses the expected remote key and IMMEDIATE activation") {
+ FeatureFlag.SDK_IDENTITY_VERIFICATION.key shouldBe "sdk_identity_verification"
+ FeatureFlag.SDK_IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt
index bc6695d419..a02c1bbf76 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt
@@ -4,6 +4,7 @@ import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.common.threading.ThreadingMode
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.user.internal.jwt.JwtRequirement
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
@@ -20,6 +21,7 @@ class FeatureManagerTests : FunSpec({
fun stubConfigModel(model: ConfigModel) {
every { model.sdkRemoteFeatureFlags } returns emptyList()
every { model.sdkRemoteFeatureFlagMetadata } returns null
+ every { model.useIdentityVerification } returns JwtRequirement.UNKNOWN
}
test("initial state enables BACKGROUND_THREADING when key is present in sdk remote flags") {
@@ -188,4 +190,26 @@ class FeatureManagerTests : FunSpec({
manager.enabledFeatureKeys() shouldBe emptyList()
}
+
+ test("IDENTITY_VERIFICATION is IMMEDIATE: mid-session flag flip flows through isEnabled") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+ val manager = FeatureManager(configModelStore)
+
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe false
+
+ // Mid-session model replacement enables the flag remotely.
+ val updatedModel = mockk()
+ stubConfigModel(updatedModel)
+ every { updatedModel.sdkRemoteFeatureFlags } returns listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key)
+ every { configModelStore.model } returns updatedModel
+
+ manager.onModelReplaced(updatedModel, ModelChangeTags.HYDRATE)
+
+ // Feature flag flips in-memory because IDENTITY_VERIFICATION is IMMEDIATE.
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/HttpClientTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/HttpClientTests.kt
index 0fc991b861..93f9776b31 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/HttpClientTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/HttpClientTests.kt
@@ -255,4 +255,48 @@ class HttpClientTests : FunSpec({
response2 shouldBe null
response3 shouldNotBe null
}
+
+ test("Authorization header is set when OptionalHeaders.jwt is provided") {
+ // Given
+ val mocks = Mocks()
+
+ // When
+ mocks.httpClient.get("URL", OptionalHeaders(jwt = "abc.def.ghi"))
+ mocks.httpClient.post("URL", JSONObject(), OptionalHeaders(jwt = "abc.def.ghi"))
+
+ // Then
+ for (connection in mocks.factory.connections) {
+ connection.getRequestProperty("Authorization") shouldBe "Bearer abc.def.ghi"
+ }
+ }
+
+ test("Authorization header is NOT set when OptionalHeaders.jwt is null") {
+ // Given
+ val mocks = Mocks()
+
+ // When
+ mocks.httpClient.get("URL", OptionalHeaders(jwt = null))
+ mocks.httpClient.post("URL", JSONObject())
+
+ // Then
+ for (connection in mocks.factory.connections) {
+ connection.getRequestProperty("Authorization") shouldBe null
+ }
+ }
+
+ test("POST + JWT does not throw (headers must be set before body write)") {
+ // Given: MockHttpURLConnection now throws IllegalStateException if setRequestProperty is
+ // called after connect()/getOutputStream()/getResponseCode (matching real Android behavior).
+ // This test fails if the HttpClient regresses to setting Authorization after the body write.
+ val mocks = Mocks()
+ mocks.response.status = 200
+ mocks.response.responseBody = "{}"
+
+ // When
+ val response = mocks.httpClient.post("URL", JSONObject().put("k", "v"), OptionalHeaders(jwt = "the-jwt"))
+
+ // Then: the request completed without the mock throwing, and the header actually stuck.
+ response.throwable shouldBe null
+ mocks.factory.connections.last().getRequestProperty("Authorization") shouldBe "Bearer the-jwt"
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/MockHttpConnectionFactory.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/MockHttpConnectionFactory.kt
index 595f6fcd51..45381b774c 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/MockHttpConnectionFactory.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/http/MockHttpConnectionFactory.kt
@@ -32,6 +32,13 @@ internal class MockHttpConnectionFactory(
url: URL?,
private val mockResponse: MockResponse,
) : HttpURLConnection(url) {
+ // Real HttpURLConnection (both stock JDK and Android's OkHttp-backed impl) commits the
+ // request line + headers to the wire when `getOutputStream()` / `setFixedLengthStreamingMode`
+ // is used to stream a body, and rejects `setRequestProperty` afterward. The stock mock
+ // lets headers be set at any time, which hides header-ordering bugs. Simulate the real
+ // contract so HttpClientTests will fail fast if headers are set after the body begins.
+ private var headersCommitted: Boolean = false
+
override fun disconnect() {}
override fun usingProxy(): Boolean {
@@ -40,6 +47,14 @@ internal class MockHttpConnectionFactory(
@Throws(IOException::class)
override fun connect() {
+ headersCommitted = true
+ }
+
+ override fun setRequestProperty(key: String, value: String?) {
+ if (headersCommitted) {
+ throw IllegalStateException("Cannot set request property '$key' after connection is made")
+ }
+ super.setRequestProperty(key, value)
}
override fun getHeaderField(name: String): String? {
@@ -48,6 +63,7 @@ internal class MockHttpConnectionFactory(
@Throws(IOException::class)
override fun getResponseCode(): Int {
+ headersCommitted = true
if (mockResponse.mockRequestTime != null) {
try {
Thread.sleep(mockResponse.mockRequestTime!!)
@@ -60,6 +76,7 @@ internal class MockHttpConnectionFactory(
}
override fun getOutputStream(): OutputStream {
+ headersCommitted = true
return NullOutputStream()
}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt
index d4fcee869d..644b4f4102 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt
@@ -28,7 +28,7 @@ class OperationModelStoreTests : FunSpec({
val jsonArray = JSONArray()
// 1. Create a VALID Operation with onesignalId
- val validOperation = SetPropertyOperation(UUID.randomUUID().toString(), UUID.randomUUID().toString(), "property", "value")
+ val validOperation = SetPropertyOperation(UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, "property", "value")
validOperation.id = UUID.randomUUID().toString()
// 2. Create a VALID operation missing onesignalId
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt
index a9f7db4f57..24efeda2a1 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt
@@ -10,8 +10,11 @@ import com.onesignal.core.internal.preferences.PreferenceStores
import com.onesignal.core.internal.time.impl.Time
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.mocks.CoreInternalMocks
import com.onesignal.mocks.MockHelper
import com.onesignal.mocks.MockPreferencesService
+import com.onesignal.user.internal.jwt.JwtRequirement
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
import com.onesignal.user.internal.operations.LoginUserOperation
import io.kotest.core.spec.style.FunSpec
@@ -40,7 +43,13 @@ import java.util.UUID
// Mocks used by every test in this file
private class Mocks {
- val configModelStore = MockHelper.configModelStore()
+ val configModelStore =
+ MockHelper.configModelStore {
+ // Default to "post-HYDRATE" so the pre-HYDRATE deferral doesn't hold up
+ // tests that aren't exercising that path.
+ it.isInitializedWithRemote = true
+ it.useIdentityVerification = JwtRequirement.NOT_REQUIRED
+ }
val operationModelStore: OperationModelStore =
run {
@@ -65,6 +74,10 @@ private class Mocks {
mockExecutor
}
+ val jwtTokenStore: JwtTokenStore = JwtTokenStore(MockPreferencesService())
+
+ var identityVerificationService = CoreInternalMocks.identityVerificationService()
+
val operationRepo: OperationRepo by lazy {
spyk(
OperationRepo(
@@ -73,6 +86,8 @@ private class Mocks {
configModelStore,
Time(),
getNewRecordState(configModelStore),
+ jwtTokenStore,
+ identityVerificationService,
),
recordPrivateCalls = true,
)
@@ -98,6 +113,8 @@ class OperationRepoTests : FunSpec({
mocks.configModelStore,
Time(),
getNewRecordState(mocks.configModelStore),
+ JwtTokenStore(MockPreferencesService()),
+ CoreInternalMocks.identityVerificationService(),
),
)
@@ -982,6 +999,160 @@ class OperationRepoTests : FunSpec({
// Verify that the grouped execution happened with both operations
// We can't easily verify the exact list content with MockK, but we verified it in the execution order tracking
}
+
+ //
+ // ---- PR 3: IV queue-runtime tests ----
+ //
+
+ test("pre-HYDRATE deferral: getNextOps returns null when useIdentityVerification is UNKNOWN") {
+ val mocks = Mocks()
+ mocks.configModelStore.model.useIdentityVerification = JwtRequirement.UNKNOWN
+
+ // enqueue an op and start processing
+ mocks.operationRepo.start()
+ mocks.operationRepo.enqueue(mockOperation())
+ delay(200)
+
+ // Op should have been enqueued but not yet executed.
+ coVerify(exactly = 0) { mocks.executor.execute(any()) }
+ // getNextOps returning null does NOT remove the op from the queue.
+ mocks.operationRepo.queue.size shouldBe 1
+ }
+
+ test("post-HYDRATE resume: useIdentityVerification flipping out of UNKNOWN unblocks the queue") {
+ val mocks = Mocks()
+ mocks.configModelStore.model.useIdentityVerification = JwtRequirement.UNKNOWN
+
+ mocks.operationRepo.start()
+ mocks.operationRepo.enqueue(mockOperation())
+ delay(100)
+ coVerify(exactly = 0) { mocks.executor.execute(any()) }
+
+ // Simulate HYDRATE completing with jwt_required=false, then wake the queue.
+ mocks.configModelStore.model.useIdentityVerification = JwtRequirement.NOT_REQUIRED
+ mocks.operationRepo.forceExecuteOperations()
+ delay(500)
+
+ coVerify(exactly = 1) { mocks.executor.execute(any()) }
+ }
+
+ test("removeOperationsWithoutExternalId drops queued anon ops and persists removal") {
+ val mocks = Mocks()
+ val anonOp = mockOperation(externalId = null)
+ val identifiedOp = mockOperation(externalId = "alice")
+ val anonId = anonOp.id
+ val identifiedId = identifiedOp.id
+
+ mocks.operationRepo.enqueue(anonOp)
+ mocks.operationRepo.enqueue(identifiedOp)
+ // Let the scope.launch-ed internalEnqueues land in the queue.
+ delay(50)
+
+ mocks.operationRepo.removeOperationsWithoutExternalId()
+
+ mocks.operationRepo.queue.size shouldBe 1
+ mocks.operationRepo.queue.first().operation.externalId shouldBe "alice"
+ verify(exactly = 1) { mocks.operationModelStore.remove(anonId) }
+ verify(exactly = 0) { mocks.operationModelStore.remove(identifiedId) }
+ }
+
+ test("onJwtConfigHydrated(true) awaits init, purges anon ops, then force-executes (in order)") {
+ val mocks = Mocks()
+ coEvery { mocks.operationRepo.awaitInitialized() } just runs
+ every { mocks.operationRepo.removeOperationsWithoutExternalId() } just runs
+ every { mocks.operationRepo.forceExecuteOperations() } just runs
+
+ mocks.operationRepo.onJwtConfigHydrated(ivRequired = true)
+ // suspendifyOnIO launches on IO; allow the orchestration to land.
+ delay(100)
+
+ coVerifyOrder {
+ mocks.operationRepo.awaitInitialized()
+ mocks.operationRepo.removeOperationsWithoutExternalId()
+ mocks.operationRepo.forceExecuteOperations()
+ }
+ }
+
+ test("onJwtConfigHydrated(false) skips the purge but still force-executes") {
+ val mocks = Mocks()
+ coEvery { mocks.operationRepo.awaitInitialized() } just runs
+ every { mocks.operationRepo.removeOperationsWithoutExternalId() } just runs
+ every { mocks.operationRepo.forceExecuteOperations() } just runs
+
+ mocks.operationRepo.onJwtConfigHydrated(ivRequired = false)
+ delay(100)
+
+ coVerify(exactly = 1) { mocks.operationRepo.awaitInitialized() }
+ verify(exactly = 0) { mocks.operationRepo.removeOperationsWithoutExternalId() }
+ verify(exactly = 1) { mocks.operationRepo.forceExecuteOperations() }
+ }
+
+ test("FAIL_UNAUTHORIZED with IV active invalidates JWT, re-queues ops, and fires IUserJwtInvalidatedListener") {
+ val mocks = Mocks()
+ mocks.identityVerificationService = CoreInternalMocks.identityVerificationService(
+ newCodePathsRun = true,
+ ivBehaviorActive = true,
+ )
+ mocks.configModelStore.model.useIdentityVerification = JwtRequirement.REQUIRED
+
+ val op = mockOperation(externalId = "alice")
+ val opId = op.id
+ coEvery { mocks.executor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED)
+
+ // Pre-seed the JWT store so invalidation is observable.
+ mocks.jwtTokenStore.putJwt("alice", "stale-token")
+
+ var invalidatedId: String? = null
+ val listenerWaiter = com.onesignal.common.threading.Waiter()
+ mocks.jwtTokenStore.addUserJwtInvalidatedListener { event ->
+ invalidatedId = event.externalId
+ listenerWaiter.wake()
+ }
+
+ mocks.operationRepo.start()
+ // enqueueAndWait with failure should wake waiter with false.
+ val waitResult =
+ runBlocking {
+ withTimeout(2_000) {
+ mocks.operationRepo.enqueueAndWait(op)
+ }
+ }
+ listenerWaiter.waitForWake()
+
+ waitResult shouldBe false
+ invalidatedId shouldBe "alice"
+ mocks.jwtTokenStore.getJwt("alice") shouldBe null
+ // Op was re-queued (not dropped from store).
+ verify(exactly = 0) { mocks.operationModelStore.remove(opId) }
+ }
+
+ test("FAIL_UNAUTHORIZED with IV inactive falls back to default drop-on-fail") {
+ val mocks = Mocks()
+ mocks.identityVerificationService = CoreInternalMocks.identityVerificationService(
+ newCodePathsRun = true,
+ ivBehaviorActive = false,
+ )
+ mocks.configModelStore.model.useIdentityVerification = JwtRequirement.NOT_REQUIRED
+ val op = mockOperation(externalId = "alice")
+ val opId = op.id
+ coEvery { mocks.executor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED)
+
+ var invalidatedFired = false
+ mocks.jwtTokenStore.addUserJwtInvalidatedListener { invalidatedFired = true }
+
+ mocks.operationRepo.start()
+ val waitResult =
+ runBlocking {
+ withTimeout(2_000) {
+ mocks.operationRepo.enqueueAndWait(op)
+ }
+ }
+
+ waitResult shouldBe false
+ invalidatedFired shouldBe false
+ // Default behavior: drop the op.
+ verify(exactly = 1) { mocks.operationModelStore.remove(opId) }
+ }
}) {
companion object {
private fun mockOperation(
@@ -993,6 +1164,8 @@ class OperationRepoTests : FunSpec({
modifyComparisonKey: String = "modify-key",
operationIdSlot: CapturingSlot? = null,
applyToRecordId: String = "",
+ externalId: String? = null,
+ requiresJwt: Boolean = true,
): Operation {
val operation = mockk()
val opIdSlot = operationIdSlot ?: slot()
@@ -1006,6 +1179,8 @@ class OperationRepoTests : FunSpec({
every { operation.modifyComparisonKey } returns modifyComparisonKey
every { operation.translateIds(any()) } just runs
every { operation.applyToRecordId } returns applyToRecordId
+ every { operation.externalId } returns externalId
+ every { operation.requiresJwt } returns requiresJwt
return operation
}
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 4f9c377348..ba4e376473 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
@@ -22,14 +22,30 @@ infix fun > T.shouldHaveEachItemEndWith(expected: Array()
+ fun register(listener: ILogListener): ILogListener {
+ registered.add(listener)
+ Logging.addListener(listener)
+ return listener
+ }
+
beforeAny {
Logging.logLevel = LogLevel.NONE
}
+ afterEach {
+ registered.forEach { Logging.removeListener(it) }
+ registered.clear()
+ }
+
test("addListener") {
// Given
val listener = TestLogLister()
- Logging.addListener(listener)
+ register(listener)
// When
Logging.debug("test")
@@ -41,8 +57,8 @@ class LoggingTests : FunSpec({
test("addListener twice") {
// Given
val listener = TestLogLister()
- Logging.addListener(listener)
- Logging.addListener(listener)
+ register(listener)
+ register(listener)
// When
Logging.debug("test")
@@ -54,7 +70,7 @@ class LoggingTests : FunSpec({
test("removeListener") {
// Given
val listener = TestLogLister()
- Logging.addListener(listener)
+ register(listener)
Logging.removeListener(listener)
// When
@@ -67,7 +83,7 @@ class LoggingTests : FunSpec({
test("removeListener twice") {
// Given
val listener = TestLogLister()
- Logging.addListener(listener)
+ register(listener)
Logging.removeListener(listener)
Logging.removeListener(listener)
@@ -81,7 +97,8 @@ class LoggingTests : FunSpec({
test("addListener nested") {
// Given
val nestedListener = TestLogLister()
- Logging.addListener { Logging.addListener(nestedListener) }
+ val outerListener = ILogListener { register(nestedListener) }
+ register(outerListener)
// When
Logging.debug("test")
@@ -101,7 +118,7 @@ class LoggingTests : FunSpec({
// Remove self from listeners
Logging.removeListener(listener)
}
- Logging.addListener(listener)
+ register(listener)
// When
Logging.debug("test")
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/mocks/CoreInternalMocks.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/mocks/CoreInternalMocks.kt
new file mode 100644
index 0000000000..cbb8b32d47
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/mocks/CoreInternalMocks.kt
@@ -0,0 +1,21 @@
+package com.onesignal.mocks
+
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
+import io.mockk.every
+import io.mockk.mockk
+
+/**
+ * Helpers for core-internal types that can't live in [MockHelper] (testhelpers module can't see
+ * `internal` declarations from core).
+ */
+internal object CoreInternalMocks {
+ fun identityVerificationService(
+ newCodePathsRun: Boolean = false,
+ ivBehaviorActive: Boolean = false,
+ ): IdentityVerificationService {
+ val mock = mockk(relaxed = true)
+ every { mock.newCodePathsRun } returns newCodePathsRun
+ every { mock.ivBehaviorActive } returns ivBehaviorActive
+ return mock
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt
index bd59e0327a..11fe4834bc 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt
@@ -5,7 +5,10 @@ import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.mocks.MockHelper
+import com.onesignal.mocks.MockPreferencesService
import com.onesignal.user.internal.identity.IdentityModel
+import com.onesignal.user.internal.jwt.JwtRequirement
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.LoginUserOperation
import com.onesignal.user.internal.properties.PropertiesModel
import io.kotest.core.spec.style.FunSpec
@@ -48,6 +51,7 @@ class LoginHelperTests : FunSpec({
val mockOperationRepo = mockk(relaxed = true)
val mockConfigModel = mockk()
every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
val loginLock = Any()
val loginHelper =
@@ -56,6 +60,7 @@ class LoginHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ jwtTokenStore = JwtTokenStore(MockPreferencesService()),
lock = loginLock,
)
@@ -88,6 +93,7 @@ class LoginHelperTests : FunSpec({
val mockOperationRepo = mockk()
val mockConfigModel = mockk()
every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
val loginLock = Any()
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
@@ -109,6 +115,7 @@ class LoginHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ jwtTokenStore = JwtTokenStore(MockPreferencesService()),
lock = loginLock,
)
@@ -154,6 +161,7 @@ class LoginHelperTests : FunSpec({
val mockOperationRepo = mockk()
val mockConfigModel = mockk()
every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
val loginLock = Any()
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
@@ -175,6 +183,7 @@ class LoginHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ jwtTokenStore = JwtTokenStore(MockPreferencesService()),
lock = loginLock,
)
@@ -197,6 +206,68 @@ class LoginHelperTests : FunSpec({
}
}
+ test("login under IV-required does NOT carry existingOnesignalId from anonymous user") {
+ // Given - anonymous user, IV is REQUIRED. The anon user was never created server-side
+ // (no JWT), so its onesignalId would be permanently local. Carrying it as
+ // existingOnesignalId on the new LoginUserOperation would deadlock canStartExecute.
+ val mockIdentityModelStore =
+ MockHelper.identityModelStore { model ->
+ model.externalId = null
+ model.onesignalId = currentOneSignalId
+ }
+
+ val newIdentityModel =
+ IdentityModel().apply {
+ externalId = newExternalId
+ onesignalId = newOneSignalId
+ }
+
+ val mockUserSwitcher = mockk()
+ val mockOperationRepo = mockk()
+ val mockConfigModel = mockk()
+ every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.REQUIRED
+
+ val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
+ every {
+ mockUserSwitcher.createAndSwitchToNewUser(
+ suppressBackendOperation = any(),
+ modify = capture(userSwitcherSlot),
+ )
+ } answers {
+ userSwitcherSlot.captured(newIdentityModel, PropertiesModel())
+ every { mockIdentityModelStore.model } returns newIdentityModel
+ }
+
+ coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true
+
+ val loginHelper =
+ LoginHelper(
+ identityModelStore = mockIdentityModelStore,
+ userSwitcher = mockUserSwitcher,
+ operationRepo = mockOperationRepo,
+ configModel = mockConfigModel,
+ jwtTokenStore = JwtTokenStore(MockPreferencesService()),
+ lock = Any(),
+ )
+
+ // When
+ runBlocking {
+ val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "fresh-jwt")
+ if (context != null) loginHelper.enqueueLogin(context)
+ }
+
+ // Then — under IV, the executor must take the createUser (upsert) path; no merge link.
+ coVerify(exactly = 1) {
+ mockOperationRepo.enqueueAndWait(
+ withArg { operation ->
+ operation.externalId shouldBe newExternalId
+ operation.existingOnesignalId shouldBe null
+ },
+ )
+ }
+ }
+
test("login logs error when operation fails") {
// Given
val mockIdentityModelStore =
@@ -215,6 +286,7 @@ class LoginHelperTests : FunSpec({
val mockOperationRepo = mockk()
val mockConfigModel = mockk()
every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
val loginLock = Any()
val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>()
@@ -237,6 +309,7 @@ class LoginHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ jwtTokenStore = JwtTokenStore(MockPreferencesService()),
lock = loginLock,
)
@@ -250,4 +323,87 @@ class LoginHelperTests : FunSpec({
verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) }
coVerify(exactly = 1) { mockOperationRepo.enqueueAndWait(any()) }
}
+
+ test("login with JWT stores token in JwtTokenStore before enqueueing op") {
+ // Given
+ val mockIdentityModelStore =
+ MockHelper.identityModelStore { model ->
+ model.externalId = null
+ model.onesignalId = currentOneSignalId
+ }
+ val mockUserSwitcher = mockk(relaxed = true)
+ every {
+ mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any())
+ } answers {
+ val modifier = arg<(IdentityModel, PropertiesModel) -> Unit>(1)
+ val newIdentity = mockk(relaxed = true)
+ val newProperties = mockk(relaxed = true)
+ modifier(newIdentity, newProperties)
+ mockIdentityModelStore.model.onesignalId = newOneSignalId
+ mockIdentityModelStore.model.externalId = newExternalId
+ }
+ val mockOperationRepo = mockk(relaxed = true)
+ coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true
+ val mockConfigModel = mockk()
+ every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
+ val jwtTokenStore = JwtTokenStore(MockPreferencesService())
+
+ val loginHelper =
+ LoginHelper(
+ identityModelStore = mockIdentityModelStore,
+ userSwitcher = mockUserSwitcher,
+ operationRepo = mockOperationRepo,
+ configModel = mockConfigModel,
+ jwtTokenStore = jwtTokenStore,
+ lock = Any(),
+ )
+
+ // When
+ runBlocking {
+ val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "the-jwt")
+ if (context != null) loginHelper.enqueueLogin(context)
+ }
+
+ // Then: JWT was stored under the new externalId.
+ jwtTokenStore.getJwt(newExternalId) shouldBe "the-jwt"
+ }
+
+ test("login with same externalId + new JWT updates the stored token") {
+ // Given: already logged in as currentExternalId
+ val mockIdentityModelStore =
+ MockHelper.identityModelStore { model ->
+ model.externalId = currentExternalId
+ model.onesignalId = currentOneSignalId
+ }
+ val mockUserSwitcher = mockk(relaxed = true)
+ val mockOperationRepo = mockk(relaxed = true)
+ val mockConfigModel = mockk()
+ every { mockConfigModel.appId } returns appId
+ every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
+ val jwtTokenStore = JwtTokenStore(MockPreferencesService())
+ jwtTokenStore.putJwt(currentExternalId, "old-jwt")
+
+ val loginHelper =
+ LoginHelper(
+ identityModelStore = mockIdentityModelStore,
+ userSwitcher = mockUserSwitcher,
+ operationRepo = mockOperationRepo,
+ configModel = mockConfigModel,
+ jwtTokenStore = jwtTokenStore,
+ lock = Any(),
+ )
+
+ // When: login with same externalId but new JWT
+ runBlocking {
+ val context = loginHelper.switchUser(currentExternalId, jwtBearerToken = "new-jwt")
+ if (context != null) loginHelper.enqueueLogin(context)
+ }
+
+ // Then: no user-switch happened, JWT was refreshed, and the queue was woken so any
+ // ops deferred by hasValidJwtIfRequired dispatch immediately.
+ verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) }
+ jwtTokenStore.getJwt(currentExternalId) shouldBe "new-jwt"
+ verify(exactly = 1) { mockOperationRepo.forceExecuteOperations() }
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt
index f783408681..c804dc4569 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt
@@ -6,6 +6,9 @@ import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.mocks.MockHelper
import com.onesignal.user.internal.operations.LoginUserOperation
+import com.onesignal.user.internal.subscriptions.SubscriptionModel
+import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
+import com.onesignal.user.internal.subscriptions.SubscriptionType
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
@@ -49,6 +52,11 @@ class LogoutHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ subscriptionModelStore = mockk(relaxed = true),
+ identityVerificationService = mockk(relaxed = true) {
+ every { newCodePathsRun } returns false
+ every { ivBehaviorActive } returns false
+ },
lock = logoutLock,
)
@@ -80,6 +88,11 @@ class LogoutHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ subscriptionModelStore = mockk(relaxed = true),
+ identityVerificationService = mockk(relaxed = true) {
+ every { newCodePathsRun } returns false
+ every { ivBehaviorActive } returns false
+ },
lock = logoutLock,
)
@@ -120,6 +133,11 @@ class LogoutHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ subscriptionModelStore = mockk(relaxed = true),
+ identityVerificationService = mockk(relaxed = true) {
+ every { newCodePathsRun } returns false
+ every { ivBehaviorActive } returns false
+ },
lock = logoutLock,
)
@@ -153,6 +171,11 @@ class LogoutHelperTests : FunSpec({
userSwitcher = mockUserSwitcher,
operationRepo = mockOperationRepo,
configModel = mockConfigModel,
+ subscriptionModelStore = mockk(relaxed = true),
+ identityVerificationService = mockk(relaxed = true) {
+ every { newCodePathsRun } returns false
+ every { ivBehaviorActive } returns false
+ },
lock = logoutLock,
)
@@ -172,4 +195,52 @@ class LogoutHelperTests : FunSpec({
verify(atLeast = 1) { mockUserSwitcher.createAndSwitchToNewUser() }
verify(atLeast = 1) { mockOperationRepo.enqueue(any()) }
}
+
+ test("switchUserIv marks current push sub as internally disabled before switching users") {
+ // Given - IV active, push sub model in store
+ val pushSubId = "push-sub-id"
+ val pushSubModel =
+ SubscriptionModel().apply {
+ id = pushSubId
+ type = SubscriptionType.PUSH
+ }
+ val mockSubscriptionModelStore = mockk(relaxed = true)
+ every { mockSubscriptionModelStore.get(pushSubId) } returns pushSubModel
+ val mockUserSwitcher = mockk(relaxed = true)
+ val mockConfigModel = mockk()
+ every { mockConfigModel.pushSubscriptionId } returns pushSubId
+
+ // When
+ val handled = switchUserIv(mockUserSwitcher, mockSubscriptionModelStore, mockConfigModel, ivBehaviorActive = true)
+
+ // Then
+ handled shouldBe true
+
+ // Order: flag must be set on the OLD push sub BEFORE the user-switch — setting it
+ // with the default NORMAL tag fires an UpdateSubscriptionOperation against the OLD
+ // user with their still-valid JWT, telling the backend that this device's push
+ // subscription is unsubscribing as the user logs out.
+ verifyOrder {
+ mockSubscriptionModelStore.get(pushSubId)
+ mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true)
+ }
+
+ // The flag is set on the model (verified via the underlying property).
+ pushSubModel.isDisabledInternally shouldBe true
+ }
+
+ test("switchUserIv returns false when IV behavior is inactive") {
+ // Given - Phase 3: new code path on, IV behavior off
+ val mockSubscriptionModelStore = mockk(relaxed = true)
+ val mockUserSwitcher = mockk(relaxed = true)
+ val mockConfigModel = mockk(relaxed = true)
+
+ // When
+ val handled = switchUserIv(mockUserSwitcher, mockSubscriptionModelStore, mockConfigModel, ivBehaviorActive = false)
+
+ // Then - falls through to legacy logout flow; no IV-specific calls.
+ handled shouldBe false
+ verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any()) }
+ verify(exactly = 0) { mockSubscriptionModelStore.get(any()) }
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt
index 924d7f9f3f..3885c73d5d 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt
@@ -35,7 +35,7 @@ class CustomEventBackendServiceTests : FunSpec({
test("track event") {
// Given
val spyHttpClient = mockk()
- coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(202, "")
+ coEvery { spyHttpClient.post(any(), any(), any()) } returns HttpResponse(202, "")
val customEventBackendService = CustomEventBackendService(spyHttpClient)
// When
@@ -80,6 +80,7 @@ class CustomEventBackendServiceTests : FunSpec({
payload.getJSONObject("os_sdk").toString() shouldBeEqual metadata.toJSONObject().toString()
payload.getString("proKey1") shouldBeEqual "proVal1"
},
+ any(),
)
}
}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/IdentityBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/IdentityBackendServiceTests.kt
index d66ee47ebf..36c99e652f 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/IdentityBackendServiceTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/IdentityBackendServiceTests.kt
@@ -21,7 +21,7 @@ class IdentityBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(200, "{ identity: { aliasKey1: \"aliasValue1\"} }")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(200, "{ identity: { aliasKey1: \"aliasValue1\"} }")
val identityBackendService = IdentityBackendService(spyHttpClient)
val identities = mapOf("aliasKey1" to "aliasValue1")
@@ -38,6 +38,7 @@ class IdentityBackendServiceTests : FunSpec({
it.getJSONObject("identity").has("aliasKey1") shouldBe true
it.getJSONObject("identity").getString("aliasKey1") shouldBe "aliasValue1"
},
+ any(),
)
}
}
@@ -48,7 +49,7 @@ class IdentityBackendServiceTests : FunSpec({
val aliasValue = "11111111-1111-1111-1111-111111111111"
val aliasToDelete = "aliasKey1"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.delete(any()) } returns HttpResponse(200, "")
+ coEvery { spyHttpClient.delete(any(), any()) } returns HttpResponse(200, "")
val identityBackendService = IdentityBackendService(spyHttpClient)
// When
@@ -56,7 +57,7 @@ class IdentityBackendServiceTests : FunSpec({
// Then
coVerify {
- spyHttpClient.delete("apps/appId/users/by/$aliasLabel/$aliasValue/identity/$aliasToDelete")
+ spyHttpClient.delete("apps/appId/users/by/$aliasLabel/$aliasValue/identity/$aliasToDelete", any())
}
}
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt
index 0ff7a15f8f..5877ec5df9 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt
@@ -27,7 +27,7 @@ class SubscriptionBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(202, "{ \"subscription\": { id: \"subscriptionId\" }, \"ryw_token\": \"123\"}")
+ coEvery { spyHttpClient.post(any(), any(), any()) } returns HttpResponse(202, "{ \"subscription\": { id: \"subscriptionId\" }, \"ryw_token\": \"123\"}")
val subscriptionBackendService = SubscriptionBackendService(spyHttpClient)
// When
@@ -55,6 +55,7 @@ class SubscriptionBackendServiceTests : FunSpec({
sub.getBoolean("enabled") shouldBe true
sub.getInt("notification_types") shouldBe 1
},
+ any(),
)
}
}
@@ -64,7 +65,7 @@ class SubscriptionBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(404, "NOT FOUND")
+ coEvery { spyHttpClient.post(any(), any(), any()) } returns HttpResponse(404, "NOT FOUND")
val subscriptionBackendService = SubscriptionBackendService(spyHttpClient)
// When
@@ -100,6 +101,7 @@ class SubscriptionBackendServiceTests : FunSpec({
sub.getBoolean("enabled") shouldBe true
sub.getInt("notification_types") shouldBe 1
},
+ any(),
)
}
}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt
index 0fa15fdcae..110c7566cb 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/UserBackendServiceTests.kt
@@ -22,7 +22,7 @@ class UserBackendServiceTests : FunSpec({
test("create user with nothing throws an exception") {
// Given
val spyHttpClient = mockk()
- coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(403, "FORBIDDEN")
+ coEvery { spyHttpClient.post(any(), any(), any()) } returns HttpResponse(403, "FORBIDDEN")
val userBackendService = UserBackendService(spyHttpClient)
val identities = mapOf()
val properties = mapOf()
@@ -44,7 +44,7 @@ class UserBackendServiceTests : FunSpec({
val osId = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
coEvery {
- spyHttpClient.post(any(), any())
+ spyHttpClient.post(any(), any(), any())
} returns HttpResponse(202, "{identity:{onesignal_id: \"$osId\", aliasLabel1: \"aliasValue1\"}, properties:{timezone_id: \"testTimeZone\", language: \"testLanguage\"}}")
val userBackendService = UserBackendService(spyHttpClient)
val identities = mapOf("aliasLabel1" to "aliasValue1")
@@ -70,6 +70,7 @@ class UserBackendServiceTests : FunSpec({
it.has("properties") shouldBe true
it.has("subscriptions") shouldBe false
},
+ any(),
)
}
}
@@ -79,7 +80,7 @@ class UserBackendServiceTests : FunSpec({
val osId = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
coEvery {
- spyHttpClient.post(any(), any())
+ spyHttpClient.post(any(), any(), any())
} returns HttpResponse(202, "{identity:{onesignal_id: \"$osId\"}, subscriptions:[{id:\"subscriptionId1\", type:\"AndroidPush\"}], properties:{timezone_id: \"testTimeZone\", language: \"testLanguage\"}}")
val userBackendService = UserBackendService(spyHttpClient)
val identities = mapOf()
@@ -109,6 +110,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONArray("subscriptions").getJSONObject(0).has("type") shouldBe true
it.getJSONArray("subscriptions").getJSONObject(0).getString("type") shouldBe "AndroidPush"
},
+ any(),
)
}
}
@@ -118,7 +120,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { tags: {tagKey1: tagValue1}}}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { tags: {tagKey1: tagValue1}}}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(tags = mapOf("tagkey1" to "tagValue1"))
val propertiesDelta = PropertiesDeltasObject()
@@ -136,6 +138,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("properties").getJSONObject("tags").has("tagkey1") shouldBe true
it.getJSONObject("properties").getJSONObject("tags").getString("tagkey1") shouldBe "tagValue1"
},
+ any(),
)
}
}
@@ -145,7 +148,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { tags: {tagKey1: tagValue1}}}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { tags: {tagKey1: tagValue1}}}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(language = "newLanguage")
val propertiesDelta = PropertiesDeltasObject()
@@ -162,6 +165,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("properties").has("language") shouldBe true
it.getJSONObject("properties").getString("language") shouldBe "newLanguage"
},
+ any(),
)
}
}
@@ -171,7 +175,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { timezone_id: \"America/New_York\"}}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { timezone_id: \"America/New_York\"}}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(timezoneId = "America/New_York")
val propertiesDelta = PropertiesDeltasObject()
@@ -188,6 +192,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("properties").has("timezone_id") shouldBe true
it.getJSONObject("properties").getString("timezone_id") shouldBe "America/New_York"
},
+ any(),
)
}
}
@@ -197,7 +202,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { country: \"TV\"}}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { country: \"TV\"}}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(country = "TV")
val propertiesDelta = PropertiesDeltasObject()
@@ -214,6 +219,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("properties").has("country") shouldBe true
it.getJSONObject("properties").getString("country") shouldBe "TV"
},
+ any(),
)
}
}
@@ -223,7 +229,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { lat: 12.34, long: 45.67}}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { lat: 12.34, long: 45.67}}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(latitude = 12.34, longitude = 45.67)
val propertiesDelta = PropertiesDeltasObject()
@@ -242,6 +248,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("properties").has("long") shouldBe true
it.getJSONObject("properties").getDouble("long") shouldBe 45.67
},
+ any(),
)
}
}
@@ -251,7 +258,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: {} }")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: {} }")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(tags = mapOf("tagkey1" to "tagValue1"))
val propertiesDelta = PropertiesDeltasObject()
@@ -266,6 +273,7 @@ class UserBackendServiceTests : FunSpec({
withArg {
it.getBoolean("refresh_device_metadata") shouldBe true
},
+ any(),
)
}
}
@@ -275,7 +283,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: {} }")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: {} }")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(tags = mapOf("tagkey1" to "tagValue1"))
val propertiesDelta = PropertiesDeltasObject()
@@ -290,6 +298,7 @@ class UserBackendServiceTests : FunSpec({
withArg {
it.getBoolean("refresh_device_metadata") shouldBe false
},
+ any(),
)
}
}
@@ -299,7 +308,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { }}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { }}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject()
val propertiesDelta = PropertiesDeltasObject(sessionTime = 1111, sessionCount = 1)
@@ -318,6 +327,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("deltas").has("session_count") shouldBe true
it.getJSONObject("deltas").getInt("session_count") shouldBe 1
},
+ any(),
)
}
}
@@ -327,7 +337,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(202, "{properties: { }}")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(202, "{properties: { }}")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject()
val propertiesDelta =
@@ -366,6 +376,7 @@ class UserBackendServiceTests : FunSpec({
it.getJSONObject("deltas").getJSONArray("purchases").getJSONObject(1).has("amount") shouldBe true
it.getJSONObject("deltas").getJSONArray("purchases").getJSONObject(1).getDouble("amount") shouldBe 4444
},
+ any(),
)
}
}
@@ -375,7 +386,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(404, "NOT FOUND")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(404, "NOT FOUND")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(tags = mapOf("tagkey1" to "tagValue1"))
val propertiesDelta = PropertiesDeltasObject()
@@ -396,7 +407,7 @@ class UserBackendServiceTests : FunSpec({
val aliasLabel = "onesignal_id"
val aliasValue = "11111111-1111-1111-1111-111111111111"
val spyHttpClient = mockk()
- coEvery { spyHttpClient.patch(any(), any()) } returns HttpResponse(403, "FORBIDDEN")
+ coEvery { spyHttpClient.patch(any(), any(), any()) } returns HttpResponse(403, "FORBIDDEN")
val userBackendService = UserBackendService(spyHttpClient)
val properties = PropertiesObject(tags = mapOf("tagkey1" to "tagValue1"))
val propertiesDelta = PropertiesDeltasObject()
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/JwtTokenStoreTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/JwtTokenStoreTests.kt
new file mode 100644
index 0000000000..0e98dc40c8
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/JwtTokenStoreTests.kt
@@ -0,0 +1,293 @@
+package com.onesignal.user.internal.jwt
+
+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.mocks.MockPreferencesService
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import org.json.JSONObject
+
+class JwtTokenStoreTests : FunSpec({
+ beforeEach {
+ // Silence logging to avoid android.util.Log.w not-mocked failures in Logging.warn
+ Logging.logLevel = LogLevel.NONE
+ }
+
+ test("getJwt returns null for an externalId never stored") {
+ val store = JwtTokenStore(MockPreferencesService())
+
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("putJwt stores a token retrievable by externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+
+ store.putJwt("alice", "token-a")
+
+ store.getJwt("alice") shouldBe "token-a"
+ }
+
+ test("putJwt replaces an existing token for the same externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a1")
+
+ store.putJwt("alice", "token-a2")
+
+ store.getJwt("alice") shouldBe "token-a2"
+ }
+
+ test("putJwt with null is a no-op (invalidate is the explicit path)") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+
+ store.putJwt("alice", null)
+
+ store.getJwt("alice") shouldBe "token-a"
+ }
+
+ test("invalidateJwt removes the token for externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+
+ store.invalidateJwt("alice")
+
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("invalidateJwt on an absent externalId is a no-op (no crash)") {
+ val store = JwtTokenStore(MockPreferencesService())
+
+ store.invalidateJwt("alice")
+
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("putJwt persists to preferences and can be recovered by a fresh store instance") {
+ val prefs = MockPreferencesService()
+ val first = JwtTokenStore(prefs)
+ first.putJwt("alice", "token-a")
+ first.putJwt("bob", "token-b")
+
+ val second = JwtTokenStore(prefs)
+
+ second.getJwt("alice") shouldBe "token-a"
+ second.getJwt("bob") shouldBe "token-b"
+ }
+
+ test("invalidateJwt persists so next launch does not see the token") {
+ val prefs = MockPreferencesService()
+ val first = JwtTokenStore(prefs)
+ first.putJwt("alice", "token-a")
+ first.invalidateJwt("alice")
+
+ val second = JwtTokenStore(prefs)
+
+ second.getJwt("alice") shouldBe null
+ }
+
+ test("pruneToExternalIds removes tokens whose externalId is not in the active set") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ store.putJwt("bob", "token-b")
+ store.putJwt("chris", "token-c")
+
+ store.pruneToExternalIds(setOf("alice", "chris"))
+
+ store.getJwt("alice") shouldBe "token-a"
+ store.getJwt("bob") shouldBe null
+ store.getJwt("chris") shouldBe "token-c"
+ }
+
+ test("subscribers are notified when a new JWT is put") {
+ val store = JwtTokenStore(MockPreferencesService())
+ val calls = mutableListOf()
+ store.addInternalUpdateListener(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.putJwt("alice", "token-a")
+
+ calls shouldBe listOf("alice")
+ }
+
+ test("subscribers are NOT notified when putJwt does not change the stored token") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ val calls = mutableListOf()
+ store.addInternalUpdateListener(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.putJwt("alice", "token-a")
+
+ calls.isEmpty() shouldBe true
+ }
+
+ test("invalidateJwt does NOT fire onJwtUpdated to internal listeners") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ val updatedCalls = mutableListOf()
+ store.addInternalUpdateListener(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ updatedCalls.add(externalId)
+ }
+ },
+ )
+
+ // putJwt above already fired onJwtUpdated once; invalidateJwt should not add another.
+ updatedCalls.size shouldBe 0
+ store.invalidateJwt("alice")
+ updatedCalls.size shouldBe 0
+ }
+
+ test("IUserJwtInvalidatedListener is NOT notified when invalidating a non-existent token") {
+ val store = JwtTokenStore(MockPreferencesService())
+ val invalidatedCalls = mutableListOf()
+ store.addUserJwtInvalidatedListener { event -> invalidatedCalls.add(event.externalId) }
+
+ store.invalidateJwt("alice")
+ Thread.sleep(50) // allow async dispatch (no-op here, but defensive)
+
+ invalidatedCalls.isEmpty() shouldBe true
+ }
+
+ test("throwing IUserJwtInvalidatedListener subscriber is isolated; other subscribers still fire") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ val laterCalls = mutableListOf()
+ val waiter = com.onesignal.common.threading.Waiter()
+ store.addUserJwtInvalidatedListener { _ -> throw RuntimeException("boom") }
+ store.addUserJwtInvalidatedListener { event ->
+ laterCalls.add(event.externalId)
+ waiter.wake()
+ }
+
+ store.invalidateJwt("alice")
+ waiter.waitForWake()
+
+ laterCalls shouldBe listOf("alice")
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("pruneToExternalIds fires for each removed externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ store.putJwt("bob", "token-b")
+ store.putJwt("chris", "token-c")
+ val calls = mutableListOf()
+ store.addInternalUpdateListener(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.pruneToExternalIds(setOf("alice"))
+
+ // Order is not deterministic across JVMs; check set semantics.
+ calls.toSet() shouldBe setOf("bob", "chris")
+ }
+
+ test("unsubscribed listener is not notified") {
+ val store = JwtTokenStore(MockPreferencesService())
+ val calls = mutableListOf()
+ val listener =
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ }
+ store.addInternalUpdateListener(listener)
+ store.removeInternalUpdateListener(listener)
+
+ store.putJwt("alice", "token-a")
+
+ calls.isEmpty() shouldBe true
+ }
+
+ test("persisted JSON is the expected shape") {
+ val prefs = MockPreferencesService()
+ val store = JwtTokenStore(prefs)
+
+ store.putJwt("alice", "token-a")
+ store.putJwt("bob", "token-b")
+
+ val raw = prefs.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS)
+ val obj = JSONObject(requireNotNull(raw))
+ obj.getString("alice") shouldBe "token-a"
+ obj.getString("bob") shouldBe "token-b"
+ }
+
+ test("malformed persisted JSON starts fresh without crashing") {
+ val prefs =
+ MockPreferencesService(
+ mapOf(PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS to "{not valid json"),
+ )
+ val store = JwtTokenStore(prefs)
+
+ store.getJwt("alice") shouldBe null
+ // Can still store new tokens after a malformed load
+ store.putJwt("alice", "token-a")
+ store.getJwt("alice") shouldBe "token-a"
+ }
+
+ test("invalidateJwt fires registered IUserJwtInvalidatedListener with the externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "stale-token")
+
+ var firedExternalId: String? = null
+ val waiter = com.onesignal.common.threading.Waiter()
+ store.addUserJwtInvalidatedListener { event ->
+ firedExternalId = event.externalId
+ waiter.wake()
+ }
+
+ store.invalidateJwt("alice")
+ waiter.waitForWake()
+
+ firedExternalId shouldBe "alice"
+ }
+
+ test("late IUserJwtInvalidatedListener subscriber does not receive earlier events (pure pub/sub)") {
+ // Matches iOS: only listeners subscribed at the time of the fire receive the event.
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "stale-token")
+
+ // Fire before any developer listener is registered.
+ store.invalidateJwt("alice")
+ Thread.sleep(50) // allow async dispatch
+
+ // Late subscriber must not receive the earlier event.
+ var lateFired = false
+ store.addUserJwtInvalidatedListener { lateFired = true }
+ Thread.sleep(50)
+ lateFired shouldBe false
+ }
+
+ test("removeUserJwtInvalidatedListener stops further notifications") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token")
+
+ var fireCount = 0
+ val listener = com.onesignal.IUserJwtInvalidatedListener { _ -> fireCount++ }
+ store.addUserJwtInvalidatedListener(listener)
+ store.removeUserJwtInvalidatedListener(listener)
+
+ store.invalidateJwt("alice")
+ Thread.sleep(50)
+
+ fireCount shouldBe 0
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt
index 554c09ac96..cb16466bc2 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt
@@ -5,7 +5,11 @@ import com.onesignal.core.internal.operations.impl.OperationRepo
import com.onesignal.core.internal.time.impl.Time
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.mocks.CoreInternalMocks
import com.onesignal.mocks.MockHelper
+import com.onesignal.mocks.MockPreferencesService
+import com.onesignal.user.internal.jwt.JwtRequirement
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.ExecutorMocks
import com.onesignal.user.internal.operations.LoginUserOperation
import io.kotest.core.spec.style.FunSpec
@@ -29,7 +33,11 @@ private class Mocks {
every { mockOperationModelStore.remove(any()) } just runs
mockOperationModelStore
}
- val configModelStore = MockHelper.configModelStore()
+ val configModelStore =
+ MockHelper.configModelStore {
+ it.isInitializedWithRemote = true
+ it.useIdentityVerification = JwtRequirement.NOT_REQUIRED
+ }
val operationRepo =
spyk(
OperationRepo(
@@ -38,6 +46,8 @@ private class Mocks {
configModelStore,
Time(),
ExecutorMocks.getNewRecordState(configModelStore),
+ JwtTokenStore(MockPreferencesService()),
+ CoreInternalMocks.identityVerificationService(),
),
)
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt
index 044d4c3726..5681a12a77 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt
@@ -3,12 +3,15 @@ package com.onesignal.user.internal.operations
import android.content.Context
import android.os.Build
import com.onesignal.common.OneSignalUtils
+import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.device.IDeviceService
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.Operation
import com.onesignal.mocks.MockHelper
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentityVerificationService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
@@ -23,7 +26,7 @@ class CustomEventOperationExecutorTests : FunSpec({
test("execution of track event operation") {
// Given
val mockCustomEventBackendService = mockk()
- coEvery { mockCustomEventBackendService.sendCustomEvent(any(), any(), any(), any(), any(), any(), any()) } returns ExecutionResponse(ExecutionResult.SUCCESS)
+ coEvery { mockCustomEventBackendService.sendCustomEvent(any(), any(), any(), any(), any(), any(), any(), any()) } returns ExecutionResponse(ExecutionResult.SUCCESS)
val mockApplicationService = MockHelper.applicationService()
val mockContext = mockk(relaxed = true)
@@ -36,7 +39,7 @@ class CustomEventOperationExecutorTests : FunSpec({
val properties = JSONObject().put("key", "value").toString()
val customEventOperationExecutor =
- CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService)
+ CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService, getJwtTokenStore(), getIdentityVerificationService())
val operations = listOf(TrackCustomEventOperation("appId", "onesignalId", null, 1, "event-name", properties))
// When
@@ -60,7 +63,40 @@ class CustomEventOperationExecutorTests : FunSpec({
it.deviceModel shouldBe deviceMode
it.deviceOS shouldBe deviceOS
},
+ null,
)
}
}
+
+ test("track event returns FAIL_UNAUTHORIZED on 401 so JWT gets invalidated under IV") {
+ // Given
+ val mockCustomEventBackendService = mockk()
+ coEvery { mockCustomEventBackendService.sendCustomEvent(any(), any(), any(), any(), any(), any(), any(), any()) } throws
+ BackendException(401, "UNAUTHORIZED", retryAfterSeconds = 5)
+
+ val mockApplicationService = MockHelper.applicationService()
+ every { mockApplicationService.appContext } returns mockk(relaxed = true)
+ val mockDeviceService = MockHelper.deviceService()
+ every { mockDeviceService.deviceType } returns IDeviceService.DeviceType.Android
+
+ val customEventOperationExecutor =
+ CustomEventOperationExecutor(
+ mockCustomEventBackendService,
+ mockApplicationService,
+ mockDeviceService,
+ getJwtTokenStore(),
+ getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true),
+ )
+ val operations =
+ listOf(
+ TrackCustomEventOperation("appId", "onesignalId", "ext-1", 1, "event-name", JSONObject().toString()),
+ )
+
+ // When
+ val response = customEventOperationExecutor.execute(operations)
+
+ // Then — must be FAIL_UNAUTHORIZED so OperationRepo.handleFailUnauthorized fires.
+ response.result shouldBe ExecutionResult.FAIL_UNAUTHORIZED
+ response.retryAfterSeconds shouldBe 5
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorMocks.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorMocks.kt
index 9890563333..aaee22323e 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorMocks.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorMocks.kt
@@ -1,12 +1,31 @@
package com.onesignal.user.internal.operations
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.time.impl.Time
import com.onesignal.mocks.MockHelper
+import com.onesignal.mocks.MockPreferencesService
+import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.impl.states.NewRecordsState
+import io.mockk.every
+import io.mockk.mockk
class ExecutorMocks {
companion object {
fun getNewRecordState(configModelStore: ConfigModelStore = MockHelper.configModelStore()) = NewRecordsState(Time(), configModelStore)
+
+ /** Real (empty) JWT store backed by a mock preferences service. `getJwt` returns null for all keys. */
+ internal fun getJwtTokenStore() = JwtTokenStore(MockPreferencesService())
+
+ /** Mocked [IdentityVerificationService] with both gates returning false by default — IV inactive, new code paths off. */
+ internal fun getIdentityVerificationService(
+ newCodePathsRun: Boolean = false,
+ ivBehaviorActive: Boolean = false,
+ ): IdentityVerificationService {
+ val mock = mockk(relaxed = true)
+ every { mock.newCodePathsRun } returns newCodePathsRun
+ every { mock.ivBehaviorActive } returns ivBehaviorActive
+ return mock
+ }
}
}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorsIvExtensionsTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorsIvExtensionsTests.kt
new file mode 100644
index 0000000000..a8de55e5dd
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/ExecutorsIvExtensionsTests.kt
@@ -0,0 +1,100 @@
+package com.onesignal.user.internal.operations
+
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.mocks.MockPreferencesService
+import com.onesignal.user.internal.backend.IdentityConstants
+import com.onesignal.user.internal.jwt.JwtTokenStore
+import com.onesignal.user.internal.operations.impl.executors.IvBackendParams
+import com.onesignal.user.internal.operations.impl.executors.resolveIvBackendParams
+import com.onesignal.user.internal.operations.impl.executors.resolveIvJwt
+import com.onesignal.user.internal.operations.impl.executors.shouldFailLoginUserFromSubscription
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+
+class ExecutorsIvExtensionsTests : FunSpec({
+ beforeAny {
+ Logging.logLevel = LogLevel.NONE
+ }
+
+ test("resolveIvBackendParams returns legacy values when ivBehaviorActive is false (Phase 3)") {
+ // Given: new code path on but behavior inactive
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ jwtStore.putJwt("ext-1", "jwt-value")
+ val op = LoginUserOperation("app", "os-1", "ext-1", null)
+
+ // When
+ val params = resolveIvBackendParams(op, "os-1", jwtStore, ivBehaviorActive = false)
+
+ // Then: onesignal_id alias, no JWT
+ params shouldBe IvBackendParams(IdentityConstants.ONESIGNAL_ID, "os-1", null)
+ }
+
+ test("resolveIvBackendParams returns external_id alias + JWT when IV active and externalId present") {
+ // Given
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ jwtStore.putJwt("ext-1", "jwt-value")
+ val op = LoginUserOperation("app", "os-1", "ext-1", null)
+
+ // When
+ val params = resolveIvBackendParams(op, "os-1", jwtStore, ivBehaviorActive = true)
+
+ // Then
+ params shouldBe IvBackendParams(IdentityConstants.EXTERNAL_ID, "ext-1", "jwt-value")
+ }
+
+ test("resolveIvBackendParams falls back to onesignal_id when IV active but externalId null (defensive)") {
+ // Given: IV active, op has no externalId (shouldn't happen per hasValidJwtIfRequired gating)
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ val op = LoginUserOperation("app", "os-1", null, null)
+
+ // When
+ val params = resolveIvBackendParams(op, "os-1", jwtStore, ivBehaviorActive = true)
+
+ // Then
+ params shouldBe IvBackendParams(IdentityConstants.ONESIGNAL_ID, "os-1", null)
+ }
+
+ test("resolveIvBackendParams returns null JWT when IV active but no JWT stored (defensive)") {
+ // Given: IV active, externalId set, but no JWT in store
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ val op = LoginUserOperation("app", "os-1", "ext-1", null)
+
+ // When
+ val params = resolveIvBackendParams(op, "os-1", jwtStore, ivBehaviorActive = true)
+
+ // Then: alias still switches to external_id but jwt is null
+ params shouldBe IvBackendParams(IdentityConstants.EXTERNAL_ID, "ext-1", null)
+ }
+
+ test("resolveIvJwt returns null when ivBehaviorActive is false") {
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ jwtStore.putJwt("ext-1", "jwt-value")
+ val op = LoginUserOperation("app", "os-1", "ext-1", null)
+
+ resolveIvJwt(op, jwtStore, ivBehaviorActive = false) shouldBe null
+ }
+
+ test("resolveIvJwt returns stored JWT when IV active and externalId present") {
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ jwtStore.putJwt("ext-1", "jwt-value")
+ val op = LoginUserOperation("app", "os-1", "ext-1", null)
+
+ resolveIvJwt(op, jwtStore, ivBehaviorActive = true) shouldBe "jwt-value"
+ }
+
+ test("resolveIvJwt returns null when IV active but op is anonymous") {
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ val op = LoginUserOperation("app", "os-1", null, null)
+
+ resolveIvJwt(op, jwtStore, ivBehaviorActive = true) shouldBe null
+ }
+
+ test("shouldFailLoginUserFromSubscription returns false when ivBehaviorActive is false") {
+ shouldFailLoginUserFromSubscription(ivBehaviorActive = false) shouldBe false
+ }
+
+ test("shouldFailLoginUserFromSubscription returns true when ivBehaviorActive is true") {
+ shouldFailLoginUserFromSubscription(ivBehaviorActive = true) shouldBe true
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt
index 34d0681c48..3e3cc45a2e 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt
@@ -5,11 +5,15 @@ import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.core.internal.operations.Operation
import com.onesignal.mocks.MockHelper
+import com.onesignal.mocks.MockPreferencesService
import com.onesignal.user.internal.backend.IIdentityBackendService
import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.identity.IdentityModel
import com.onesignal.user.internal.identity.IdentityModelStore
+import com.onesignal.user.internal.jwt.JwtTokenStore
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentityVerificationService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor
import io.kotest.core.spec.style.FunSpec
@@ -39,8 +43,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1"))
// When
val response = identityOperationExecutor.execute(operations)
@@ -69,8 +73,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1"))
// When
@@ -90,8 +94,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1"))
// When
@@ -111,8 +115,8 @@ class IdentityOperationExecutorTests : FunSpec({
every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1"))
// When
@@ -134,8 +138,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 }
val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") }
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState)
- val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1"))
// When
@@ -160,8 +164,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1"))
// When
val response = identityOperationExecutor.execute(operations)
@@ -183,8 +187,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1"))
// When
@@ -203,8 +207,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1"))
// When
@@ -225,8 +229,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockBuildUserService = mockk()
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState())
- val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1"))
// When
@@ -250,8 +254,8 @@ class IdentityOperationExecutorTests : FunSpec({
val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 }
val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") }
val identityOperationExecutor =
- IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState)
- val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1"))
+ IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, getJwtTokenStore(), getIdentityVerificationService())
+ val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1"))
// When
val response = identityOperationExecutor.execute(operations)
@@ -259,4 +263,85 @@ class IdentityOperationExecutorTests : FunSpec({
// Then
response.result shouldBe ExecutionResult.FAIL_RETRY
}
+
+ test("set alias uses external_id alias and attaches JWT when IV active") {
+ // Given
+ val mockIdentityBackendService = mockk()
+ coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf()
+
+ val mockIdentityModel = mockk()
+ every { mockIdentityModel.onesignalId } returns "onesignalId"
+ every { mockIdentityModel.setStringProperty(any(), any(), any()) } just runs
+
+ val mockIdentityModelStore = mockk()
+ every { mockIdentityModelStore.model } returns mockIdentityModel
+
+ val mockBuildUserService = mockk()
+
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ jwtStore.putJwt("ext-1", "the-jwt")
+
+ val identityOperationExecutor =
+ IdentityOperationExecutor(
+ mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), jwtStore,
+ getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true),
+ )
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", "ext-1", "aliasKey1", "aliasValue1"))
+
+ // When
+ val response = identityOperationExecutor.execute(operations)
+
+ // Then
+ response.result shouldBe ExecutionResult.SUCCESS
+ coVerify(exactly = 1) {
+ mockIdentityBackendService.setAlias(
+ "appId",
+ IdentityConstants.EXTERNAL_ID,
+ "ext-1",
+ mapOf("aliasKey1" to "aliasValue1"),
+ "the-jwt",
+ )
+ }
+ }
+
+ test("set alias keeps onesignal_id alias and null JWT in Phase 3 (newCodePathsRun + ivBehaviorActive=false)") {
+ // Given: new code path on but IV behavior off — extension runs but must return legacy values
+ val mockIdentityBackendService = mockk()
+ coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf()
+
+ val mockIdentityModel = mockk()
+ every { mockIdentityModel.onesignalId } returns "onesignalId"
+ every { mockIdentityModel.setStringProperty(any(), any(), any()) } just runs
+
+ val mockIdentityModelStore = mockk()
+ every { mockIdentityModelStore.model } returns mockIdentityModel
+
+ val mockBuildUserService = mockk()
+
+ val jwtStore = JwtTokenStore(MockPreferencesService())
+ // A JWT is stored; Phase 3 must NOT attach it.
+ jwtStore.putJwt("ext-1", "the-jwt")
+
+ val identityOperationExecutor =
+ IdentityOperationExecutor(
+ mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), jwtStore,
+ getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = false),
+ )
+ val operations = listOf(SetAliasOperation("appId", "onesignalId", "ext-1", "aliasKey1", "aliasValue1"))
+
+ // When
+ val response = identityOperationExecutor.execute(operations)
+
+ // Then: onesignal_id alias, null JWT
+ response.result shouldBe ExecutionResult.SUCCESS
+ coVerify(exactly = 1) {
+ mockIdentityBackendService.setAlias(
+ "appId",
+ IdentityConstants.ONESIGNAL_ID,
+ "onesignalId",
+ mapOf("aliasKey1" to "aliasValue1"),
+ null,
+ )
+ }
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt
index d80dc5531a..14121ca0cc 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt
@@ -14,6 +14,8 @@ import com.onesignal.user.internal.backend.PropertiesObject
import com.onesignal.user.internal.backend.SubscriptionObject
import com.onesignal.user.internal.backend.SubscriptionObjectType
import com.onesignal.user.internal.identity.IdentityModel
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentityVerificationService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor
import com.onesignal.user.internal.properties.PropertiesModel
@@ -43,6 +45,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
"subscriptionId1",
SubscriptionType.PUSH,
true,
@@ -76,6 +79,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations =
listOf(
@@ -120,6 +124,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations =
listOf(
@@ -148,7 +153,7 @@ class LoginUserOperationExecutorTests : FunSpec({
val mockSubscriptionsModelStore = mockk()
val loginUserOperationExecutor =
- LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext())
+ LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true))
val operations =
listOf(
LoginUserOperation(appId, localOneSignalId, null, null),
@@ -176,7 +181,7 @@ class LoginUserOperationExecutorTests : FunSpec({
val mockSubscriptionsModelStore = mockk()
val loginUserOperationExecutor =
- LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext())
+ LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true))
val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null))
// When
@@ -214,6 +219,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null))
@@ -242,7 +248,7 @@ class LoginUserOperationExecutorTests : FunSpec({
val mockSubscriptionsModelStore = mockk()
val loginUserOperationExecutor =
- LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext())
+ LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true))
val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId"))
// When
@@ -278,7 +284,7 @@ class LoginUserOperationExecutorTests : FunSpec({
val mockSubscriptionsModelStore = mockk()
val loginUserOperationExecutor =
- LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext())
+ LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true))
val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId"))
// When
@@ -314,7 +320,7 @@ class LoginUserOperationExecutorTests : FunSpec({
val mockSubscriptionsModelStore = mockk()
val loginUserOperationExecutor =
- LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext())
+ LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true))
val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId"))
// When
@@ -352,7 +358,7 @@ class LoginUserOperationExecutorTests : FunSpec({
val mockSubscriptionsModelStore = mockk()
val loginUserOperationExecutor =
- LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext())
+ LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true))
val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId"))
// When
@@ -403,6 +409,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations =
listOf(
@@ -410,6 +417,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
"subscriptionId1",
SubscriptionType.PUSH,
true,
@@ -419,6 +427,7 @@ class LoginUserOperationExecutorTests : FunSpec({
UpdateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
"subscriptionId1",
SubscriptionType.PUSH,
true,
@@ -428,13 +437,14 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
"subscriptionId2",
SubscriptionType.EMAIL,
true,
"name@company.com",
SubscriptionStatus.SUBSCRIBED,
),
- DeleteSubscriptionOperation(appId, localOneSignalId, "subscriptionId2"),
+ DeleteSubscriptionOperation(appId, localOneSignalId, null, "subscriptionId2"),
)
// When
@@ -504,6 +514,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations =
listOf(
@@ -511,6 +522,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
localSubscriptionId1,
SubscriptionType.PUSH,
true,
@@ -520,6 +532,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
localSubscriptionId2,
SubscriptionType.EMAIL,
true,
@@ -590,6 +603,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations =
listOf(
@@ -597,6 +611,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
localSubscriptionId1,
SubscriptionType.PUSH,
true,
@@ -606,6 +621,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
localSubscriptionId2,
SubscriptionType.EMAIL,
true,
@@ -662,6 +678,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val operations =
listOf(
@@ -669,6 +686,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
localSubscriptionId1,
SubscriptionType.PUSH,
true,
@@ -678,6 +696,7 @@ class LoginUserOperationExecutorTests : FunSpec({
CreateSubscriptionOperation(
appId,
localOneSignalId,
+ null,
localSubscriptionId2,
SubscriptionType.EMAIL,
true,
@@ -725,6 +744,7 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
// anonymous Login request
val operations = listOf(LoginUserOperation(appId, localOneSignalId, null, null))
@@ -771,14 +791,15 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
MockHelper.configModelStore(),
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
// send PUSH then EMAIL (local IDs 1,2) — order differs from backend response
val ops =
listOf(
LoginUserOperation(appId, localOneSignalId, null, null),
- CreateSubscriptionOperation(appId, localOneSignalId, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken2", SubscriptionStatus.SUBSCRIBED),
- CreateSubscriptionOperation(appId, localOneSignalId, localSubscriptionId2, SubscriptionType.EMAIL, true, "name@company.com", SubscriptionStatus.SUBSCRIBED),
+ CreateSubscriptionOperation(appId, localOneSignalId, null, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken2", SubscriptionStatus.SUBSCRIBED),
+ CreateSubscriptionOperation(appId, localOneSignalId, null, localSubscriptionId2, SubscriptionType.EMAIL, true, "name@company.com", SubscriptionStatus.SUBSCRIBED),
)
// When
@@ -835,12 +856,13 @@ class LoginUserOperationExecutorTests : FunSpec({
mockSubscriptionsModelStore,
configModelStore,
MockHelper.languageContext(),
+ getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true),
)
val ops =
listOf(
LoginUserOperation(appId, localOneSignalId, null, null),
- CreateSubscriptionOperation(appId, localOneSignalId, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken1", SubscriptionStatus.SUBSCRIBED),
+ CreateSubscriptionOperation(appId, localOneSignalId, null, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken1", SubscriptionStatus.SUBSCRIBED),
)
// When
@@ -859,4 +881,66 @@ class LoginUserOperationExecutorTests : FunSpec({
configModelStore.model.pushSubscriptionId shouldBe remoteSubscriptionId1
coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) }
}
+
+ test("IV active: loginUser with existingOnesignalId + externalId goes straight to createUser, skipping optimistic SetAliasOperation") {
+ // Given: IV active. Under legacy or Phase 3 this input would hit the optimistic-merge
+ // SetAliasOperation path; under IV we must skip that because IdentityOperationExecutor
+ // would resolve alias to (external_id, newExternalId) and target the wrong user.
+ val mockUserBackendService = mockk()
+ coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns
+ CreateUserResponse(
+ mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId),
+ PropertiesObject(),
+ listOf(),
+ )
+
+ // Strict mock: if the optimistic-merge path is reached, the test fails because no
+ // `execute` stub is registered on IdentityOperationExecutor.
+ val mockIdentityOperationExecutor = mockk()
+
+ val mockIdentityModelStore = MockHelper.identityModelStore()
+ val mockPropertiesModelStore = MockHelper.propertiesModelStore()
+ val mockSubscriptionsModelStore = mockk()
+
+ val loginUserOperationExecutor =
+ LoginUserOperationExecutor(
+ mockIdentityOperationExecutor,
+ AndroidMockHelper.applicationService(),
+ MockHelper.deviceService(),
+ mockUserBackendService,
+ mockIdentityModelStore,
+ mockPropertiesModelStore,
+ mockSubscriptionsModelStore,
+ MockHelper.configModelStore(),
+ MockHelper.languageContext(),
+ getJwtTokenStore(),
+ getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true),
+ mockk(relaxed = true),
+ )
+
+ // LoginUserOperation has existingOnesignalId AND externalId — the input shape that
+ // triggers the optimistic-merge branch under legacy.
+ val operations =
+ listOf(
+ LoginUserOperation(appId, localOneSignalId, "new-external-id", "existing-osid"),
+ )
+
+ // When
+ val response = loginUserOperationExecutor.execute(operations)
+
+ // Then
+ response.result shouldBe ExecutionResult.SUCCESS
+ coVerify(exactly = 1) {
+ mockUserBackendService.createUser(
+ appId,
+ mapOf(IdentityConstants.EXTERNAL_ID to "new-external-id"),
+ any(),
+ any(),
+ any(),
+ )
+ }
+ // IdentityOperationExecutor must NOT be invoked (strict mock enforces this via kotest's
+ // unstubbed-call failure; coVerify(exactly = 0) makes the expectation explicit).
+ coVerify(exactly = 0) { mockIdentityOperationExecutor.execute(any()) }
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt
index 2689d761af..43aa2ef827 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt
@@ -14,6 +14,8 @@ import com.onesignal.user.internal.backend.SubscriptionObject
import com.onesignal.user.internal.backend.SubscriptionObjectType
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.identity.IdentityModel
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentityVerificationService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
import com.onesignal.user.internal.operations.impl.executors.RefreshUserOperationExecutor
import com.onesignal.user.internal.properties.PropertiesModel
@@ -107,9 +109,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
mockConfigModelStore,
mockBuildUserService,
getNewRecordState(),
+ getJwtTokenStore(), getIdentityVerificationService(),
)
- val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId))
+ val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null))
try {
// When
@@ -191,9 +194,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
MockHelper.configModelStore(),
mockBuildUserService,
getNewRecordState(),
+ getJwtTokenStore(), getIdentityVerificationService(),
)
- val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId))
+ val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null))
// When
val response = refreshUserOperationExecutor.execute(operations)
@@ -230,9 +234,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
MockHelper.configModelStore(),
mockBuildUserService,
getNewRecordState(),
+ getJwtTokenStore(), getIdentityVerificationService(),
)
- val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId))
+ val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null))
// When
val response = refreshUserOperationExecutor.execute(operations)
@@ -265,9 +270,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
MockHelper.configModelStore(),
mockBuildUserService,
getNewRecordState(),
+ getJwtTokenStore(), getIdentityVerificationService(),
)
- val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId))
+ val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null))
// When
val response = refreshUserOperationExecutor.execute(operations)
@@ -300,9 +306,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
MockHelper.configModelStore(),
mockBuildUserService,
getNewRecordState(),
+ getJwtTokenStore(), getIdentityVerificationService(),
)
- val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId))
+ val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null))
// When
val response = refreshUserOperationExecutor.execute(operations)
@@ -337,9 +344,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
MockHelper.configModelStore(),
mockBuildUserService,
newRecordState,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
- val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId))
+ val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null))
// When
val response = refreshUserOperationExecutor.execute(operations)
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt
index 4ae3053247..19afa4c4dd 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt
@@ -13,6 +13,8 @@ import com.onesignal.user.internal.backend.ISubscriptionBackendService
import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.backend.SubscriptionObjectType
import com.onesignal.user.internal.builduser.IRebuildUserService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentityVerificationService
+import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
import com.onesignal.user.internal.operations.impl.executors.SubscriptionOperationExecutor
import com.onesignal.user.internal.subscriptions.SubscriptionModel
@@ -68,6 +70,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -75,6 +78,7 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -128,6 +132,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -135,6 +140,7 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -178,6 +184,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -185,6 +192,7 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -233,6 +241,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -240,6 +249,7 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -288,6 +298,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
newRecordState,
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -295,6 +306,7 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -331,6 +343,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -338,13 +351,14 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
"pushToken",
SubscriptionStatus.SUBSCRIBED,
),
- DeleteSubscriptionOperation(appId, remoteOneSignalId, localSubscriptionId),
+ DeleteSubscriptionOperation(appId, remoteOneSignalId, null, localSubscriptionId),
)
// When
@@ -377,6 +391,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -384,6 +399,7 @@ class SubscriptionOperationExecutorTests :
CreateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -393,6 +409,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
localSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -447,6 +464,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -454,6 +472,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -463,6 +482,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -508,6 +528,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -515,6 +536,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -560,6 +582,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -567,6 +590,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -614,6 +638,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
newRecordState,
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -621,6 +646,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -656,11 +682,12 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
listOf(
- DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId),
+ DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId),
)
// When
@@ -690,11 +717,12 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
listOf(
- DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId),
+ DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId),
)
// When
@@ -725,11 +753,12 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
listOf(
- DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId),
+ DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId),
)
// When
@@ -760,11 +789,12 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
newRecordState,
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
listOf(
- DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId),
+ DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId),
)
// When
@@ -801,6 +831,7 @@ class SubscriptionOperationExecutorTests :
mockBuildUserService,
getNewRecordState(),
mockConsistencyManager,
+ getJwtTokenStore(), getIdentityVerificationService(),
)
val operations =
@@ -808,6 +839,7 @@ class SubscriptionOperationExecutorTests :
UpdateSubscriptionOperation(
appId,
remoteOneSignalId,
+ null,
remoteSubscriptionId,
SubscriptionType.PUSH,
true,
@@ -823,4 +855,100 @@ class SubscriptionOperationExecutorTests :
mockConsistencyManager.setRywData(remoteOneSignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywData)
}
}
+
+ test("update subscription returns FAIL_UNAUTHORIZED on 401 so JWT gets invalidated under IV") {
+ // Given
+ val mockSubscriptionBackendService = mockk()
+ coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any(), any()) } throws
+ BackendException(401, "UNAUTHORIZED", retryAfterSeconds = 5)
+
+ val subscriptionOperationExecutor =
+ SubscriptionOperationExecutor(
+ mockSubscriptionBackendService,
+ MockHelper.deviceService(),
+ AndroidMockHelper.applicationService(),
+ mockk(),
+ MockHelper.configModelStore(),
+ mockk(),
+ getNewRecordState(),
+ mockConsistencyManager,
+ getJwtTokenStore(),
+ getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true),
+ )
+ val operations =
+ listOf(
+ UpdateSubscriptionOperation(
+ appId, remoteOneSignalId, "ext-1", remoteSubscriptionId,
+ SubscriptionType.PUSH, true, "pushToken2", SubscriptionStatus.SUBSCRIBED,
+ ),
+ )
+
+ // When
+ val response = subscriptionOperationExecutor.execute(operations)
+
+ // Then — must be FAIL_UNAUTHORIZED so OperationRepo.handleFailUnauthorized fires.
+ response.result shouldBe ExecutionResult.FAIL_UNAUTHORIZED
+ response.retryAfterSeconds shouldBe 5
+ }
+
+ test("delete subscription returns FAIL_UNAUTHORIZED on 401 so JWT gets invalidated under IV") {
+ // Given
+ val mockSubscriptionBackendService = mockk()
+ coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any(), any()) } throws
+ BackendException(401, "UNAUTHORIZED", retryAfterSeconds = 5)
+
+ val subscriptionOperationExecutor =
+ SubscriptionOperationExecutor(
+ mockSubscriptionBackendService,
+ MockHelper.deviceService(),
+ AndroidMockHelper.applicationService(),
+ mockk(),
+ MockHelper.configModelStore(),
+ mockk