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(), + getNewRecordState(), + mockConsistencyManager, + getJwtTokenStore(), + getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true), + ) + val operations = + listOf(DeleteSubscriptionOperation(appId, remoteOneSignalId, "ext-1", remoteSubscriptionId)) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_UNAUTHORIZED + response.retryAfterSeconds shouldBe 5 + } + + test("transfer subscription returns FAIL_UNAUTHORIZED on 401 so JWT gets invalidated under IV") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { + mockSubscriptionBackendService.transferSubscription(any(), any(), any(), any(), any()) + } throws BackendException(403, "FORBIDDEN", 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(TransferSubscriptionOperation(appId, remoteOneSignalId, "ext-1", remoteSubscriptionId)) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_UNAUTHORIZED + response.retryAfterSeconds shouldBe 5 + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt index de2e148ff8..21e3bfdf17 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt @@ -10,6 +10,8 @@ import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.backend.IUserBackendService import com.onesignal.user.internal.backend.IdentityConstants 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.UpdateUserOperationExecutor import com.onesignal.user.internal.properties.PropertiesModel @@ -56,8 +58,9 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1")) // When val response = loginUserOperationExecutor.execute(operations) @@ -96,24 +99,25 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) val operations = listOf( - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-1"), - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-2"), - SetTagOperation(appId, remoteOneSignalId, "tagKey2", "tagValue2"), - SetTagOperation(appId, remoteOneSignalId, "tagKey3", "tagValue3"), - DeleteTagOperation(appId, remoteOneSignalId, "tagKey3"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang1"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang2"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::timezone.name, "timezone"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::country.name, "country"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLatitude.name, 123.45), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLongitude.name, 678.90), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationType.name, 1), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationAccuracy.name, 0.15), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationBackground.name, true), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationTimestamp.name, 1111L), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1-1"), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1-2"), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey2", "tagValue2"), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey3", "tagValue3"), + DeleteTagOperation(appId, remoteOneSignalId, null, "tagKey3"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::language.name, "lang1"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::language.name, "lang2"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::timezone.name, "timezone"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::country.name, "country"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationLatitude.name, 123.45), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationLongitude.name, 678.90), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationType.name, 1), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationAccuracy.name, 0.15), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationBackground.name, true), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationTimestamp.name, 1111L), ) // When @@ -158,10 +162,11 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) val operations = listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 1111), ) // When @@ -203,13 +208,15 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) val operations = listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 1111), TrackPurchaseOperation( appId, remoteOneSignalId, + null, false, BigDecimal(2222), listOf( @@ -217,7 +224,7 @@ class UpdateUserOperationExecutorTests : PurchaseInfo("sku2", "iso2", BigDecimal(1222)), ), ), - TrackSessionEndOperation(appId, remoteOneSignalId, 3333), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 3333), ) // When @@ -268,12 +275,13 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) val operations = listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1"), - TrackSessionEndOperation(appId, remoteOneSignalId, 3333), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 1111), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1"), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 3333), ) // When @@ -316,8 +324,9 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1")) // When val response = loginUserOperationExecutor.execute(operations) @@ -349,8 +358,9 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1")) // When val response = loginUserOperationExecutor.execute(operations) @@ -379,11 +389,12 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + getJwtTokenStore(), getIdentityVerificationService(), ) val operations = listOf( - TrackSessionStartOperation(appId, onesignalId = remoteOneSignalId), + TrackSessionStartOperation(appId, remoteOneSignalId, null), ) // When diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index b4994a0aeb..cc42e2371c 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -18,6 +18,7 @@ import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService 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.language.ILanguageContext import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime @@ -48,6 +49,8 @@ import com.onesignal.user.IUserManager import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.jwt.IJwtUpdateListener +import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionChangedHandler import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -76,6 +79,8 @@ internal class InAppMessagesManager( private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, + private val _identityVerificationService: IdentityVerificationService, ) : IInAppMessagesManager, IStartableService, ISubscriptionChangedHandler, @@ -83,7 +88,8 @@ internal class InAppMessagesManager( IInAppLifecycleEventHandler, ITriggerHandler, ISessionLifecycleHandler, - IApplicationLifecycleHandler { + IApplicationLifecycleHandler, + IJwtUpdateListener { private val lifecycleCallback = EventProducer() private val messageClickCallback = EventProducer() @@ -114,8 +120,19 @@ internal class InAppMessagesManager( private val redisplayedInAppMessages: MutableList = mutableListOf() private val fetchIAMMutex = Mutex() + + @Volatile private var lastTimeFetchedIAMs: Long? = null + // Pending JWT-retry state under IV. When the IAM fetch returns 401/403 we save + // the externalId we were trying to fetch for + the rywData; on the next JwtTokenStore + // update for the same externalId, IAMs are re-fetched. Cleared on user-switch. + @Volatile + private var pendingJwtRetryExternalId: String? = null + + @Volatile + private var pendingJwtRetryRywData: RywData? = null + // Tracks whether the first IAM fetch has completed since this cold start private var hasCompletedFirstFetch: Boolean = false @@ -127,7 +144,12 @@ internal class InAppMessagesManager( override fun onModelReplaced( model: IdentityModel, tag: String, - ) { } + ) { + // User-switch (login or logout): drop any pending JWT retry — the externalId + // we were waiting on isn't the current user anymore. + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + } override fun onModelUpdated( args: ModelChangedArgs, @@ -190,6 +212,10 @@ internal class InAppMessagesManager( _sessionService.subscribe(this) _applicationService.addApplicationLifecycleHandler(this) _identityModelStore.subscribe(identityModelChangeHandler) + // Subscribe to JwtTokenStore so a JWT refresh (developer responding to a previous 401) + // can drive a deferred IAM re-fetch under IV. Subscription is ungated; the listener + // body checks for a pending retry, which is only set on the IV fetch path. + _jwtTokenStore.addInternalUpdateListener(this) suspendifyOnIO { _repository.cleanCachedInAppMessages() @@ -310,7 +336,12 @@ internal class InAppMessagesManager( // lambda so that it is updated on each potential retry val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime } - val newMessages = _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider) + val newMessages = + if (_identityVerificationService.newCodePathsRun) { + fetchIvOrSaveRetry(appId, subscriptionId, rywData, sessionDurationProvider) + } else { + _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider) + } if (newMessages != null) { this.messages = newMessages as MutableList @@ -338,6 +369,65 @@ internal class InAppMessagesManager( } } + /** + * IV-aware IAM fetch. Resolves alias + JWT based on `ivBehaviorActive`, calls the + * alias-based backend endpoint, and on 401/403 saves retry state so [onJwtUpdated] + * can re-fetch once the developer supplies a refreshed JWT. + * + * Phase 3 path (newCodePathsRun=true, ivBehaviorActive=false): uses onesignal_id alias + * with no JWT — exercises the new endpoint structurally without IV-specific behavior. + */ + private suspend fun fetchIvOrSaveRetry( + appId: String, + subscriptionId: String, + rywData: RywData, + sessionDurationProvider: () -> Long, + ): List? { + val ivBehaviorActive = _identityVerificationService.ivBehaviorActive + val externalId = _identityModelStore.model.externalId + val onesignalId = _identityModelStore.model.onesignalId + + val (aliasLabel, aliasValue, jwt) = + if (ivBehaviorActive && externalId != null) { + Triple(IdentityConstants.EXTERNAL_ID, externalId, _jwtTokenStore.getJwt(externalId)) + } else { + Triple(IdentityConstants.ONESIGNAL_ID, onesignalId, null) + } + + // Set pending state before the call so a mid-flight onJwtUpdated finds it; clear on success. + if (ivBehaviorActive && externalId != null) { + pendingJwtRetryExternalId = externalId + pendingJwtRetryRywData = rywData + } + return try { + val result = _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + result + } catch (ex: BackendException) { + if (ivBehaviorActive && externalId != null) { + Logging.info("InAppMessagesManager: IAM fetch returned ${ex.statusCode}, awaiting JWT refresh for $externalId") + // Reset the rate-limiter so the retry isn't throttled. + lastTimeFetchedIAMs = null + } else { + Logging.warn("InAppMessagesManager: IAM fetch returned ${ex.statusCode}: ${ex.response}") + } + null + } + } + + // IJwtUpdateListener — fires when JwtTokenStore.putJwt or invalidateJwt runs for an externalId. + override fun onJwtUpdated(externalId: String) { + val pending = pendingJwtRetryExternalId + val pendingRyw = pendingJwtRetryRywData + if (pending == null || pending != externalId || pendingRyw == null) return + // Clear before retry so concurrent fires don't re-enter. + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + Logging.info("InAppMessagesManager: JWT refreshed for $externalId, retrying IAM fetch") + suspendifyOnIO { fetchMessages(pendingRyw) } + } + /** * Iterate through the messages and determine if they should be shown to the user. */ diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt index 6755b6eb5a..563413f664 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt @@ -26,6 +26,29 @@ internal interface IInAppBackendService { sessionDurationProvider: () -> Long, ): List? + /** + * IV-aware list of in-app messages. Hits the alias-based endpoint + * (`apps/{appId}/users/by/{aliasLabel}/{aliasValue}/subscriptions/{subscriptionId}/iams`) + * and attaches a JWT bearer token when supplied. Used only when + * `IdentityVerificationService.newCodePathsRun` is true; Phase 1 callers continue to use + * [listInAppMessages]. + * + * Throws [BackendException] on 401/403 so the caller can save retry state and re-fetch + * once the JWT is refreshed (via `IUserJwtInvalidatedListener` → `updateUserJwt`). + * + * [rywData] may be null when this is a fallback retry after the RYW-aware path exhausted + * its retry budget — in which case the request is sent without the RYW token. + */ + suspend fun listInAppMessagesIv( + appId: String, + aliasLabel: String, + aliasValue: String, + subscriptionId: String, + rywData: RywData?, + sessionDurationProvider: () -> Long, + jwt: String?, + ): List? + /** * Retrieve the data for a specific In App Message. * diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index 9bbd738d55..7e381f3f6d 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -34,7 +34,29 @@ internal class InAppBackendService( delay(rywDelay) // Delay by the specified amount val baseUrl = "apps/$appId/subscriptions/$subscriptionId/iams" - return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider) + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt = null) + } + + override suspend fun listInAppMessagesIv( + appId: String, + aliasLabel: String, + aliasValue: String, + subscriptionId: String, + rywData: RywData?, + sessionDurationProvider: () -> Long, + jwt: String?, + ): List? { + val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" + + // Fallback path: caller exhausted RYW-aware retries and is asking for a no-RYW fetch + // (e.g. after stale RYW token). Skip the RYW-token header; let the request through. + if (rywData == null) { + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) + } + + val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS + delay(rywDelay) + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } override suspend fun getIAMData( @@ -209,6 +231,7 @@ internal class InAppBackendService( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String?, ): List? { var attempts = 0 var retryLimit: Int = 0 // retry limit is remote defined & set dynamically below @@ -220,6 +243,7 @@ internal class InAppBackendService( rywToken = rywData.rywToken, sessionDuration = sessionDurationProvider(), retryCount = retryCount, + jwt = jwt, ) val response = _httpClient.get(baseUrl, values) @@ -234,6 +258,10 @@ internal class InAppBackendService( response.retryAfterSeconds?.let { delay(it * 1_000L) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + // 401/403 — caller (InAppMessagesManager IV path) needs to surface this so it can + // save retry state and re-fetch once the JWT is refreshed. + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else if (response.statusCode in 500..599) { return null } else { @@ -244,24 +272,28 @@ internal class InAppBackendService( } while (attempts <= retryLimit) // Final attempt without the RYW token if retries fail - return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider) + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) } private suspend fun fetchInAppMessagesWithoutRywToken( url: String, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? { val response = _httpClient.get( url, OptionalHeaders( sessionDuration = sessionDurationProvider(), + jwt = jwt, ), ) if (response.isSuccess) { val jsonResponse = response.payload?.let { JSONObject(it) } return jsonResponse?.let { hydrateInAppMessages(it) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else { return null } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 418cce53cc..3f9525bf15 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -89,6 +89,15 @@ private class Mocks { coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred } + val jwtTokenStore = mockk(relaxed = true) { + every { getJwt(any()) } returns null + } + + val identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + } + val subscriptionManager = mockk(relaxed = true) { every { subscriptions } returns mockk { every { push } returns pushSubscription @@ -187,6 +196,8 @@ private class Mocks { languageContext, time, consistencyManager, + jwtTokenStore, + identityVerificationService, ) } diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt index 188cc66cbd..3009177772 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt @@ -252,7 +252,7 @@ class DeviceRegistrationListenerTests : FunSpec({ permission = true, pushModel = uninitializedPushModel(), pushTokenResponse = - PushTokenResponse(NEW_TOKEN, SubscriptionStatus.SUBSCRIBED), + PushTokenResponse(NEW_TOKEN, SubscriptionStatus.SUBSCRIBED), ) // Permission flips off between gate evaluation and the IO callback. every { harness.notificationsManager.permission } returns true andThen false diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt index 8982aefc85..09a65333df 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt @@ -167,12 +167,13 @@ object OneSignalService { * Fetch user data from OneSignal API. * Note: This endpoint does not require authentication. * - * @param onesignalId The OneSignal user ID + * @param aliasLabel The alias type to look up by (e.g. "onesignal_id" or "external_id") + * @param aliasValue The alias value * @return UserData object containing aliases, tags, emails, and SMS numbers, or null on error */ - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - if (onesignalId.isEmpty()) { - LogManager.w(TAG, "Cannot fetch user - onesignalId is empty") + suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { + if (aliasValue.isEmpty()) { + LogManager.w(TAG, "Cannot fetch user - aliasValue is empty") return@withContext null } @@ -180,9 +181,9 @@ object OneSignalService { LogManager.w(TAG, "Cannot fetch user - appId not set") return@withContext null } - + try { - val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/onesignal_id/$onesignalId" + val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/$aliasLabel/$aliasValue" LogManager.d(TAG, "Fetching user data from: $url") val connection = (URL(url).openConnection() as HttpURLConnection).apply { @@ -190,6 +191,9 @@ object OneSignalService { connectTimeout = 30000 readTimeout = 30000 setRequestProperty("Accept", "application/json") + if (jwt != null) { + setRequestProperty("Authorization", "Bearer $jwt") + } requestMethod = "GET" } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt index 70696e54fd..774b03fd97 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt @@ -19,12 +19,17 @@ class OneSignalRepository { } // User operations - suspend fun loginUser(externalUserId: String) = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging in user with externalUserId: $externalUserId") - OneSignal.login(externalUserId) + suspend fun loginUser(externalUserId: String, jwtToken: String? = null) = withContext(Dispatchers.IO) { + Log.d(TAG, "Logging in user with externalUserId: $externalUserId, jwt: ${if (jwtToken != null) "provided" else "none"}") + OneSignal.login(externalUserId, jwtToken) Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}") } + suspend fun updateUserJwt(externalUserId: String, jwtToken: String) = withContext(Dispatchers.IO) { + Log.d(TAG, "Updating JWT for externalUserId: $externalUserId") + OneSignal.updateUserJwt(externalUserId, jwtToken) + } + suspend fun logoutUser() = withContext(Dispatchers.IO) { Log.d(TAG, "Logging out user") OneSignal.logout() @@ -236,8 +241,8 @@ class OneSignalRepository { } // Fetch user data from API - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching user data for: $onesignalId") - OneSignalService.fetchUser(onesignalId) + suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { + Log.d(TAG, "Fetching user data by $aliasLabel: $aliasValue") + OneSignalService.fetchUser(aliasLabel, aliasValue, jwt) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt index 8045664984..f4dcb99ba2 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt @@ -341,18 +341,60 @@ fun MultiSelectRemoveDialog( } /** - * Dialog for login/switch user. + * Dialog for login/switch user with optional JWT token. */ @Composable fun LoginDialog( onDismiss: () -> Unit, - onConfirm: (String) -> Unit + onConfirm: (String, String?) -> Unit ) { - SingleInputDialog( - title = "Login User", - label = "External User Id", - onDismiss = onDismiss, - onConfirm = onConfirm + var externalId by remember { mutableStateOf("") } + var jwtToken by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), + properties = DialogProperties(usePlatformDefaultWidth = false), + title = { + Text("Login User", style = MaterialTheme.typography.titleMedium) + }, + text = { + Column { + OutlinedTextField( + value = externalId, + onValueChange = { externalId = it }, + label = { Text("External User Id") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = jwtToken, + onValueChange = { jwtToken = it }, + label = { Text("JWT Token (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(externalId, jwtToken.ifBlank { null }) }, + enabled = externalId.isNotBlank() + ) { + Text("Login") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) ) } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt index 82dfc01183..6d0ccfb100 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt @@ -69,6 +69,7 @@ fun MainScreen(viewModel: MainViewModel) { val consentRequired by viewModel.consentRequired.observeAsState(false) val privacyConsentGiven by viewModel.privacyConsentGiven.observeAsState(false) val externalUserId by viewModel.externalUserId.observeAsState() + val useIdentityVerification by viewModel.useIdentityVerification.observeAsState(false) val aliases by viewModel.aliases.observeAsState(emptyList()) val emails by viewModel.emails.observeAsState(emptyList()) val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList()) @@ -80,6 +81,7 @@ fun MainScreen(viewModel: MainViewModel) { // Dialog states var showLoginDialog by remember { mutableStateOf(false) } + var showUpdateJwtDialog by remember { mutableStateOf(false) } var showAddAliasDialog by remember { mutableStateOf(false) } var showAddMultipleAliasDialog by remember { mutableStateOf(false) } var showAddEmailDialog by remember { mutableStateOf(false) } @@ -159,8 +161,11 @@ fun MainScreen(viewModel: MainViewModel) { // === USER SECTION === UserSection( externalUserId = externalUserId, + useIdentityVerification = useIdentityVerification, + onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, onLoginClick = { showLoginDialog = true }, - onLogoutClick = { viewModel.logoutUser() } + onLogoutClick = { viewModel.logoutUser() }, + onUpdateJwtClick = { showUpdateJwtDialog = true } ) // === PUSH SECTION === @@ -284,13 +289,26 @@ fun MainScreen(viewModel: MainViewModel) { if (showLoginDialog) { LoginDialog( onDismiss = { showLoginDialog = false }, - onConfirm = { userId -> - viewModel.loginUser(userId) + onConfirm = { userId, jwt -> + viewModel.loginUser(userId, jwt) showLoginDialog = false } ) } - + + if (showUpdateJwtDialog) { + PairInputDialog( + title = "Update User JWT", + keyLabel = "External User Id", + valueLabel = "JWT Token", + onDismiss = { showUpdateJwtDialog = false }, + onConfirm = { externalId, token -> + viewModel.updateUserJwt(externalId, token) + showUpdateJwtDialog = false + } + ) + } + if (showAddAliasDialog) { PairInputDialog( title = "Add Alias", diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index e65af736d2..46a7d4f50d 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -5,7 +5,9 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.onesignal.IUserJwtInvalidatedListener import com.onesignal.OneSignal +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.notifications.IPermissionObserver import com.onesignal.sdktest.data.model.NotificationType import com.onesignal.sdktest.data.repository.OneSignalRepository @@ -19,7 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver { +class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver, IUserJwtInvalidatedListener { private val repository = OneSignalRepository() @@ -74,6 +76,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private val _locationShared = MutableLiveData() val locationShared: LiveData = _locationShared + // Identity Verification toggle (demo app only, controls alias used for API calls) + private val _useIdentityVerification = MutableLiveData() + val useIdentityVerification: LiveData = _useIdentityVerification + // Toast messages private val _toastMessage = MutableLiveData() val toastMessage: LiveData = _toastMessage @@ -99,6 +105,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I OneSignal.User.pushSubscription.addObserver(this) OneSignal.Notifications.addPermissionObserver(this) OneSignal.User.addObserver(this) + OneSignal.addUserJwtInvalidatedListener(this) android.util.Log.d("MainViewModel", "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") LogManager.debug("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") } @@ -127,6 +134,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _privacyConsentGiven.value = repository.getPrivacyConsent() _inAppMessagesPaused.value = repository.isInAppMessagesPaused() _locationShared.value = repository.isLocationShared() + _useIdentityVerification.value = SharedPreferenceUtil.getCachedIdentityVerification(context) val externalId = OneSignal.User.externalId _externalUserId.value = if (externalId.isEmpty()) null else externalId @@ -145,16 +153,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } fun fetchUserDataFromApi() { - val onesignalId = OneSignal.User.onesignalId - if (onesignalId.isNullOrEmpty()) { - _isLoading.value = false - return + val useIV = _useIdentityVerification.value == true + val aliasLabel: String + val aliasValue: String + + if (useIV) { + val externalId = _externalUserId.value + if (externalId.isNullOrEmpty()) { + _isLoading.value = false + return + } + aliasLabel = "external_id" + aliasValue = externalId + } else { + val onesignalId = OneSignal.User.onesignalId + if (onesignalId.isNullOrEmpty()) { + _isLoading.value = false + return + } + aliasLabel = "onesignal_id" + aliasValue = onesignalId } + val jwt = if (useIV) SharedPreferenceUtil.getCachedJwtToken(getApplication()) else null + _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { try { - val userData = repository.fetchUser(onesignalId) + val userData = repository.fetchUser(aliasLabel, aliasValue, jwt) withContext(Dispatchers.Main) { if (userData != null) { aliasesList.clear() @@ -217,12 +243,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private fun refreshTriggers() { _triggers.value = triggersList.toList() } // User operations - fun loginUser(externalUserId: String) { + fun loginUser(externalUserId: String, jwtToken: String? = null) { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { - repository.loginUser(externalUserId) + repository.loginUser(externalUserId, jwtToken) withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) _externalUserId.value = externalUserId showToast("Logged in as: $externalUserId") aliasesList.clear() @@ -235,7 +262,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshTriggers() loadExistingTags() refreshPushSubscription() - // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it + _isLoading.value = false + } + } + } + + fun updateUserJwt(externalUserId: String, jwtToken: String) { + viewModelScope.launch(Dispatchers.IO) { + repository.updateUserJwt(externalUserId, jwtToken) + withContext(Dispatchers.Main) { + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) + showToast("Updated JWT for: $externalUserId") } } } @@ -262,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + fun setUseIdentityVerification(enabled: Boolean) { + SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled) + _useIdentityVerification.value = enabled + showToast(if (enabled) "Identity verification enabled" else "Identity verification disabled") + } + // Consent required fun setConsentRequired(required: Boolean) { repository.setConsentRequired(required) @@ -619,8 +662,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _pushEnabled.postValue(state.current.optedIn) } + override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { + LogManager.warn("JWT invalidated for externalId: ${event.externalId}") + showToast("JWT invalidated for: ${event.externalId}") + } + override fun onCleared() { super.onCleared() OneSignal.User.pushSubscription.removeObserver(this) + OneSignal.removeUserJwtInvalidatedListener(this) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt index f672d322c1..7cc769d838 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt @@ -137,12 +137,22 @@ fun AppSection( @Composable fun UserSection( externalUserId: String?, + useIdentityVerification: Boolean, + onUseIdentityVerificationChange: (Boolean) -> Unit, onLoginClick: () -> Unit, - onLogoutClick: () -> Unit + onLogoutClick: () -> Unit, + onUpdateJwtClick: () -> Unit ) { val isLoggedIn = !externalUserId.isNullOrEmpty() SectionCard(title = "User") { + ToggleRow( + label = "Identity Verification", + description = "Use external_id for API calls", + checked = useIdentityVerification, + onCheckedChange = onUseIdentityVerificationChange + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) // Status Row( modifier = Modifier @@ -200,6 +210,11 @@ fun UserSection( onClick = onLogoutClick ) } + + OutlineButton( + text = "UPDATE USER JWT", + onClick = onUpdateJwtClick + ) } // === PUSH SECTION === diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt index f3b93dfb00..1cef40b592 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt @@ -12,6 +12,8 @@ object SharedPreferenceUtil { private const val LOCATION_SHARED_PREF = "LOCATION_SHARED_PREF" private const val IN_APP_MESSAGING_PAUSED_PREF = "IN_APP_MESSAGING_PAUSED_PREF" private const val CONSENT_REQUIRED_PREF = "CONSENT_REQUIRED_PREF" + private const val IDENTITY_VERIFICATION_PREF = "IDENTITY_VERIFICATION_PREF" + private const val JWT_TOKEN_PREF = "JWT_TOKEN_PREF" private fun getSharedPreference(context: Context): SharedPreferences { return context.getSharedPreferences(APP_SHARED_PREFS, Context.MODE_PRIVATE) @@ -69,4 +71,20 @@ object SharedPreferenceUtil { fun cacheConsentRequired(context: Context, required: Boolean) { getSharedPreference(context).edit().putBoolean(CONSENT_REQUIRED_PREF, required).apply() } + + fun getCachedIdentityVerification(context: Context): Boolean { + return getSharedPreference(context).getBoolean(IDENTITY_VERIFICATION_PREF, false) + } + + fun cacheIdentityVerification(context: Context, enabled: Boolean) { + getSharedPreference(context).edit().putBoolean(IDENTITY_VERIFICATION_PREF, enabled).apply() + } + + fun getCachedJwtToken(context: Context): String? { + return getSharedPreference(context).getString(JWT_TOKEN_PREF, null) + } + + fun cacheJwtToken(context: Context, token: String?) { + getSharedPreference(context).edit().putString(JWT_TOKEN_PREF, token).apply() + } }