diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 0530b2d0eb..b5361b0bb7 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -22,6 +22,7 @@ ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _paramsBackendService: IParamsBackendService ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _subscriptionManager: ISubscriptionManager + ConstructorParameterNaming:CustomEventOperationExecutor.kt$CustomEventOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:DatabaseCursor.kt$DatabaseCursor$private val _cursor: Cursor ConstructorParameterNaming:DatabaseProvider.kt$DatabaseProvider$private val _application: IApplicationService ConstructorParameterNaming:DeviceService.kt$DeviceService$private val _applicationService: IApplicationService @@ -33,16 +34,26 @@ ConstructorParameterNaming:HttpConnectionFactory.kt$HttpConnectionFactory$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:IdentityBackendService.kt$IdentityBackendService$private val _httpClient: IHttpClient ConstructorParameterNaming:IdentityModelStoreListener.kt$IdentityModelStoreListener$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:IdentityModelStoreListener.kt$IdentityModelStoreListener$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _buildUserService: IRebuildUserService + ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityBackend: IIdentityBackendService ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _newRecordState: NewRecordsState + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _jwtTokenStore: JwtTokenStore + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _operationRepo: IOperationRepo + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _userManager: UserManager 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 _configModelStore: ConfigModelStore ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService @@ -51,6 +62,7 @@ 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 _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 +74,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 _identityModelStore: IdentityModelStore + 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 +95,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 +108,7 @@ 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 _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 +139,7 @@ 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 _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 @@ -132,8 +149,10 @@ ConstructorParameterNaming:TrackGooglePurchase.kt$TrackGooglePurchase$private val _operationRepo: IOperationRepo ConstructorParameterNaming:TrackGooglePurchase.kt$TrackGooglePurchase$private val _prefs: IPreferencesService ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _buildUserService: IRebuildUserService + ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _consistencyManager: IConsistencyManager ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _identityModelStore: IdentityModelStore + 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,10 +177,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 ForbiddenComment:PermissionsViewModel.kt$PermissionsViewModel.Companion$// TODO this will be removed once the handler is deleted @@ -192,15 +207,16 @@ 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: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, ) 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, ) 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, ) + 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 _configModelStore: ConfigModelStore, private val _jwtTokenStore: JwtTokenStore, ) 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 @@ -265,6 +281,7 @@ NestedBlockDepth:InfluenceManager.kt$InfluenceManager$private fun attemptSessionUpgrade( entryAction: AppEntryAction, directId: String? = null, ) NestedBlockDepth:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean NestedBlockDepth:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + NestedBlockDepth:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) NestedBlockDepth:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$override fun resolve(provider: IServiceProvider): Any? NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean @@ -281,7 +298,6 @@ RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean - ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model? ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse @@ -294,8 +310,10 @@ 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 hasValidJwtIfRequired( iv: Boolean, op: Operation, ): 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 @@ -320,6 +338,7 @@ 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) @@ -334,6 +353,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 @@ -412,10 +432,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 @@ -604,11 +620,13 @@ 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("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 use") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") diff --git a/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml b/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml index da9439705a..fe085e6d85 100644 --- a/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml +++ b/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml @@ -1,9 +1,9 @@ - + - + ComplexCondition:InAppMessagesManager.kt$InAppMessagesManager$!message.isTriggerChanged && isMessageDisplayed && (isTriggerOnMessage || isNewTriggerAdded && isOnlyDynamicTriggers) - ComplexMethod:TriggerController.kt$TriggerController$private fun evaluateTrigger(trigger: Trigger): Boolean + ComplexMethod:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData) ConstructorParameterNaming:DynamicTriggerController.kt$DynamicTriggerController$private val _session: ISessionService ConstructorParameterNaming:DynamicTriggerController.kt$DynamicTriggerController$private val _state: InAppStateService ConstructorParameterNaming:DynamicTriggerController.kt$DynamicTriggerController$private val _time: ITime @@ -39,6 +39,7 @@ ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _displayer: IInAppDisplayer ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _influenceManager: IInfluenceManager + ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _languageContext: ILanguageContext ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _lifecycle: IInAppLifecycleService ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _outcomeEventsController: IOutcomeEventsController @@ -65,10 +66,12 @@ ForbiddenComment:InAppMessagesManager.kt$InAppMessagesManager$// TODO until we don't fix the activity going forward or back dismissing the IAM, we need to auto dismiss ForbiddenComment:InAppMessagesManager.kt$InAppMessagesManager$// TODO: Add more action payload preview logs here in future LongMethod:DynamicTriggerController.kt$DynamicTriggerController$fun dynamicTriggerShouldFire(trigger: Trigger): Boolean + LongMethod:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData) LongMethod:InAppRepository.kt$InAppRepository$override suspend fun cleanCachedInAppMessages() + LongParameterList:IInAppBackendService.kt$IInAppBackendService$( appId: String, aliasLabel: String, aliasValue: String, subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, jwt: String? = null, ) LongParameterList:IInAppBackendService.kt$IInAppBackendService$( appId: String, subscriptionId: String, variantId: String?, messageId: String, clickId: String?, isFirstClick: Boolean, ) LongParameterList:InAppDisplayer.kt$InAppDisplayer$( private val _applicationService: IApplicationService, private val _lifecycle: IInAppLifecycleService, private val _promptFactory: IInAppMessagePromptFactory, private val _backend: IInAppBackendService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _time: ITime, ) - LongParameterList:InAppMessagesManager.kt$InAppMessagesManager$( private val _applicationService: IApplicationService, private val _sessionService: ISessionService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _userManager: IUserManager, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _outcomeEventsController: IOutcomeEventsController, private val _state: InAppStateService, private val _prefs: IInAppPreferencesController, private val _repository: IInAppRepository, private val _backend: IInAppBackendService, private val _triggerController: ITriggerController, private val _triggerModelStore: TriggerModelStore, private val _displayer: IInAppDisplayer, private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, ) + LongParameterList:InAppMessagesManager.kt$InAppMessagesManager$( private val _applicationService: IApplicationService, private val _sessionService: ISessionService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _userManager: IUserManager, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _outcomeEventsController: IOutcomeEventsController, private val _state: InAppStateService, private val _prefs: IInAppPreferencesController, private val _repository: IInAppRepository, private val _backend: IInAppBackendService, private val _triggerController: ITriggerController, private val _triggerModelStore: TriggerModelStore, private val _displayer: IInAppDisplayer, private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, private val _jwtTokenStore: JwtTokenStore, ) LongParameterList:OneSignalAnimate.kt$OneSignalAnimate$( view: View, deltaFromY: Float, deltaToY: Float, duration: Int, interpolator: Interpolator?, animCallback: Animation.AnimationListener?, ) MagicNumber:DraggableRelativeLayout.kt$DraggableRelativeLayout$3 MagicNumber:DraggableRelativeLayout.kt$DraggableRelativeLayout$3000 @@ -85,12 +88,12 @@ MagicNumber:InAppMessageView.kt$InAppMessageView$5 MagicNumber:InAppMessageView.kt$InAppMessageView$8 MagicNumber:InAppMessageView.kt$InAppMessageView$8.0 - MagicNumber:InAppMessageView.kt$InAppMessageView.<no name provided>$5 + MagicNumber:InAppMessageView.kt$InAppMessageView.<no name provided>$5 MagicNumber:InAppMessagesManager.kt$InAppMessagesManager$1000 MagicNumber:InAppRepository.kt$InAppRepository$1000L MagicNumber:OneSignalAnimate.kt$OneSignalAnimate$0.5f MagicNumber:WebViewManager.kt$WebViewManager$3 - NestedBlockDepth:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean + NestedBlockDepth:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean PrintStackTrace:InAppMessage.kt$InAppMessage$e PrintStackTrace:InAppMessage.kt$InAppMessage$exception PrintStackTrace:InAppMessageClickResult.kt$InAppMessageClickResult$e @@ -100,17 +103,17 @@ PrintStackTrace:InAppMessageTag.kt$InAppMessageTag$e PrintStackTrace:Trigger.kt$Trigger$exception PrintStackTrace:WebViewManager.kt$WebViewManager.OSJavaScriptInterface$e - ReturnCount:DraggableRelativeLayout.kt$DraggableRelativeLayout.<no name provided>$override fun clampViewPositionVertical( child: View, top: Int, dy: Int, ): Int + ReturnCount:DraggableRelativeLayout.kt$DraggableRelativeLayout.<no name provided>$override fun clampViewPositionVertical( child: View, top: Int, dy: Int, ): Int ReturnCount:DynamicTriggerController.kt$DynamicTriggerController$fun dynamicTriggerShouldFire(trigger: Trigger): Boolean ReturnCount:InAppBackendService.kt$InAppBackendService$override suspend fun getIAMData( appId: String, messageId: String, variantId: String?, ): GetIAMDataResponse - ReturnCount:InAppBackendService.kt$InAppBackendService$private suspend fun attemptFetchWithRetries( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, ): List<InAppMessage>? + ReturnCount:InAppBackendService.kt$InAppBackendService$private suspend fun attemptFetchWithRetries( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, jwt: String? = null, ): List<InAppMessage>? ReturnCount:InAppHydrator.kt$InAppHydrator$fun hydrateIAMMessageContent(jsonObject: JSONObject): InAppMessageContent? ReturnCount:InAppMessage.kt$InAppMessage$private fun parseEndTimeJson(json: JSONObject): Date? ReturnCount:InAppMessagePreviewHandler.kt$InAppMessagePreviewHandler$private fun inAppPreviewPushUUID(payload: JSONObject): String? ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$override fun onMessageWasDisplayed(message: InAppMessage) ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData) ReturnCount:TriggerController.kt$TriggerController$override fun evaluateMessageTriggers(message: InAppMessage): Boolean - ReturnCount:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean + ReturnCount:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean ReturnCount:TriggerController.kt$TriggerController$override fun messageHasOnlyDynamicTriggers(message: InAppMessage): Boolean ReturnCount:TriggerController.kt$TriggerController$private fun evaluateTrigger(trigger: Trigger): Boolean ReturnCount:TriggerController.kt$TriggerController$private fun triggerMatchesFlex( triggerValue: Any?, deviceValue: Any, operator: Trigger.OSTriggerOperator, ): Boolean diff --git a/OneSignalSDK/detekt/detekt-baseline-notifications.xml b/OneSignalSDK/detekt/detekt-baseline-notifications.xml index 8ee6e8c589..104b3ef175 100644 --- a/OneSignalSDK/detekt/detekt-baseline-notifications.xml +++ b/OneSignalSDK/detekt/detekt-baseline-notifications.xml @@ -137,28 +137,20 @@ LongParameterList:INotificationGenerationWorkManager.kt$INotificationGenerationWorkManager$( context: Context, osNotificationId: String, androidNotificationId: Int, jsonPayload: JSONObject?, timestamp: Long, isRestoring: Boolean, isHighPriority: Boolean, ) LongParameterList:INotificationRepository.kt$INotificationRepository$( id: String, groupId: String?, collapseKey: String?, shouldDismissIdenticals: Boolean, isOpened: Boolean, androidId: Int, title: String?, body: String?, expireTime: Long, jsonPayload: String, ) LongParameterList:NotificationLifecycleService.kt$NotificationLifecycleService$( private val _applicationService: IApplicationService, private val _time: ITime, private val _configModelStore: ConfigModelStore, private val _influenceManager: IInfluenceManager, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _backend: INotificationBackendService, private val _receiveReceiptWorkManager: IReceiveReceiptWorkManager, private val _analyticsTracker: IAnalyticsTracker, ) - LoopWithTooManyJumpStatements:NotificationLifecycleService.kt$NotificationLifecycleService$for (i in 0 until data.length()) { val notificationId = NotificationFormatHelper.getOSNotificationIdFromJson(data[i] as JSONObject?) ?: continue if (postedOpenedNotifIds.contains(notificationId)) { continue } postedOpenedNotifIds.add(notificationId) suspendifyWithErrorHandling( useIO = true, // or false for CPU operations block = { _backend.updateNotificationAsOpened( appId, notificationId, subscriptionId, deviceType, ) }, onError = { ex -> if (ex is BackendException) { Logging.error("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") } else { Logging.error("Unexpected error in notification opened confirmation", ex) } }, ) } + LoopWithTooManyJumpStatements:NotificationLifecycleService.kt$NotificationLifecycleService$for (i in 0 until data.length()) { val notificationId = NotificationFormatHelper.getOSNotificationIdFromJson(data[i] as JSONObject?) ?: continue if (postedOpenedNotifIds.contains(notificationId)) { continue } postedOpenedNotifIds.add(notificationId) suspendifyWithErrorHandling( useIO = true, // or false for CPU operations block = { _backend.updateNotificationAsOpened( appId, notificationId, subscriptionId, deviceType, ) }, onError = { ex -> if (ex is BackendException) { Logging.info("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") } else { Logging.info("Unexpected error in notification opened confirmation", ex) } }, ) } MagicNumber:FirebaseAnalyticsTracker.kt$FirebaseAnalyticsTracker$1000 MagicNumber:FirebaseAnalyticsTracker.kt$FirebaseAnalyticsTracker$30 MagicNumber:FirebaseAnalyticsTracker.kt$FirebaseAnalyticsTracker$60 MagicNumber:Notification.kt$Notification$1000 MagicNumber:NotificationBundleProcessor.kt$NotificationBundleProcessor$1000L - MagicNumber:NotificationBundleProcessor.kt$NotificationBundleProcessor$9 MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$16 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$3 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$5 MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$6 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$7 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$9 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$1000L MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$16 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$2000 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$3 - MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$4 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$5000 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$6 - MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$7 - MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$9 MagicNumber:NotificationDisplayer.kt$NotificationDisplayer$16 MagicNumber:NotificationDisplayer.kt$NotificationDisplayer$3 MagicNumber:NotificationDisplayer.kt$NotificationDisplayer$5000 @@ -166,6 +158,9 @@ MagicNumber:NotificationGenerationProcessor.kt$NotificationGenerationProcessor$1000L MagicNumber:NotificationGenerationWorkManager.kt$NotificationGenerationWorkManager.NotificationGenerationWorker$1000L MagicNumber:NotificationHelper.kt$NotificationHelper$10 + MagicNumber:NotificationPriorityMapper.kt$NotificationPriorityMapper$3 + MagicNumber:NotificationPriorityMapper.kt$NotificationPriorityMapper$5 + MagicNumber:NotificationPriorityMapper.kt$NotificationPriorityMapper$7 MagicNumber:NotificationQueryHelper.kt$NotificationQueryHelper$1000L MagicNumber:NotificationQueryHelper.kt$NotificationQueryHelper$604800L MagicNumber:NotificationRepository.kt$NotificationRepository$1000L @@ -194,8 +189,6 @@ ReturnCount:GenerateNotificationOpenIntent.kt$GenerateNotificationOpenIntent$private fun getIntentAppOpen(): Intent? ReturnCount:NotificationChannelManager.kt$NotificationChannelManager$override fun createNotificationChannel(notificationJob: NotificationGenerationJob): String ReturnCount:NotificationChannelManager.kt$NotificationChannelManager$override fun processChannelList(list: JSONArray?) - ReturnCount:NotificationChannelManager.kt$NotificationChannelManager$private fun priorityToImportance(priority: Int): Int - ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun convertOSToAndroidPriority(priority: Int): Int ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun getAccentColor(fcmJson: JSONObject): BigInteger? ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun getBitmapFromAssetsOrResourceName(bitmapStr: String): Bitmap? ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun getResourceIcon(iconName: String?): Int @@ -213,6 +206,8 @@ ReturnCount:NotificationHelper.kt$NotificationHelper$fun getNotificationIdFromFCMJson(fcmJson: JSONObject?): String? ReturnCount:NotificationLifecycleService.kt$NotificationLifecycleService$private fun shouldInitDirectSessionFromNotificationOpen(context: Activity): Boolean ReturnCount:NotificationPermissionController.kt$NotificationPermissionController$override suspend fun prompt(fallbackToSettings: Boolean): Boolean + ReturnCount:NotificationPriorityMapper.kt$NotificationPriorityMapper$fun toAndroidImportance(osPriority: Int): Int + ReturnCount:NotificationPriorityMapper.kt$NotificationPriorityMapper$fun toAndroidPriority(osPriority: Int): Int ReturnCount:NotificationRestoreProcessor.kt$NotificationRestoreProcessor$private fun getVisibleNotifications(): List<Int>? ReturnCount:NotificationRestoreWorkManager.kt$NotificationRestoreWorkManager.NotificationRestoreWorker$override suspend fun doWork(): Result ReturnCount:NotificationSummaryManager.kt$NotificationSummaryManager$private suspend fun internalUpdateSummaryNotificationAfterChildRemoved( group: String, dismissed: Boolean, ) @@ -247,6 +242,7 @@ TooGenericExceptionCaught:NotificationGenerationProcessor.kt$NotificationGenerationProcessor$t: Throwable TooGenericExceptionCaught:NotificationHelper.kt$NotificationHelper$e: Throwable TooGenericExceptionCaught:NotificationHelper.kt$NotificationHelper$t: Throwable + TooGenericExceptionCaught:NotificationLifecycleService.kt$NotificationLifecycleService$e: Exception TooGenericExceptionCaught:NotificationLimitManager.kt$NotificationLimitManager$t: Throwable TooGenericExceptionCaught:NotificationRepository.kt$NotificationRepository$t: Throwable TooGenericExceptionCaught:NotificationRestoreProcessor.kt$NotificationRestoreProcessor$t: Throwable diff --git a/OneSignalSDK/detekt/detekt-baseline-otel.xml b/OneSignalSDK/detekt/detekt-baseline-otel.xml new file mode 100644 index 0000000000..751e432022 --- /dev/null +++ b/OneSignalSDK/detekt/detekt-baseline-otel.xml @@ -0,0 +1,10 @@ + + + + + LongParameterList:OtelLoggingHelper.kt$OtelLoggingHelper$( telemetry: IOtelOpenTelemetryRemote, level: String, message: String, exceptionType: String? = null, exceptionMessage: String? = null, exceptionStacktrace: String? = null, ) + ReturnCount:OtelConfigRemoteOneSignal.kt$OtelConfigRemoteOneSignal.ExporterLoggingConfig.LoggingLogRecordExporter$@Suppress("TooGenericExceptionCaught") private fun resolveHttpFailureMessage(throwable: Throwable?): String + TooGenericExceptionCaught:OtelCrashHandler.kt$OtelCrashHandler$t: Throwable + UndocumentedPublicFunction:IOtelCrashReporter.kt$IOtelCrashReporter$suspend fun saveCrash(thread: Thread, throwable: Throwable) + + 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..477f7e90ac 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -226,4 +226,31 @@ interface IOneSignal { * Logout the current user (suspend version). */ suspend fun logoutSuspend() + + /** + * Update the JWT bearer token for a user identified by [externalId]. Call this when + * a token is about to expire or after receiving an [IUserJwtInvalidatedListener] callback. + * + * @param externalId The external ID of the user whose token is being updated. + * @param token The new JWT bearer token. + */ + fun updateUserJwt( + externalId: String, + token: String, + ) + + /** + * Add a listener that will be called when a user's JWT is invalidated (e.g. expired + * or rejected by the server). Use this to provide a fresh token via [updateUserJwt]. + * + * @param listener The listener to add. + */ + fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) + + /** + * Remove a previously added [IUserJwtInvalidatedListener]. + * + * @param listener The listener to remove. + */ + fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) } 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..82cc6e1d7b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt @@ -0,0 +1,15 @@ +package com.onesignal + +/** + * Implement this interface and provide an instance to [OneSignal.addUserJwtInvalidatedListener] + * in order to receive control when the JWT for the current user is invalidated. + * + */ +interface IUserJwtInvalidatedListener { + /** + * Called when the JWT is invalidated + * + * @param event The user JWT that expired. + */ + 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..55c343631f 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,42 @@ object OneSignal { @JvmStatic fun logout() = oneSignal.logout() + /** + * Update the JWT bearer token for a user identified by [externalId]. Call this when + * a token is about to expire or after receiving an [IUserJwtInvalidatedListener] callback. + * + * @param externalId The external ID of the user whose token is being updated. + * @param token The new JWT bearer token. + */ + @JvmStatic + fun updateUserJwt( + externalId: String, + token: String, + ) { + oneSignal.updateUserJwt(externalId, token) + } + + /** + * Add a listener that will be called when a user's JWT is invalidated (e.g. expired + * or rejected by the server). Use this to provide a fresh token via [updateUserJwt]. + * + * @param listener The listener to add. + */ + @JvmStatic + fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + oneSignal.addUserJwtInvalidatedListener(listener) + } + + /** + * Remove a previously added [IUserJwtInvalidatedListener]. + * + * @param listener The listener to remove. + */ + @JvmStatic + fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + oneSignal.removeUserJwtInvalidatedListener(listener) + } + private val oneSignal: IOneSignal by lazy { OneSignalImp() } 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..9c7ddcb87b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt @@ -0,0 +1,10 @@ +package com.onesignal + +/** + * The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated], it provides access + * to the external ID whose JWT has just been invalidated. + * + */ +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 9d34231d63..260a830c81 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 @@ -10,6 +10,7 @@ import com.onesignal.core.internal.background.IBackgroundManager 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.IdentityVerificationService import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.DatabaseProvider import com.onesignal.core.internal.device.IDeviceService @@ -42,6 +43,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.identity.JwtTokenStore internal class CoreModule : IModule { override fun register(builder: ServiceBuilder) { @@ -63,6 +65,10 @@ internal class CoreModule : IModule { builder.register().provides() builder.register().provides() + // Identity Verification + builder.register().provides() + builder.register().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 ec0af86055..273946ef0b 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 @@ -84,8 +84,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") ?: false, notificationChannels = responseJson.optJSONArray("chnl_lst"), firebaseAnalytics = responseJson.safeBool("fba"), restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"), @@ -95,7 +94,6 @@ internal class ParamsBackendService( unsubscribeWhenNotificationsDisabled = responseJson.safeBool("unsubscribe_on_notifications_disabled"), locationShared = responseJson.safeBool("location_shared"), requiresUserPrivacyConsent = responseJson.safeBool("requires_user_privacy_consent"), - // TODO: New opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), features = features, influenceParams = influenceParams ?: InfluenceParamsObject(), 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 a88739e05e..86ac5f56bf 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 @@ -237,12 +237,15 @@ class ConfigModel : Model() { } /** - * Whether SMS auth hash should be used. + * Whether identity verification (JWT) is required for this application. + * - `null` = unknown (remote params haven't arrived yet; all operations are held) + * - `false` = explicitly disabled (SDK behaves as today, no JWT gating) + * - `true` = enabled (operations require a valid JWT, anonymous users are blocked) */ - var useIdentityVerification: Boolean - get() = getBooleanProperty(::useIdentityVerification.name) { false } + var useIdentityVerification: Boolean? + get() = getOptBooleanProperty(::useIdentityVerification.name) set(value) { - setBooleanProperty(::useIdentityVerification.name, value) + setOptBooleanProperty(::useIdentityVerification.name, value) } /** 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..171be38190 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt @@ -0,0 +1,66 @@ +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.operations.IOperationRepo +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.UserManager +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore + +/** + * Reacts to the identity-verification remote param arriving via config HYDRATE. + * + * - When IV transitions from unknown (null) to true: purges anonymous operations. + * - When IV transitions from unknown (null) to any value: wakes the operation queue. + * - On beta migration: if IV=true and the current user has an externalId but no JWT, + * fires [UserJwtInvalidatedEvent] so the developer provides a fresh token. + */ +internal class IdentityVerificationService( + private val _configModelStore: ConfigModelStore, + private val _operationRepo: IOperationRepo, + private val _identityModelStore: IdentityModelStore, + private val _jwtTokenStore: JwtTokenStore, + private val _userManager: UserManager, +) : IStartableService, ISingletonModelStoreChangeHandler { + override fun start() { + _configModelStore.subscribe(this) + _operationRepo.setJwtInvalidatedHandler { externalId -> + _userManager.fireJwtInvalidated(externalId) + } + } + + override fun onModelReplaced( + model: ConfigModel, + tag: String, + ) { + if (tag != ModelChangeTags.HYDRATE) return + + val useIV = model.useIdentityVerification + + if (useIV == true) { + Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations") + _operationRepo.removeOperationsWithoutExternalId() + + val externalId = _identityModelStore.model.externalId + if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) { + Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, firing invalidated event") + _userManager.fireJwtInvalidated(externalId) + } + } + + _operationRepo.forceExecuteOperations() + } + + override fun onModelUpdated( + args: ModelChangedArgs, + tag: String, + ) { + // Individual property updates are not expected for remote params; + // ConfigModelStoreListener replaces the entire model on HYDRATE. + } +} 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..f7c01b843e 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,18 +159,6 @@ 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 - if (headers?.cacheKey != null) { val eTag = _prefs.getString( @@ -195,6 +183,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/IOperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt index d2dceea5c3..2f8a8ac8fe 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt @@ -42,6 +42,20 @@ interface IOperationRepo { suspend fun awaitInitialized() fun forceExecuteOperations() + + /** + * Remove all queued operations that have no externalId (anonymous operations). + * Used by IdentityVerificationService when identity verification is enabled to + * purge operations that cannot be executed without an authenticated user. + */ + fun removeOperationsWithoutExternalId() + + /** + * Register a handler to be called when a runtime 401 Unauthorized response + * invalidates a JWT. This allows the caller to notify the developer so they + * can supply a fresh token via [OneSignal.updateUserJwt]. + */ + fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?) } // Extension function so the syntax containsInstanceOf() can be used over 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..8227ebb877 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,18 @@ abstract class Operation(name: String) : Model() { setStringProperty(::name.name, value) } + /** + * The external ID of the user this operation belongs to. Used by [IOperationRepo] to look up + * the correct JWT when identity verification is enabled, and to gate anonymous operations. + * Stamped automatically by [IOperationRepo] at enqueue time from the current identity model + * when not already set by the concrete operation's constructor. + */ + var externalId: String? + get() = getOptStringProperty(::externalId.name) + set(value) { + setOptStringProperty(::externalId.name, value) + } + init { this.name = name } @@ -49,6 +61,13 @@ abstract class Operation(name: String) : Model() { */ abstract val canStartExecute: Boolean + /** + * Whether this operation requires a valid JWT when identity verification is enabled. + * Override to return `false` for operations whose backend endpoint does not require + * a JWT (e.g. subscription updates). + */ + open val requiresJwt: Boolean get() = true + /** * Called when an operation has resolved a local ID to a backend ID (i.e. successfully * created a backend resource). Any IDs within the operation that could be local IDs should 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 9b39566d17..03259156ce 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 @@ -11,6 +11,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.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.impl.states.NewRecordsState import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -28,6 +30,8 @@ internal class OperationRepo( private val _configModelStore: ConfigModelStore, private val _time: ITime, private val _newRecordState: NewRecordsState, + private val _jwtTokenStore: JwtTokenStore, + private val _identityModelStore: IdentityModelStore, ) : IOperationRepo, IStartableService { internal class OperationQueueItem( val operation: Operation, @@ -40,6 +44,9 @@ internal class OperationRepo( } } + @Volatile + private var _jwtInvalidatedHandler: ((String) -> Unit)? = null + internal class LoopWaiterMessage( val force: Boolean, val previousWaitedTime: Long = 0, @@ -123,6 +130,7 @@ internal class OperationRepo( Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)") operation.id = UUID.randomUUID().toString() + stampExternalId(operation) scope.launch { internalEnqueue(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush, true) } @@ -135,6 +143,7 @@ internal class OperationRepo( Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)") operation.id = UUID.randomUUID().toString() + stampExternalId(operation) val waiter = WaiterWithValue() scope.launch { internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) @@ -148,6 +157,18 @@ internal class OperationRepo( * * @returns true if the OperationQueueItem was added, false if not */ + /** + * Capture the externalId from the current identity model onto the operation + * synchronously on the caller's thread, before the async enqueue coroutine runs. + * Operations that already set externalId in their constructor (e.g. LoginUserOperation) + * are left unchanged. + */ + private fun stampExternalId(operation: Operation) { + if (operation.externalId == null) { + operation.externalId = _identityModelStore.model.externalId + } + } + private fun internalEnqueue( queueItem: OperationQueueItem, flush: Boolean, @@ -188,7 +209,8 @@ internal class OperationRepo( } val ops = getNextOps(executeBucket) - Logging.debug("processQueueForever:ops:\n$ops") + val queueSnapshot = synchronized(queue) { queue.toList() } + Logging.debug("processQueueForever:ops:\n$ops\nqueue(${queueSnapshot.size}):\n$queueSnapshot") if (ops != null) { executeOperations(ops) @@ -268,7 +290,21 @@ 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 -> { + val externalId = startingOp.operation.externalId + if (externalId != null) { + _jwtTokenStore.invalidateJwt(externalId) + _jwtInvalidatedHandler?.invoke(externalId) + Logging.warn("Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. Operations re-queued.") + synchronized(queue) { + ops.reversed().forEach { queue.add(0, it) } + } + } else { + Logging.warn("Operation execution failed with 401 Unauthorized for anonymous user. Operations dropped.") + ops.forEach { _operationModelStore.remove(it.operation.id) } + ops.forEach { it.waiter?.wake(false) } + } + } ExecutionResult.FAIL_NORETRY, ExecutionResult.FAIL_CONFLICT, -> { @@ -312,9 +348,13 @@ internal class OperationRepo( // if there are operations provided on the result, we need to enqueue them at the // beginning of the queue. if (response.operations != null) { + val parentExternalId = startingOp.operation.externalId synchronized(queue) { for (op in response.operations.reversed()) { op.id = UUID.randomUUID().toString() + if (op.externalId == null && parentExternalId != null) { + op.externalId = parentExternalId + } val queueItem = OperationQueueItem(op, bucket = 0) queue.add(0, queueItem) _operationModelStore.add(0, queueItem.operation) @@ -372,12 +412,16 @@ internal class OperationRepo( } internal fun getNextOps(bucketFilter: Int): List? { + val iv = _configModelStore.model.useIdentityVerification + if (iv == null) return null + return synchronized(queue) { val startingOp = queue.firstOrNull { it.operation.canStartExecute && _newRecordState.canAccess(it.operation.applyToRecordId) && - it.bucket <= bucketFilter + it.bucket <= bucketFilter && + hasValidJwtIfRequired(iv, it.operation) } if (startingOp != null) { @@ -389,6 +433,16 @@ internal class OperationRepo( } } + private fun hasValidJwtIfRequired( + iv: Boolean, + op: Operation, + ): Boolean { + if (!iv) return true + if (!op.requiresJwt) return true + val externalId = op.externalId ?: return false + return _jwtTokenStore.getJwt(externalId) != null + } + /** * Given a starting operation, find and remove from the queue all other operations that * can be executed along with the starting operation. The full list of operations, with @@ -450,6 +504,32 @@ internal class OperationRepo( index = 0, ) } + + val activeExternalIds = + synchronized(queue) { + queue.mapNotNull { it.operation.externalId }.toMutableSet() + } + _identityModelStore.model.externalId?.let { activeExternalIds.add(it) } + _jwtTokenStore.pruneToExternalIds(activeExternalIds) + initialized.complete(Unit) } + + override fun removeOperationsWithoutExternalId() { + synchronized(queue) { + val toRemove = queue.filter { it.operation.externalId == null } + toRemove.forEach { + queue.remove(it) + _operationModelStore.remove(it.operation.id) + it.waiter?.wake(false) + } + if (toRemove.isNotEmpty()) { + Logging.debug("OperationRepo: removed ${toRemove.size} anonymous operations (no externalId)") + } + } + } + + override fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?) { + _jwtInvalidatedHandler = handler + } } 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..0c9f47c517 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 @@ -272,6 +272,13 @@ object PreferenceOneSignalKeys { */ const val PREFS_OS_IAM_LAST_DISMISSED_TIME = "PREFS_OS_IAM_LAST_DISMISSED_TIME" + // Identity Verification + + /** + * (String) JSON map of externalId -> JWT token for identity verification. + */ + const val PREFS_OS_JWT_TOKENS = "PREFS_OS_JWT_TOKENS" + // Models /** 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 5cd9cb9177..875d32042d 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 @@ -35,8 +36,10 @@ import com.onesignal.user.IUserManager import com.onesignal.user.UserModule import com.onesignal.user.internal.LoginHelper import com.onesignal.user.internal.LogoutHelper +import com.onesignal.user.internal.UserManager import com.onesignal.user.internal.UserSwitcher import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.resolveAppId import com.onesignal.user.internal.subscriptions.SubscriptionModelStore @@ -142,6 +145,7 @@ 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 listOfModules = listOf( "com.onesignal.notifications.NotificationsModule", @@ -220,6 +224,7 @@ internal class OneSignalImp( userSwitcher = userSwitcher, operationRepo = operationRepo, configModel = configModel, + jwtTokenStore = jwtTokenStore, lock = loginLogoutLock, ) } @@ -230,6 +235,7 @@ internal class OneSignalImp( userSwitcher = userSwitcher, operationRepo = operationRepo, configModel = configModel, + subscriptionModelStore = subscriptionModelStore, lock = loginLogoutLock, ) } @@ -409,6 +415,23 @@ internal class OneSignalImp( } } + override fun updateUserJwt( + externalId: String, + token: String, + ) { + Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId)") + jwtTokenStore.putJwt(externalId, token) + operationRepo.forceExecuteOperations() + } + + override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + services.getService().addJwtInvalidatedListener(listener) + } + + override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + services.getService().removeJwtInvalidatedListener(listener) + } + override fun hasService(c: Class): Boolean = services.hasService(c) override fun getService(c: Class): T = services.getService(c) 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..0b92fb85bc 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,7 @@ 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 15939441ba..cc9bb14daf 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,6 +4,7 @@ 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.identity.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation class LoginHelper( @@ -11,6 +12,7 @@ class LoginHelper( private val userSwitcher: UserSwitcher, private val operationRepo: IOperationRepo, private val configModel: ConfigModel, + private val jwtTokenStore: JwtTokenStore, private val lock: Any, ) { suspend fun login( @@ -26,10 +28,13 @@ class LoginHelper( currentIdentityOneSignalId = identityModelStore.model.onesignalId if (currentIdentityExternalId == externalId) { + jwtTokenStore.putJwt(externalId, jwtBearerToken) + operationRepo.forceExecuteOperations() return } - // TODO: Set JWT Token for all future requests. + jwtTokenStore.putJwt(externalId, jwtBearerToken) + userSwitcher.createAndSwitchToNewUser { identityModel, _ -> identityModel.externalId = externalId } @@ -37,13 +42,20 @@ class LoginHelper( newIdentityOneSignalId = identityModelStore.model.onesignalId } + val existingOneSignalId = + if (configModel.useIdentityVerification == true) { + null + } else { + if (currentIdentityExternalId == null) currentIdentityOneSignalId else null + } + val result = operationRepo.enqueueAndWait( LoginUserOperation( configModel.appId, newIdentityOneSignalId, externalId, - if (currentIdentityExternalId == null) currentIdentityOneSignalId else null, + 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 8d9015c612..ebe105c585 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 @@ -4,12 +4,14 @@ import com.onesignal.core.internal.config.ConfigModel 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( private val identityModelStore: IdentityModelStore, private val userSwitcher: UserSwitcher, private val operationRepo: IOperationRepo, private val configModel: ConfigModel, + private val subscriptionModelStore: SubscriptionModelStore, private val lock: Any, ) { fun logout() { @@ -18,20 +20,24 @@ class LogoutHelper( return } - // Create new device-scoped user (clears external ID) - userSwitcher.createAndSwitchToNewUser() + if (configModel.useIdentityVerification == true) { + configModel.pushSubscriptionId?.let { pushSubId -> + subscriptionModelStore.get(pushSubId) + ?.let { it.isDisabledInternally = true } + } - // Enqueue login operation for the new device-scoped user (no external ID) - operationRepo.enqueue( - LoginUserOperation( - configModel.appId, - identityModelStore.model.onesignalId, - null, - // No external ID for device-scoped user - ), - ) + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + } else { + userSwitcher.createAndSwitchToNewUser() - // TODO: remove JWT Token for all future requests. + operationRepo.enqueue( + LoginUserOperation( + configModel.appId, + identityModelStore.model.onesignalId, + null, + ), + ) + } } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 328cb9da7d..4ec95c0820 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -1,5 +1,7 @@ package com.onesignal.user.internal +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.IDManager import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils @@ -43,6 +45,21 @@ internal open class UserManager( get() = _subscriptionManager.subscriptions val changeHandlersNotifier = EventProducer() + private val jwtInvalidatedNotifier = EventProducer() + + fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedNotifier.subscribe(listener) + } + + fun removeJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedNotifier.unsubscribe(listener) + } + + fun fireJwtInvalidated(externalId: String) { + jwtInvalidatedNotifier.fireOnMain { + it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + } + } override val pushSubscription: IPushSubscription get() = _subscriptionManager.subscriptions.push 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..a09f40ca68 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 @@ -1,6 +1,7 @@ package com.onesignal.user.internal.backend import com.onesignal.common.exceptions.BackendException +import com.onesignal.debug.internal.logging.Logging interface IIdentityBackendService { /** @@ -18,6 +19,7 @@ interface IIdentityBackendService { aliasLabel: String, aliasValue: String, identities: Map, + jwt: String? = null, ): Map /** @@ -35,6 +37,7 @@ interface IIdentityBackendService { aliasLabel: String, aliasValue: String, aliasLabelToDelete: String, + jwt: String? = null, ) } @@ -48,4 +51,23 @@ object IdentityConstants { * The alias label for the internal onesignal ID alias. */ const val ONESIGNAL_ID = "onesignal_id" + + /** + * Resolves which alias (external_id vs onesignal_id) should be used in backend API paths. + * When identity verification is enabled and the operation has an externalId, routes through + * external_id; otherwise falls back to onesignal_id. + */ + fun resolveAlias( + useIdentityVerification: Boolean?, + externalId: String?, + onesignalId: String, + ): Pair { + if (useIdentityVerification == true) { + if (externalId != null) { + return EXTERNAL_ID to externalId + } + Logging.error("Identity verification is enabled but externalId is null. Falling back to onesignal_id.") + } + return ONESIGNAL_ID to onesignalId + } } 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..e6e65bff1f 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,6 +64,7 @@ interface ISubscriptionBackendService { subscriptionId: String, aliasLabel: String, aliasValue: String, + jwt: String? = null, ) /** @@ -74,5 +78,6 @@ interface ISubscriptionBackendService { suspend fun getIdentityFromSubscription( appId: String, subscriptionId: String, + jwt: String? = null, ): Map } 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..b849fc4c42 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 } 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..614b8a3bf3 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,13 @@ 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, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -36,8 +38,9 @@ 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", jwt?.let { OptionalHeaders(jwt = it) }) 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/SubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt index a2266d4d36..1003dd84c5 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,12 @@ 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, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -50,12 +52,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, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -76,8 +79,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", jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -89,12 +93,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, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -104,8 +109,9 @@ internal class SubscriptionBackendService( override suspend fun getIdentityFromSubscription( appId: String, subscriptionId: String, + jwt: String?, ): Map { - val response = _httpClient.get("apps/$appId/subscriptions/$subscriptionId/user/identity") + val response = _httpClient.get("apps/$appId/subscriptions/$subscriptionId/user/identity", jwt?.let { OptionalHeaders(jwt = it) }) 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..8a5c58d691 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, jwt?.let { OptionalHeaders(jwt = it) }) 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, jwt?.let { OptionalHeaders(jwt = it) }) 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", jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) 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..eccd67b650 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, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt new file mode 100644 index 0000000000..2bbda35723 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt @@ -0,0 +1,105 @@ +package com.onesignal.user.internal.identity + +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.JSONObject + +/** + * Persistent store mapping externalId -> JWT token. Supports multiple users simultaneously + * so that queued operations for a previous user can still resolve their JWT at execution time. + * + * Storage is unconditional (callers store JWTs regardless of the identity-verification flag). + * Only *usage* of JWTs (Authorization header, gating, alias resolution) is gated on + * [com.onesignal.core.internal.config.ConfigModel.useIdentityVerification]. + */ +class JwtTokenStore( + private val _prefs: IPreferencesService, +) { + private val tokens: MutableMap = mutableMapOf() + private var isLoaded = false + + /** Not thread-safe; callers 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: Exception) { + Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh", e) + } + } + isLoaded = true + } + + /** Not thread-safe; callers must hold `synchronized(tokens)`. */ + private fun persist() { + _prefs.saveString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS, + JSONObject(tokens.toMap()).toString(), + ) + } + + /** + * Returns the JWT for the given [externalId], or null if none is stored. + */ + fun getJwt(externalId: String): String? { + synchronized(tokens) { + ensureLoaded() + return tokens[externalId] + } + } + + /** + * Stores (or replaces) the JWT for [externalId]. Passing a null [jwt] is a no-op; + * use [invalidateJwt] to remove a token. + */ + fun putJwt( + externalId: String, + jwt: String?, + ) { + if (jwt == null) return + synchronized(tokens) { + ensureLoaded() + tokens[externalId] = jwt + persist() + } + } + + /** + * Removes the JWT for [externalId], marking it as invalid. Operations for this user + * will be held until a new JWT is provided via [putJwt]. + */ + fun invalidateJwt(externalId: String) { + synchronized(tokens) { + ensureLoaded() + if (tokens.remove(externalId) != null) { + persist() + } + } + } + + /** + * Removes all stored JWTs whose externalId is NOT in [activeIds]. + * Called on cold start after loading persisted operations to prevent unbounded growth. + */ + fun pruneToExternalIds(activeIds: Set) { + synchronized(tokens) { + ensureLoaded() + val removed = tokens.keys.retainAll(activeIds) + if (removed) { + persist() + } + } + } +} 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 b283cc3da0..9164ab39ca 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/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/UpdateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt index 57f17a29f5..426da703dd 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 @@ -86,6 +86,7 @@ class UpdateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.UP override val groupComparisonType: GroupComparisonType = GroupComparisonType.ALTER override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId) override val applyToRecordId: String get() = subscriptionId + override val requiresJwt: Boolean get() = false constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() { this.appId = appId 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..14166713df 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 @@ -13,12 +13,14 @@ 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.identity.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, ) : IOperationExecutor { override val operations: List get() = listOf(CUSTOM_EVENT) @@ -40,6 +42,7 @@ internal class CustomEventOperationExecutor( try { when (operation) { is TrackCustomEventOperation -> { + val jwt = operation.externalId?.let { _jwtTokenStore.getJwt(it) } customEventBackendService.sendCustomEvent( operation.appId, operation.onesignalId, @@ -48,6 +51,7 @@ internal class CustomEventOperationExecutor( operation.eventName, operation.eventProperties, eventMetadataJson, + jwt, ) } } 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..f12831c3f9 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,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.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -12,6 +13,7 @@ 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.identity.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 +23,8 @@ internal class IdentityOperationExecutor( private val _identityModelStore: IdentityModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, + private val _configModelStore: ConfigModelStore, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(SET_ALIAS, DELETE_ALIAS) @@ -44,12 +48,21 @@ internal class IdentityOperationExecutor( val lastOperation = operations.last() if (lastOperation is SetAliasOperation) { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + lastOperation.externalId, + lastOperation.onesignalId, + ) + val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + try { _identityBackend.setAlias( lastOperation.appId, - IdentityConstants.ONESIGNAL_ID, - lastOperation.onesignalId, + aliasLabel, + aliasValue, mapOf(lastOperation.label to lastOperation.value), + jwt, ) // ensure the now created alias is in the model as long as the user is still current. @@ -87,12 +100,21 @@ internal class IdentityOperationExecutor( } } } else if (lastOperation is DeleteAliasOperation) { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + lastOperation.externalId, + lastOperation.onesignalId, + ) + val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + try { _identityBackend.deleteAlias( lastOperation.appId, - IdentityConstants.ONESIGNAL_ID, - lastOperation.onesignalId, + aliasLabel, + aliasValue, lastOperation.label, + 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..cf63ab2e20 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.ConfigModelStore 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 _configModelStore: ConfigModelStore, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER_FROM_SUBSCRIPTION_USER) @@ -27,6 +29,11 @@ internal class LoginUserFromSubscriptionOperationExecutor( override suspend fun execute(operations: List): ExecutionResponse { Logging.debug("LoginUserFromSubscriptionOperationExecutor(operation: $operations)") + if (_configModelStore.model.useIdentityVerification == true) { + 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") } 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..c80adff4ae 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 @@ -24,6 +24,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.identity.JwtTokenStore import com.onesignal.user.internal.operations.CreateSubscriptionOperation import com.onesignal.user.internal.operations.DeleteSubscriptionOperation import com.onesignal.user.internal.operations.LoginUserOperation @@ -47,6 +48,7 @@ internal class LoginUserOperationExecutor( private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER) @@ -168,7 +170,8 @@ internal class LoginUserOperationExecutor( try { val subscriptionList = subscriptions.toList() - val response = _userBackend.createUser(createUserOperation.appId, identities, subscriptionList.map { it.second }, properties) + val jwt = createUserOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + 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. 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..02e10bbc22 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 @@ -17,6 +17,7 @@ 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.identity.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,7 @@ internal class RefreshUserOperationExecutor( private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(REFRESH_USER) @@ -54,12 +56,21 @@ internal class RefreshUserOperationExecutor( } private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + op.externalId, + op.onesignalId, + ) + val jwt = op.externalId?.let { _jwtTokenStore.getJwt(it) } + try { val response = _userBackend.getUser( op.appId, - IdentityConstants.ONESIGNAL_ID, - op.onesignalId, + aliasLabel, + aliasValue, + 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..97d78ec4d7 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 @@ -26,6 +26,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.builduser.IRebuildUserService +import com.onesignal.user.internal.identity.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,7 @@ internal class SubscriptionOperationExecutor( private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(CREATE_SUBSCRIPTION, UPDATE_SUBSCRIPTION, DELETE_SUBSCRIPTION, TRANSFER_SUBSCRIPTION) @@ -107,12 +109,21 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + createOperation.externalId, + createOperation.onesignalId, + ) + val jwt = createOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + val result = _subscriptionBackend.createSubscription( createOperation.appId, - IdentityConstants.ONESIGNAL_ID, - createOperation.onesignalId, + aliasLabel, + aliasValue, subscription, + jwt, ) ?: return ExecutionResponse(ExecutionResult.SUCCESS) val backendSubscriptionId = result.first @@ -190,7 +201,8 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) - val rywData = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription) + val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + val rywData = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription, jwt) if (rywData != null) { _consistencyManager.setRywData(startingOperation.onesignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywData) @@ -239,12 +251,21 @@ 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 (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + startingOperation.externalId, + startingOperation.onesignalId, + ) + val jwt = startingOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + try { _subscriptionBackend.transferSubscription( startingOperation.appId, startingOperation.subscriptionId, - IdentityConstants.ONESIGNAL_ID, - startingOperation.onesignalId, + aliasLabel, + aliasValue, + jwt, ) } catch (ex: BackendException) { val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) @@ -275,8 +296,10 @@ internal class SubscriptionOperationExecutor( } private suspend fun deleteSubscription(op: DeleteSubscriptionOperation): ExecutionResponse { + val jwt = op.externalId?.let { _jwtTokenStore.getJwt(it) } + 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) 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..090b3f3904 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.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -19,6 +20,7 @@ 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.identity.JwtTokenStore import com.onesignal.user.internal.operations.DeleteTagOperation import com.onesignal.user.internal.operations.SetPropertyOperation import com.onesignal.user.internal.operations.SetTagOperation @@ -35,6 +37,8 @@ internal class UpdateUserOperationExecutor( private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, + private val _configModelStore: ConfigModelStore, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(SET_TAG, DELETE_TAG, SET_PROPERTY, TRACK_SESSION_START, TRACK_SESSION_END, TRACK_PURCHASE) @@ -137,15 +141,25 @@ internal class UpdateUserOperationExecutor( } if (appId != null && onesignalId != null) { + val firstOp = operations.first() + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + firstOp.externalId, + onesignalId, + ) + val jwt = firstOp.externalId?.let { _jwtTokenStore.getJwt(it) } + try { val rywData = _userBackend.updateUser( appId, - IdentityConstants.ONESIGNAL_ID, - onesignalId, + aliasLabel, + aliasValue, propertiesObject, refreshDeviceMetadata, deltasObject, + 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..b34d7069b7 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 @@ -10,10 +10,14 @@ import com.onesignal.user.internal.operations.DeleteAliasOperation import com.onesignal.user.internal.operations.SetAliasOperation internal class IdentityModelStoreListener( - store: IdentityModelStore, + private val _identityModelStore: IdentityModelStore, opRepo: IOperationRepo, private val _configModelStore: ConfigModelStore, -) : SingletonModelStoreListener(store, opRepo) { +) : SingletonModelStoreListener(_identityModelStore, opRepo) { + private fun shouldSuppressForAnonymousUser(): Boolean = + _configModelStore.model.useIdentityVerification == true && + _identityModelStore.model.externalId == null + override fun getReplaceOperation(model: IdentityModel): Operation? { // when the identity model is replaced, nothing to do on the backend. Already handled via login process. return null @@ -25,7 +29,9 @@ internal class IdentityModelStoreListener( property: String, oldValue: Any?, newValue: Any?, - ): Operation { + ): Operation? { + if (shouldSuppressForAnonymousUser()) return null + return if (newValue != null && newValue is String) { SetAliasOperation(_configModelStore.model.appId, model.onesignalId, property, newValue) } else { 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..8ca4d7326a 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,7 +15,12 @@ internal class PropertiesModelStoreListener( store: PropertiesModelStore, opRepo: IOperationRepo, private val _configModelStore: ConfigModelStore, + private val _identityModelStore: IdentityModelStore, ) : SingletonModelStoreListener(store, opRepo) { + private fun shouldSuppressForAnonymousUser(): Boolean = + _configModelStore.model.useIdentityVerification == true && + _identityModelStore.model.externalId == null + override fun getReplaceOperation(model: PropertiesModel): Operation? { // when the property model is replaced, nothing to do on the backend. Already handled via login process. return null @@ -27,6 +33,8 @@ internal class PropertiesModelStoreListener( oldValue: Any?, newValue: Any?, ): Operation? { + if (shouldSuppressForAnonymousUser()) return null + // for any of the property changes, we do not need to fire an operation. if (path.startsWith(PropertiesModel::locationTimestamp.name) || path.startsWith(PropertiesModel::locationBackground.name) || 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..4be3aa2e31 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 @@ -18,7 +18,13 @@ internal class SubscriptionModelStoreListener( private val _identityModelStore: IdentityModelStore, private val _configModelStore: ConfigModelStore, ) : ModelStoreListener(store, opRepo) { - override fun getAddOperation(model: SubscriptionModel): Operation { + private fun shouldSuppressForAnonymousUser(): Boolean = + _configModelStore.model.useIdentityVerification == true && + _identityModelStore.model.externalId == null + + override fun getAddOperation(model: SubscriptionModel): Operation? { + if (shouldSuppressForAnonymousUser()) return null + val enabledAndStatus = getSubscriptionEnabledAndStatus(model) return CreateSubscriptionOperation( _configModelStore.model.appId, @@ -31,7 +37,9 @@ internal class SubscriptionModelStoreListener( ) } - override fun getRemoveOperation(model: SubscriptionModel): Operation { + override fun getRemoveOperation(model: SubscriptionModel): Operation? { + if (shouldSuppressForAnonymousUser()) return null + return DeleteSubscriptionOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, model.id) } @@ -41,7 +49,9 @@ internal class SubscriptionModelStoreListener( property: String, oldValue: Any?, newValue: Any?, - ): Operation { + ): Operation? { + if (shouldSuppressForAnonymousUser()) return null + val enabledAndStatus = getSubscriptionEnabledAndStatus(model) return UpdateSubscriptionOperation( _configModelStore.model.appId, @@ -56,6 +66,10 @@ internal class SubscriptionModelStoreListener( companion object { fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair { + 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/subscriptions/SubscriptionModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt index c7bde3aae8..a4622d3aee 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,20 @@ class SubscriptionModel : Model() { setBooleanProperty(::optedIn.name, value) } + /** + * Set to true by the SDK when logout is called with Identity Verification enabled. + * The real [optedIn] and [status] remain unchanged to preserve the user's preference. + * When a subscription update is built, this flag causes enabled=false and + * status=UNSUBSCRIBE to be sent to the backend instead of the real values. + * On the next login, [UserSwitcher.createAndSwitchToNewUser] creates a fresh model + * that does not carry this flag (defaults to false), 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/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 164949612c..7fae5773af 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 @@ -12,6 +12,7 @@ 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.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.LoginUserOperation import io.kotest.core.spec.style.FunSpec @@ -72,6 +73,8 @@ private class Mocks { configModelStore, Time(), getNewRecordState(configModelStore), + mockk(relaxed = true), + MockHelper.identityModelStore(), ), recordPrivateCalls = true, ) @@ -97,6 +100,8 @@ class OperationRepoTests : FunSpec({ mocks.configModelStore, Time(), getNewRecordState(mocks.configModelStore), + mockk(relaxed = true), + MockHelper.identityModelStore(), ), ) @@ -889,6 +894,152 @@ 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 } + + test("FAIL_UNAUTHORIZED invalidates JWT and fires handler for identified user") { + // Given + val configModelStore = + MockHelper.configModelStore { + it.useIdentityVerification = true + } + val identityModelStore = + MockHelper.identityModelStore { + it.externalId = "test-user" + } + val jwtTokenStore = mockk(relaxed = true) + every { jwtTokenStore.getJwt("test-user") } returns "valid-jwt" + + val operationModelStore = + run { + val operationStoreList = mutableListOf() + val mock = mockk() + every { mock.loadOperations() } just runs + every { mock.list() } answers { operationStoreList.toList() } + every { mock.add(any()) } answers { operationStoreList.add(firstArg()) } + every { mock.remove(any()) } answers { + val id = firstArg() + operationStoreList.removeIf { it.id == id } + } + mock + } + + val executor = mockk() + every { executor.operations } returns listOf("DUMMY_OPERATION") + coEvery { executor.execute(any()) } returns + ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) andThen + ExecutionResponse(ExecutionResult.SUCCESS) + + val operationRepo = + spyk( + OperationRepo( + listOf(executor), + operationModelStore, + configModelStore, + Time(), + getNewRecordState(configModelStore), + jwtTokenStore, + identityModelStore, + ), + recordPrivateCalls = true, + ) + + var handlerCalledWith: String? = null + operationRepo.setJwtInvalidatedHandler { externalId -> + handlerCalledWith = externalId + } + + val operation = mockOperation() + every { operation.externalId } returns "test-user" + + // When + operationRepo.start() + val response = operationRepo.enqueueAndWait(operation) + + // Then + response shouldBe true + verify { jwtTokenStore.invalidateJwt("test-user") } + handlerCalledWith shouldBe "test-user" + } + + test("enqueue stamps externalId synchronously before async dispatch") { + // Verifies the fix for a race condition where createAndSwitchToNewUser() + // could clear the identity model's externalId before the async internalEnqueue + // had a chance to stamp it. + + // Given + val identityModel = com.onesignal.user.internal.identity.IdentityModel() + identityModel.id = "-singleton" + identityModel.onesignalId = "onesignal-id" + identityModel.externalId = "old-user" + + val identityModelStore = mockk(relaxed = true) + every { identityModelStore.model } returns identityModel + + val configModelStore = + MockHelper.configModelStore { + it.useIdentityVerification = true + } + val jwtTokenStore = mockk(relaxed = true) + every { jwtTokenStore.getJwt("old-user") } returns "valid-jwt" + + val operationModelStore = + run { + val operationStoreList = mutableListOf() + val mock = mockk() + every { mock.loadOperations() } just runs + every { mock.list() } answers { operationStoreList.toList() } + every { mock.add(any()) } answers { operationStoreList.add(firstArg()) } + every { mock.remove(any()) } answers { + val id = firstArg() + operationStoreList.removeIf { it.id == id } + } + mock + } + + val executor = mockk() + every { executor.operations } returns listOf("DUMMY_OPERATION") + coEvery { executor.execute(any()) } returns ExecutionResponse(ExecutionResult.SUCCESS) + + val operationRepo = + spyk( + OperationRepo( + listOf(executor), + operationModelStore, + configModelStore, + Time(), + getNewRecordState(configModelStore), + jwtTokenStore, + identityModelStore, + ), + recordPrivateCalls = true, + ) + + val operation = mockOperation() + // externalId starts null — stampExternalId should fill it from the identity model + + // When — enqueue then immediately switch user (simulating LogoutHelper's pattern) + operationRepo.enqueue(operation) + identityModel.externalId = null + + // Then — the operation should have captured "old-user" before the switch + operation.externalId shouldBe "old-user" + } + + test("FAIL_UNAUTHORIZED drops operations for anonymous user") { + // Given + val mocks = Mocks() + coEvery { mocks.executor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) + + val operation = mockOperation() + // externalId defaults to null in mockOperation + + // When + mocks.operationRepo.start() + val response = mocks.operationRepo.enqueueAndWait(operation) + + // Then + response shouldBe false + verify { mocks.operationModelStore.remove(operation.id) } + } }) { companion object { private fun mockOperation( @@ -913,6 +1064,9 @@ class OperationRepoTests : FunSpec({ every { operation.modifyComparisonKey } returns modifyComparisonKey every { operation.translateIds(any()) } just runs every { operation.applyToRecordId } returns applyToRecordId + every { operation.requiresJwt } returns true + every { operation.externalId } returns null + every { operation.externalId = any() } just runs return operation } 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 a501e73bcf..ec64157170 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 @@ -6,6 +6,7 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.properties.PropertiesModel import io.kotest.core.spec.style.FunSpec @@ -48,6 +49,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val loginHelper = @@ -56,6 +58,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) @@ -87,6 +90,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -108,6 +112,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) @@ -152,6 +157,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -173,6 +179,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) @@ -212,6 +219,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -234,6 +242,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) 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 4921ed6bb6..7d4f485952 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,7 @@ 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.SubscriptionModelStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -41,6 +42,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -49,6 +51,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) @@ -71,6 +74,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -79,6 +83,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) @@ -110,6 +115,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -118,6 +124,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) @@ -142,6 +149,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -150,6 +158,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) 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..0caf8f2f49 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 @@ -6,6 +6,7 @@ import com.onesignal.core.internal.time.impl.Time import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks import com.onesignal.user.internal.operations.LoginUserOperation import io.kotest.core.spec.style.FunSpec @@ -38,6 +39,8 @@ private class Mocks { configModelStore, Time(), ExecutorMocks.getNewRecordState(configModelStore), + mockk(relaxed = true), + MockHelper.identityModelStore(), ), ) 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..5c6d8d6c60 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 @@ -9,6 +9,7 @@ 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.identity.JwtTokenStore import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.equals.shouldBeEqual @@ -36,7 +37,7 @@ class CustomEventOperationExecutorTests : FunSpec({ val properties = JSONObject().put("key", "value").toString() val customEventOperationExecutor = - CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService) + CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService, mockk(relaxed = true)) val operations = listOf(TrackCustomEventOperation("appId", "onesignalId", null, 1, "event-name", properties)) // When 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..2ac7861484 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 @@ -10,6 +10,7 @@ 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.identity.JwtTokenStore 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,7 +40,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -69,7 +70,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -90,7 +91,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -111,7 +112,7 @@ class IdentityOperationExecutorTests : FunSpec({ every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -134,7 +135,7 @@ 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) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, mockConfigModelStore, mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -160,7 +161,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -183,7 +184,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -203,7 +204,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -225,7 +226,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -250,7 +251,7 @@ 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) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, mockConfigModelStore, mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When 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..001a3d96f4 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,7 @@ 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.identity.JwtTokenStore 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 @@ -53,7 +54,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login anonymous user successfully creates user") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -76,6 +77,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -94,6 +96,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -101,7 +104,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login anonymous user fails with retry when network condition exists") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT", retryAfterSeconds = 10) + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT", retryAfterSeconds = 10) val mockIdentityOperationExecutor = mockk() @@ -120,6 +123,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -133,13 +137,13 @@ class LoginUserOperationExecutorTests : FunSpec({ // Then response.result shouldBe ExecutionResult.FAIL_RETRY response.retryAfterSeconds shouldBe 10 - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } test("login anonymous user fails with no retry when backend error condition exists") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(404, "NOT FOUND") + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(404, "NOT FOUND") val mockIdentityOperationExecutor = mockk() @@ -148,7 +152,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(), mockk(relaxed = true)) val operations = listOf( LoginUserOperation(appId, localOneSignalId, null, null), @@ -160,13 +164,13 @@ class LoginUserOperationExecutorTests : FunSpec({ // Then response.result shouldBe ExecutionResult.FAIL_PAUSE_OPREPO - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } test("login identified user without association successfully creates user") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -176,7 +180,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(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) // When @@ -186,7 +190,7 @@ class LoginUserOperationExecutorTests : FunSpec({ response.result shouldBe ExecutionResult.SUCCESS coVerify( exactly = 1, - ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any()) } + ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any(), any()) } } // If the User is identified then the backend may have found an existing User, if so @@ -194,7 +198,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login identified user returns result with RefreshUser") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -214,6 +218,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) @@ -242,7 +247,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(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -267,7 +272,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login identified user with association fails with retry when association fails with retry") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -278,7 +283,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(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -303,7 +308,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login identified user with association successfully creates user when association fails with no retry") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -314,7 +319,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(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -336,13 +341,13 @@ class LoginUserOperationExecutorTests : FunSpec({ } coVerify( exactly = 1, - ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any()) } + ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any(), any()) } } test("login identified user with association fails with retry when association fails with no retry and network condition exists") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT") + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT") val mockIdentityOperationExecutor = mockk() coEvery { mockIdentityOperationExecutor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_NORETRY) @@ -352,7 +357,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(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -374,13 +379,13 @@ class LoginUserOperationExecutorTests : FunSpec({ } coVerify( exactly = 1, - ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any()) } + ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any(), any()) } } test("creating user will merge operations into one backend call") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -403,6 +408,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -459,6 +465,7 @@ class LoginUserOperationExecutorTests : FunSpec({ SubscriptionStatus.fromInt(subscription.notificationTypes!!) shouldBe SubscriptionStatus.SUBSCRIBED }, any(), + any(), ) } } @@ -466,7 +473,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("creating user will hydrate when the user hasn't changed") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -504,6 +511,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -545,6 +553,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -552,7 +561,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("creating user will not hydrate when the user has changed") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -590,6 +599,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -631,6 +641,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -638,7 +649,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("creating user will provide local to remote translations") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -662,6 +673,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -698,6 +710,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -705,7 +718,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("ensure anonymous login with no other operations will fail with FAIL_NORETRY") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -725,6 +738,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) // anonymous Login request val operations = listOf(LoginUserOperation(appId, localOneSignalId, null, null)) @@ -737,14 +751,14 @@ class LoginUserOperationExecutorTests : FunSpec({ // ensure user is not created by the bad request coVerify( exactly = 0, - ) { mockUserBackendService.createUser(appId, any(), any(), any()) } + ) { mockUserBackendService.createUser(appId, any(), any(), any(), any()) } } test("create user maps subscriptions when backend order is different (match by id/token)") { // Given val mockUserBackendService = mockk() // backend returns EMAIL first (with token), then PUSH — out of order - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -771,6 +785,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) // send PUSH then EMAIL (local IDs 1,2) — order differs from backend response @@ -795,14 +810,14 @@ class LoginUserOperationExecutorTests : FunSpec({ // email localSubscriptionId2 to remoteSubscriptionId2, ) - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } test("create user maps push subscription by type when id and token don't match (case for deleted push sub)") { // Given val mockUserBackendService = mockk() // simulate server-side push sub recreated with new ID and no token; must match by type - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -835,6 +850,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, configModelStore, MockHelper.languageContext(), + mockk(relaxed = true), ) val ops = @@ -857,6 +873,6 @@ class LoginUserOperationExecutorTests : FunSpec({ localPushModel.id shouldBe remoteSubscriptionId1 // pushSubscriptionId should be updated from local to remote id configModelStore.model.pushSubscriptionId shouldBe remoteSubscriptionId1 - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), 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..a32a004c73 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,7 @@ 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.identity.JwtTokenStore 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,6 +108,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockConfigModelStore, mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -191,6 +193,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -230,6 +233,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -265,6 +269,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -300,6 +305,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -337,6 +343,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, newRecordState, + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) 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..1658207431 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,7 @@ 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.identity.JwtTokenStore 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 +69,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -128,6 +130,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -178,6 +181,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -233,6 +237,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -288,6 +293,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -331,6 +337,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -377,6 +384,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -447,6 +455,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -508,6 +517,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -560,6 +570,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -614,6 +625,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -656,6 +668,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -690,6 +703,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -725,6 +739,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -760,6 +775,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -801,6 +817,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = 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..de3ff7b89b 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,7 @@ 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.identity.JwtTokenStore 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,6 +57,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) @@ -96,6 +99,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -158,6 +163,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -203,6 +210,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -268,6 +277,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -316,6 +327,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) @@ -349,6 +362,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) @@ -379,6 +394,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = 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..80e4ba4f93 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 @@ -48,6 +48,7 @@ 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.identity.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionChangedHandler import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -76,6 +77,7 @@ internal class InAppMessagesManager( private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, ) : IInAppMessagesManager, IStartableService, ISubscriptionChangedHandler, @@ -299,6 +301,12 @@ internal class InAppMessagesManager( return } + val externalId = _identityModelStore.model.externalId + if (_configModelStore.model.useIdentityVerification == true && externalId == null) { + Logging.debug("InAppMessagesManager.fetchMessages: Skipping IAM fetch for anonymous user while identity verification is enabled.") + return + } + fetchIAMMutex.withLock { val now = _time.currentTimeMillis if (lastTimeFetchedIAMs != null && (now - lastTimeFetchedIAMs!!) < _configModelStore.model.fetchIAMMinInterval) { @@ -308,9 +316,17 @@ internal class InAppMessagesManager( lastTimeFetchedIAMs = now } + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + externalId, + _identityModelStore.model.onesignalId, + ) + val jwt = externalId?.let { _jwtTokenStore.getJwt(it) } + // 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 = _backend.listInAppMessages(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) if (newMessages != null) { this.messages = newMessages as MutableList 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..7044d6db3b 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 @@ -21,9 +21,12 @@ internal interface IInAppBackendService { */ suspend fun listInAppMessages( appId: String, + aliasLabel: String, + aliasValue: String, subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? /** 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..77a77b5f5f 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 @@ -26,15 +26,18 @@ internal class InAppBackendService( override suspend fun listInAppMessages( appId: String, + aliasLabel: String, + aliasValue: String, subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String?, ): List? { val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS delay(rywDelay) // Delay by the specified amount - val baseUrl = "apps/$appId/subscriptions/$subscriptionId/iams" - return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider) + val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } override suspend fun getIAMData( @@ -209,6 +212,7 @@ internal class InAppBackendService( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? { var attempts = 0 var retryLimit: Int = 0 // retry limit is remote defined & set dynamically below @@ -220,6 +224,7 @@ internal class InAppBackendService( rywToken = rywData.rywToken, sessionDuration = sessionDurationProvider(), retryCount = retryCount, + jwt = jwt, ) val response = _httpClient.get(baseUrl, values) @@ -244,18 +249,20 @@ 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, ), ) 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..60a75847bd 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 @@ -31,6 +31,7 @@ import com.onesignal.session.internal.influence.IInfluenceManager import com.onesignal.session.internal.outcomes.IOutcomeEventsController import com.onesignal.session.internal.session.ISessionService import com.onesignal.user.IUserManager +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.IPushSubscription @@ -77,7 +78,7 @@ private class Mocks { val inAppLifecycleService = mockk(relaxed = true) val languageContext = MockHelper.languageContext() val time = MockHelper.time(1000) - val inAppMessageLifecycleListener = spyk() + val inAppMessageLifecycleListener = mockk(relaxed = true) val inAppMessageClickListener = spyk() val rywData = RywData("token", 100L) @@ -89,6 +90,8 @@ private class Mocks { coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred } + val jwtTokenStore = mockk(relaxed = true) + val subscriptionManager = mockk(relaxed = true) { every { subscriptions } returns mockk { every { push } returns pushSubscription @@ -187,6 +190,7 @@ private class Mocks { languageContext, time, consistencyManager, + jwtTokenStore, ) } @@ -455,7 +459,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null val args = ModelChangedArgs( ConfigModel(), ConfigModel::appId.name, @@ -470,7 +474,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then // Should trigger fetchMessagesWhenConditionIsMet - coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onModelUpdated does nothing when non-appId property changes") { @@ -488,7 +492,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onModelReplaced fetches messages") { @@ -497,7 +501,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null // When mocks.inAppMessagesManager.onModelReplaced(ConfigModel(), "tag") @@ -505,7 +509,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then coVerify { - mocks.backend.listInAppMessages(any(), any(), any(), any()) + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } } @@ -525,7 +529,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null // When mocks.inAppMessagesManager.onSubscriptionChanged(mocks.pushSubscription, args) @@ -533,7 +537,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then coVerify { - mocks.backend.listInAppMessages(any(), any(), any(), any()) + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } @@ -555,7 +559,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSubscriptionChanged does nothing when id path does not match") { @@ -575,7 +579,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSubscriptionAdded does not fetch") { @@ -587,7 +591,7 @@ class InAppMessagesManagerTests : FunSpec({ iamManager.onSubscriptionAdded(mockSubscription) // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSubscriptionRemoved does not fetch") { @@ -599,7 +603,7 @@ class InAppMessagesManagerTests : FunSpec({ iamManager.onSubscriptionRemoved(mockSubscription) // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } } @@ -622,7 +626,7 @@ class InAppMessagesManagerTests : FunSpec({ } returns mockDeferred every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null // When mocks.inAppMessagesManager.start() @@ -633,7 +637,7 @@ class InAppMessagesManagerTests : FunSpec({ // Verify messages were reset and backend was called message1.isDisplayedInSession shouldBe false message2.isDisplayedInSession shouldBe false - coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSessionActive does nothing") { @@ -772,7 +776,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // Fetch messages first mocks.inAppMessagesManager.onSessionStarted() @@ -792,7 +796,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // Fetch messages first mocks.inAppMessagesManager.onSessionStarted() @@ -943,7 +947,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is empty") { @@ -957,7 +961,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is local ID") { @@ -971,7 +975,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("fetchMessagesWhenConditionIsMet evaluates messages when new messages are returned") { @@ -981,14 +985,14 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // When mocks.inAppMessagesManager.onSessionStarted() awaitIO() // Then - coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } verify { mocks.triggerController.evaluateMessageTriggers(any()) } } } @@ -1028,7 +1032,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.inAppStateService.inAppMessageIdShowing } returns null every { mocks.inAppStateService.paused } returns true coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true // Fetch messages first @@ -1277,7 +1281,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false // Mock backend to return both messages - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message1, message2) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message1, message2) // Start the manager to load redisplayed messages mocks.inAppMessagesManager.start() @@ -1311,8 +1315,8 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - every { mocks.configModelStore.model.appId } returns "test-app-id" - every { mocks.configModelStore.model.fetchIAMMinInterval } returns 0L + mocks.configModelStore.model.appId = "test-app-id" + mocks.configModelStore.model.fetchIAMMinInterval = 0L every { mocks.triggerModelStore.get(any()) } returns null every { mocks.triggerModelStore.add(any()) } answers {} @@ -1324,7 +1328,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false // Mock first fetch to return the message - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) mocks.inAppMessagesManager.start() awaitIO() @@ -1344,7 +1348,7 @@ class InAppMessagesManagerTests : FunSpec({ earlySessionTriggers.contains("lateTrigger") shouldBe false // Mock second fetch to return the same message - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // Trigger second fetch mocks.inAppMessagesManager.onSessionStarted() @@ -1367,11 +1371,11 @@ class InAppMessagesManagerTests : FunSpec({ every { mockTriggerModelStore.add(any()) } answers {} coEvery { mockRepository.listInAppMessages() } returns mutableListOf() every { mockTriggerController.evaluateMessageTriggers(any()) } returns false - coEvery { mockBackend.listInAppMessages(any(), any(), any(), any()) } returns listOf(mocks.createInAppMessage()) + coEvery { mockBackend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(mocks.createInAppMessage()) every { mocks.pushSubscription.id } returns "test-sub-id" - every { mocks.configModelStore.model.appId } returns "test-app-id" - every { mocks.configModelStore.model.fetchIAMMinInterval } returns 0L + mocks.configModelStore.model.appId = "test-app-id" + mocks.configModelStore.model.fetchIAMMinInterval = 0L every { mocks.applicationService.isInForeground } returns true iamManager.start() @@ -1399,7 +1403,7 @@ class InAppMessagesManagerTests : FunSpec({ val messageAfterClear = mocks.createInAppMessage() // Mock backend for second fetch - coEvery { mockBackend.listInAppMessages(any(), any(), any(), any()) } returns listOf(messageAfterClear) + coEvery { mockBackend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(messageAfterClear) // Mock that message is in redisplayed and matches the cleared triggers coEvery { mockRepository.listInAppMessages() } returns mutableListOf(messageAfterClear) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt index 72f986e0ec..d2c0eb561e 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt @@ -40,12 +40,12 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) // Then response shouldNotBe null response!!.count() shouldBe 0 - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } test("listInAppMessages with 1 message returns one-lengthed array") { @@ -63,7 +63,7 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) // Then response shouldNotBe null @@ -84,7 +84,7 @@ class InAppBackendServiceTests : response[0].redisplayStats.displayLimit shouldBe 11111 response[0].redisplayStats.displayDelay shouldBe 22222 - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } test("listInAppMessages returns null when non-success response") { @@ -96,11 +96,11 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) // Then response shouldBe null - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } test( @@ -125,7 +125,7 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("1234", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("1234", 500L), mockSessionDurationProvider) // Then response shouldNotBe null @@ -133,7 +133,7 @@ class InAppBackendServiceTests : coVerify(exactly = 1) { mockHttpClient.get( - "apps/appId/subscriptions/subscriptionId/iams", + "apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", match { it.rywToken == "1234" && it.retryCount == null && it.sessionDuration == mockSessionDurationProvider() }, @@ -143,7 +143,7 @@ class InAppBackendServiceTests : // Verify that the get method retried twice with the RYW token coVerify(exactly = 3) { mockHttpClient.get( - "apps/appId/subscriptions/subscriptionId/iams", + "apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", match { it.rywToken == "1234" && it.sessionDuration == mockSessionDurationProvider() && it.retryCount != null }, @@ -153,7 +153,7 @@ class InAppBackendServiceTests : // Verify that the get method was retried the final time without the RYW token coVerify(exactly = 1) { mockHttpClient.get( - "apps/appId/subscriptions/subscriptionId/iams", + "apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", match { it.rywToken == null && it.sessionDuration == mockSessionDurationProvider() && it.retryCount == null }, diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt index 500b736e79..107e395d43 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt @@ -53,6 +53,7 @@ object MockHelper { configModel.foregroundFetchNotificationPermissionInterval = 1 configModel.appId = DEFAULT_APP_ID + configModel.useIdentityVerification = false if (action != null) { action(configModel) 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 a9c02609b2..45dbc39a72 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,12 +289,25 @@ 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( 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() + } } diff --git a/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md b/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md new file mode 100644 index 0000000000..282cd89665 --- /dev/null +++ b/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md @@ -0,0 +1,1268 @@ +# Identity Verification (JWT) Manual Test Plan + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Migration Paths Under Test](#migration-paths-under-test) +- [How to Prepare Each Migration Path](#how-to-prepare-each-migration-path) +- [Section 1: Startup and Initialization](#section-1-startup-and-initialization) +- [Section 2: Login with JWT](#section-2-login-with-jwt-iv-on) +- [Section 3: Multi-User Login Sequences](#section-3-multi-user-login-sequences-iv-on) +- [Section 4: Logout](#section-4-logout-iv-on) +- [Section 5: User Data Operations](#section-5-user-data-operations-iv-on) +- [Section 6: In-App Messages](#section-6-in-app-messages-iv-on) +- [Section 7: Caching, Persistence, and Retry](#section-7-caching-persistence-and-retry-iv-on) +- [Section 8: Migration Paths](#section-8-migration-paths) +- [Section 9: IV Toggle (Dashboard Changes)](#section-9-iv-toggle-dashboard-changes) +- [Section 10: Edge Cases and Error Handling](#section-10-edge-cases-and-error-handling) +- [Section 11: IV OFF Regression](#section-11-iv-off-regression) +- [Testing Checklist Summary](#testing-checklist-summary) + +--- + +## Prerequisites + +### Tools +- Android device or emulator +- OneSignal Dashboard access with ability to toggle Identity Verification (JWT) on/off +- A JWT generation tool or server endpoint to produce valid/invalid/expired JWTs for test external IDs +- Network proxy (e.g., Charles Proxy) or `adb logcat` with `LogLevel.VERBOSE` to inspect SDK network requests and logs +- The demo app (`Examples/demo`) built from the `feat/identity_verification_5.8` branch + +### Dashboard Setup +- OneSignal app configured with a REST API key (for the demo app's notification sending) +- Ability to toggle **Identity Verification** on and off in dashboard settings +- At least one In-App Message configured (for Section 6 tests) + +### Key Terminology +- **IV** = Identity Verification (the JWT feature) +- **IV ON** = `jwt_required: true` in remote params, `useIdentityVerification == true` in ConfigModel +- **IV OFF** = `jwt_required: false` in remote params, `useIdentityVerification == false` in ConfigModel +- **IV unknown** = Remote params haven't arrived yet, `useIdentityVerification == null` +- **HYDRATE** = The moment remote params are fetched and applied to ConfigModel +- **Sink user** = The local-only anonymous user created on logout when IV is ON (never sent to backend) + +### How to Verify with the Demo App +- **Login**: Tap "Login" button -> enter External User ID and JWT token -> confirm +- **Logout**: Tap "Logout" button +- **Update JWT**: Tap "Update JWT" button -> enter External User ID and JWT token -> confirm +- **JWT Invalidated Callback**: Watch the log view at the top of the demo app for "JWT invalidated for externalId: ..." messages +- **Add Tags/Aliases/Email/SMS**: Use the corresponding sections in the demo app +- **Network Requests**: Use `adb logcat | grep -i "OneSignal"` with `LogLevel.VERBOSE` or a network proxy + +### Log Messages to Watch For +- `"Identity verification is enabled"` -- logged on HYDRATE when IV turns on +- `"JWT invalidated for externalId: ..."` -- logged when `onUserJwtInvalidated` fires +- `"Authorization: Bearer ..."` -- in HTTP request headers when IV is on +- `"Removing operations without externalId"` -- when anonymous ops are purged +- `"hasValidJwtIfRequired"` -- when ops are gated on JWT availability +- `"FAIL_UNAUTHORIZED"` -- when a 401 response is received + +--- + +## Migration Paths Under Test + +Every scenario should be considered across these four starting states: + +| Path | Description | +|------|-------------| +| **New Install** | Fresh app install, no prior data in SharedPreferences | +| **v4 Player Model** | App was on SDK v4 (legacy player ID stored). Upgrade to this branch | +| **v5 (no IV)** | App was on v5 `main` branch (no JWT feature). Has existing anonymous or identified user. Upgrade to this branch | +| **JWT Beta** | App was on the previous `feat/identity_verification` beta branch (JWT stored as singleton on `IdentityModel`). Upgrade to this branch | + +--- + +## How to Prepare Each Migration Path + +### New Install +1. Uninstall the demo app completely (or clear all app data) +2. Build and install the `feat/identity_verification_5.8` branch + +### v4 Player Model +1. Build and install the demo app from a v4 SDK tag (e.g., `4.x.x`) +2. Open the app, let it register a player +3. Verify a legacy player ID is stored (visible in logcat) +4. WITHOUT uninstalling, build and install the `feat/identity_verification_5.8` branch over the top + +### v5 (no IV) +1. Build and install the demo app from the `main` branch (v5, no JWT feature) +2. Open the app, either leave as anonymous user OR login with an externalId (depending on the test) +3. Let the user sync to backend +4. WITHOUT uninstalling, build and install the `feat/identity_verification_5.8` branch over the top + +### JWT Beta +1. Build and install the demo app from the previous `feat/identity_verification` beta branch +2. Open the app, login with JWT +3. Optionally create the multi-user stuck state (login as userA with expired JWT, then login as userB) +4. WITHOUT uninstalling, build and install the `feat/identity_verification_5.8` branch over the top + +--- + +## Section 1: Startup and Initialization + +These test the critical window between `initWithContext` and remote params arriving, where `useIdentityVerification == null`. + +### Test 1.1: New install, IV ON on dashboard + +**Precondition**: Fresh install. IV is ON in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Uninstall app, build and install from `feat/identity_verification_5.8` | Clean install | +| 2 | Open app | `initWithContext` is called. Logcat shows anonymous `LoginUserOperation` enqueued | +| 3 | Immediately tap "Add Tag" and add key="test", value="1" | Tag op enqueued locally | +| 4 | Wait for remote params to arrive (watch logcat for "Identity verification is enabled") | HYDRATE fires with IV=true | +| 5 | Check logcat for "Removing operations without externalId" | Anonymous `LoginUserOperation` and the tag op are purged | +| 6 | Verify in OneSignal Dashboard: no new user was created | No anonymous user on backend | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.2: New install, IV OFF on dashboard + +**Precondition**: Fresh install. IV is OFF in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Uninstall app, build and install | Clean install | +| 2 | Open app | `initWithContext` called, anonymous `LoginUserOperation` enqueued | +| 3 | Immediately add a tag (key="test", value="1") | Tag op enqueued | +| 4 | Wait for remote params | HYDRATE fires with IV=false | +| 5 | Check logcat | Anonymous user creation request sent, tag request sent | +| 6 | Verify in dashboard | Anonymous user exists with the tag | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.3: New install, IV ON, no internet at startup + +**Precondition**: Fresh install. IV is ON in dashboard. Device in airplane mode. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet | +| 2 | Uninstall app, install from `feat/identity_verification_5.8` | Clean install | +| 3 | Open app | `initWithContext` called. Anonymous op enqueued. Remote params cannot be fetched | +| 4 | Tap Login -> enter externalId="alice", JWT=valid token | `LoginUserOperation` for alice enqueued, JWT stored in `JwtTokenStore` | +| 5 | Disable airplane mode | Internet restored | +| 6 | Wait for remote params to arrive | HYDRATE with IV=true. Anonymous ops purged | +| 7 | Check logcat for alice's `LoginUserOperation` executing with Authorization header | User "alice" created on backend | +| 8 | Verify in dashboard | User "alice" exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.4: Cold start (returning user, IV ON) + +**Precondition**: Previously logged in as "alice" with valid JWT. IV is ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT. Confirm user created on backend | Setup complete | +| 2 | Force-kill the app | App terminated | +| 3 | Reopen the app | `initWithContext` called. Persisted ops reload. JwtTokenStore loaded from SharedPreferences | +| 4 | Wait for HYDRATE | IV=true confirmed. `forceExecuteOperations()` called | +| 5 | Check that "alice" is still the current user (externalId shown in UI) | User identity persisted correctly | +| 6 | Add a tag | Tag sent to backend with Authorization header for alice | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.5: Cold start, IV ON, JWT expired in store + +**Precondition**: Logged in as "alice" with a JWT that will expire. IV is ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with a short-lived JWT. Verify user created | Setup complete | +| 2 | Wait for the JWT to expire (or use a pre-expired token from step 1) | JWT is now invalid | +| 3 | Force-kill the app | App terminated | +| 4 | Reopen the app | Persisted ops and JWT loaded | +| 5 | Wait for HYDRATE | Ops attempt to execute with expired JWT | +| 6 | Check logcat for 401 response and "JWT invalidated" | `onUserJwtInvalidated("alice")` fires | +| 7 | Check demo app log view | "JWT invalidated for externalId: alice" appears | +| 8 | Tap "Update JWT" -> enter externalId="alice", JWT=new valid token | JWT updated in store | +| 9 | Check logcat | Pending ops retry with new JWT and succeed | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 2: Login with JWT (IV ON) + +**Precondition for all tests in this section**: IV is ON in dashboard. Fresh install unless stated otherwise. + +### Test 2.1: Login with valid JWT + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install) | App initializes | +| 2 | Wait for HYDRATE (IV=true) | Anonymous ops purged | +| 3 | Tap Login -> externalId="alice", JWT=valid token | Login called | +| 4 | Check logcat for HTTP request | `POST /users` or `GET /users/by/external_id/alice` with `Authorization: Bearer ` | +| 5 | Verify in dashboard | User "alice" exists with push subscription | +| 6 | Check demo app UI | ExternalId shows "alice" | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.2: Login with invalid/expired JWT + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install), wait for HYDRATE | IV=true | +| 2 | Tap Login -> externalId="alice", JWT=expired/invalid token | Login called | +| 3 | Check logcat | `LoginUserOperation` executes, backend returns 401 | +| 4 | Check for callback | `onUserJwtInvalidated("alice")` fires. Demo app log shows "JWT invalidated for externalId: alice" | +| 5 | Verify in dashboard | User "alice" NOT created | +| 6 | Check that ops are re-queued and paused (no more requests until JWT updated) | No repeated 401 requests | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.3: Login then update JWT + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Perform Test 2.2 (login with expired JWT, callback fires) | Setup: alice with invalid JWT, ops paused | +| 2 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT updated in store, `forceExecuteOperations()` called | +| 3 | Check logcat | Ops retry with new JWT. `LoginUserOperation` succeeds | +| 4 | Verify in dashboard | User "alice" now exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.4: Same-user re-login (JWT refresh) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT. Wait for user creation | Alice exists on backend | +| 2 | Tap Login again -> externalId="alice", JWT=new valid token | Login called for same user | +| 3 | Check logcat | No new `LoginUserOperation`. Only JWT updated in store + `forceExecuteOperations()` | +| 4 | Check demo app UI | ExternalId still shows "alice". No loading spinner for user switch | +| 5 | Add a tag | Tag sent with the new JWT in Authorization header | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.5: Login without JWT when IV is ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install), wait for HYDRATE (IV=true) | Setup | +| 2 | Call login with externalId="alice" but no JWT (leave JWT field empty in login dialog) | Login called without JWT | +| 3 | Check logcat | `LoginUserOperation` enqueued but gated (no valid JWT in store) | +| 4 | Verify `onUserJwtInvalidated("alice")` fires | Demo app log shows invalidation message | +| 5 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT provided | +| 6 | Check logcat | Ops unblock and execute | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.6: Login with JWT when IV is OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Set IV OFF in dashboard | IV disabled | +| 2 | Open app (fresh install), wait for HYDRATE (IV=false) | Anonymous user created normally | +| 3 | Tap Login -> externalId="alice", JWT=valid token | Login called with JWT | +| 4 | Check logcat | Login proceeds via `onesignal_id`-based URLs (NOT `external_id`). NO `Authorization: Bearer` header sent | +| 5 | Verify in dashboard | User "alice" exists (created via standard flow) | +| 6 | Verify JWT is stored (it will be used later if IV is turned on) | Check logcat for "putJwt" or similar storage log | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 3: Multi-User Login Sequences (IV ON) + +These test the core design change: per-user JWT in `JwtTokenStore` instead of singleton. + +### Test 3.1: Rapid user switching + +**Precondition**: Fresh install. IV ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app, wait for HYDRATE | IV=true, anonymous ops purged | +| 2 | Login as "alice" with valid jwtA | Alice's `LoginUserOperation` enqueued | +| 3 | Add tag key="alice_tag", value="1" | Tag op enqueued with externalId="alice" | +| 4 | Login as "bob" with valid jwtB | Bob's `LoginUserOperation` enqueued. Alice's ops still in queue | +| 5 | Add tag key="bob_tag", value="2" | Tag op enqueued with externalId="bob" | +| 6 | Login as "alice" with valid jwtA2 | JWT refresh for alice. No new user switch if alice was previous user before bob | +| 7 | Add tag key="alice_tag2", value="3" | Tag op enqueued with externalId="alice" | +| 8 | Wait for all ops to process | Check logcat: each op uses correct JWT from JwtTokenStore | +| 9 | Verify in dashboard | alice has tags "alice_tag" and "alice_tag2". bob has tag "bob_tag" | +| 10 | Check demo app | Current user is alice (last login). Push subscription belongs to alice | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 3.2: Multi-user with invalid JWT for one user + +**Precondition**: Fresh install. IV ON. (Matches existing spreadsheet row 10) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "userA" with invalid JWT | Ops enqueued, will fail with 401 | +| 2 | Add tag key="tagA1", value="1" | Tag for userA enqueued | +| 3 | Login as "userB" with invalid JWT | Ops enqueued for userB | +| 4 | Add tag key="tagB1", value="2" | Tag for userB enqueued | +| 5 | Login as "userA" with invalid JWT | JWT refresh for userA (still invalid) | +| 6 | Add tag key="tagA2", value="3" | Another tag for userA | +| 7 | Login as "userB" with VALID JWT | JWT refresh for userB (now valid) | +| 8 | Wait for processing | userB's ops succeed: user created + tagB1 sent. userA's ops get 401, `onUserJwtInvalidated("userA")` fires | +| 9 | Verify in dashboard | userB exists with tagB1. userA does NOT exist yet | +| 10 | Verify current user is userB | Demo app shows externalId="userB" | +| 11 | Force-kill and reopen app | Cold start | +| 12 | Tap "Update JWT" -> externalId="userA", JWT=valid token | userA's JWT updated | +| 13 | Wait for processing | userA's ops execute: user created + tagA1 + tagA2 sent | +| 14 | Verify in dashboard | userA exists with both tags. userB still has its tag | +| 15 | Verify current user is still userB | Push subscription belongs to userB | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 3.3: One user's 401 does not block another + +**Precondition**: Fresh install. IV ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with expired JWT | Alice's ops enqueued | +| 2 | Add tag for alice | Tag enqueued for alice | +| 3 | Login as "bob" with valid JWT | Bob's ops enqueued | +| 4 | Add tag for bob | Tag enqueued for bob | +| 5 | Wait for processing | Bob's ops proceed and succeed. Alice's ops get 401, are re-queued | +| 6 | Check callbacks | `onUserJwtInvalidated("alice")` fires. No invalidation for bob | +| 7 | Verify in dashboard | Bob exists with tag. Alice does NOT exist yet | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 4: Logout (IV ON) + +### Test 4.1: Logout with IV ON + +**Precondition**: Logged in as "alice" with valid JWT. IV ON. User exists on backend. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Logout | Logout called | +| 2 | Check logcat | `createAndSwitchToNewUser(suppressBackendOperation=true)` -- local-only sink user created | +| 3 | Check logcat | Push subscription opted out locally (`isDisabledInternally = true`) | +| 4 | Check logcat | NO `LoginUserOperation` enqueued for the anonymous sink user | +| 5 | Check demo app | ExternalId shows empty/null. Push opt-in shows OFF | +| 6 | Wait 30 seconds | No network requests sent for anonymous user | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.2: Logout then add data + +**Precondition**: Perform Test 4.1 (logged out state with IV ON). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add tag key="sink_tag", value="1" | Tag written to local sink user | +| 2 | Add email "test@test.com" | Email written to local sink user | +| 3 | Check logcat | No network requests for tag or email. Ops suppressed by IV+anonymous checks | +| 4 | Wait 30 seconds | No backend calls for any of this data | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.3: Logout then login + +**Precondition**: Perform Test 4.2 (logged out with data on sink user). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Login -> externalId="bob", JWT=valid token | Login called | +| 2 | Check logcat | Sink user replaced entirely by bob. `LoginUserOperation` for bob enqueued and executes | +| 3 | Verify in dashboard | User "bob" exists. No "sink_tag" or "test@test.com" on bob's profile | +| 4 | Check demo app | ExternalId shows "bob" | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.4: Logout, background, reopen, then login (IAM test) + +**Precondition**: Logged in as "alice" with valid JWT. IV ON. At least one IAM configured in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Logout | Logged out, sink user created | +| 2 | Press Home to background the app | App backgrounded | +| 3 | Wait at least 60 seconds | Enough time for new session threshold | +| 4 | Reopen the app | New session triggered | +| 5 | Wait 15 seconds | No IAM fetch request in logcat (anonymous user, IV ON) | +| 6 | Login as "alice" with valid JWT | User re-authenticated | +| 7 | Check logcat | IAM fetch request sent with Authorization header for alice | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.5: Logout with IV OFF + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Logout | Standard v5 logout | +| 2 | Check logcat | New anonymous user created. `LoginUserOperation` enqueued for anonymous user | +| 3 | Wait for processing | Anonymous user created on backend. Push subscription transferred | +| 4 | Verify in dashboard | New anonymous user exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 5: User Data Operations (IV ON) + +**Precondition for all**: IV ON. Logged in as "alice" with valid JWT. User exists on backend. + +### Test 5.1: Add aliases + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap "Add Alias" -> label="my_alias", id="123" | Alias add called | +| 2 | Check logcat for HTTP request | URL contains `/users/by/external_id/alice/identity` (NOT `onesignal_id`). `Authorization: Bearer` header present | +| 3 | Verify in dashboard | Alias "my_alias:123" on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.2: Remove aliases + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Remove alias "my_alias" from Test 5.1 | Alias remove called | +| 2 | Check logcat | DELETE request to `/users/by/external_id/alice/identity/my_alias`. Auth header present | +| 3 | Verify in dashboard | Alias removed | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.3: Add tags + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add tag key="color", value="blue" | Tag add called | +| 2 | Check logcat | PATCH request to `/users/by/external_id/alice`. Auth header present | +| 3 | Verify in dashboard | Tag "color:blue" on alice | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.4: Add email/SMS subscriptions + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add email "alice@test.com" | Email subscription add called | +| 2 | Check logcat | POST to create subscription with Auth header | +| 3 | Add SMS "+15551234567" | SMS subscription add called | +| 4 | Check logcat | POST to create subscription with Auth header | +| 5 | Verify in dashboard | Email and SMS subscriptions on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.5: All operations while JWT is invalid + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with expired JWT | Ops queued, 401 received, callback fires | +| 2 | Add tag key="pending_tag", value="1" | Tag op queued, gated (no valid JWT) | +| 3 | Add alias label="pending_alias", id="456" | Alias op queued, gated | +| 4 | Add email "pending@test.com" | Email op queued, gated | +| 5 | Check logcat | No HTTP requests for these ops (all waiting for valid JWT) | +| 6 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT updated, `forceExecuteOperations()` | +| 7 | Check logcat | All queued ops flush: user created, tag sent, alias sent, email sent | +| 8 | Verify in dashboard | Alice exists with tag, alias, and email | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 6: In-App Messages (IV ON) + +### Test 6.1: IAM fetch with JWT + +**Precondition**: IV ON. Logged in as "alice" with valid JWT. IAM configured in dashboard for alice's segment. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Background the app for 60+ seconds, then reopen (trigger new session) | Session started | +| 2 | Check logcat for IAM fetch request | URL contains `/users/by/external_id/alice/subscriptions/.../iams`. `Authorization: Bearer` header present | +| 3 | Verify IAM displays correctly | Message appears in app | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 6.2: IAM fetch skipped for anonymous user + +**Precondition**: IV ON. Fresh install, no login. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install) | HYDRATE with IV=true, anonymous ops purged | +| 2 | Background for 60+ seconds, reopen | New session triggered | +| 3 | Check logcat | NO IAM fetch request (anonymous user doesn't exist on backend) | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 6.3: IAM fetch with expired JWT + +**Precondition**: IV ON. Logged in as "alice" but JWT has expired. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with a JWT that will expire soon. Wait for it to expire | JWT now invalid | +| 2 | Background for 60+ seconds, reopen | New session, IAM fetch attempted | +| 3 | Check logcat | IAM fetch fails with 401. `onUserJwtInvalidated("alice")` fires | +| 4 | Update JWT with valid token | JWT refreshed | +| 5 | Background and reopen again | New session | +| 6 | Check logcat | IAM fetch succeeds with new JWT | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 7: Caching, Persistence, and Retry (IV ON) + +### Test 7.1: Offline queueing + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install), wait for HYDRATE (IV=true) | Setup | +| 2 | Enable airplane mode | No internet | +| 3 | Login as "alice" with valid JWT | JWT stored. `LoginUserOperation` enqueued. HTTP fails (no network) | +| 4 | Add tag key="offline_tag", value="1" | Tag op enqueued | +| 5 | Add email "offline@test.com" | Email op enqueued | +| 6 | Force-kill the app | Ops persisted to disk | +| 7 | Disable airplane mode | Internet restored | +| 8 | Reopen the app | Persisted ops loaded. JWT still in JwtTokenStore | +| 9 | Wait for HYDRATE and processing | All ops execute with JWT: user created, tag sent, email added | +| 10 | Verify in dashboard | Alice exists with tag and email | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 7.2: Expired JWT in cache + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT. Verify user created | Setup | +| 2 | Update JWT: tap "Update JWT" -> externalId="alice", JWT=expired token | Expired JWT now in store | +| 3 | Add tags and aliases | Ops enqueued | +| 4 | Force-kill the app | Ops and expired JWT persisted | +| 5 | Reopen the app | Ops loaded, JWT loaded | +| 6 | Wait for processing | Ops try with expired JWT, get 401. `onUserJwtInvalidated("alice")` fires | +| 7 | Tap "Update JWT" -> externalId="alice", JWT=new valid token | JWT updated | +| 8 | Wait for processing | Ops retry and succeed | +| 9 | Verify in dashboard | Tags and aliases on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 7.3: JwtTokenStore pruning on cold start + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT | Alice's JWT stored | +| 2 | Add a tag for alice | Op queued for alice | +| 3 | Login as "bob" with valid JWT | Bob's JWT stored | +| 4 | Wait for all ops to complete | Both users created on backend | +| 5 | Force-kill the app | State persisted | +| 6 | Reopen the app | `loadSavedOperations()` runs, `pruneToExternalIds()` called | +| 7 | Check logcat | JwtTokenStore only contains entries for externalIds with pending ops + current identity. No stale entries from old users | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 8: Migration Paths + +### 8A: New Install + +#### Test 8A.1: New install, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Open app | Anonymous `LoginUserOperation` enqueued, held by IV=null gate | +| 2 | Wait for HYDRATE (IV=true) | Anonymous op purged. Log: "Removing operations without externalId" | +| 3 | Verify no user created on backend | Dashboard shows no new anonymous user | +| 4 | Login as "alice" with JWT | User created on backend | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8A.2: New install, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Open app | Anonymous `LoginUserOperation` enqueued | +| 2 | Wait for HYDRATE (IV=false) | Anonymous user created on backend normally | +| 3 | Verify in dashboard | Standard v5 anonymous user exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### 8B: v4 Player Model Migration + +#### Test 8B.1: v4 -> this branch, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 SDK demo app. Open, let player register | Legacy player ID stored | +| 2 | Upgrade to `feat/identity_verification_5.8` (install over) | Migration path triggered | +| 3 | Open app | `LoginUserFromSubscriptionOperation` enqueued. Held until HYDRATE | +| 4 | Wait for HYDRATE (IV=false) | Migration op proceeds: legacy player linked to new v5 user | +| 5 | Verify in dashboard | User has push subscription linked from legacy player | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8B.2: v4 -> this branch, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 SDK demo app. Open, let player register | Legacy player ID stored | +| 2 | Turn IV ON in dashboard | IV enabled | +| 3 | Upgrade to `feat/identity_verification_5.8` (install over) | Migration path triggered | +| 4 | Open app | `LoginUserFromSubscriptionOperation` enqueued (externalId=null). Held until HYDRATE | +| 5 | Wait for HYDRATE (IV=true) | `IdentityVerificationService` purges the op (externalId=null). Legacy player ID cleared | +| 6 | Check logcat | Executor safety net: `FAIL_NORETRY` if somehow reached. Purge message logged | +| 7 | Login as "alice" with JWT | New user created on backend | +| 8 | Verify in dashboard | Alice exists. Legacy player is NOT linked (migration was purged) | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8B.3: v4 -> this branch, IV ON, no internet then login + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 app, let player register. Turn IV ON in dashboard | Setup | +| 2 | Enable airplane mode | No internet | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | Migration op enqueued. No HYDRATE possible | +| 4 | Login as "alice" with valid JWT | Alice's op enqueued, JWT stored | +| 5 | Disable airplane mode | Internet restored | +| 6 | Wait for HYDRATE (IV=true) | Legacy migration op purged. Alice's op executes with JWT | +| 7 | Verify in dashboard | Alice exists. No legacy player linkage | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### 8C: v5 (no IV) Migration + +#### Test 8C.1: v5 (anonymous user) -> this branch, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Open, let anonymous user sync | Anonymous user on backend | +| 2 | Upgrade to `feat/identity_verification_5.8` | Migration | +| 3 | Open app. Wait for HYDRATE (IV=false) | Normal startup. Existing anonymous user continues | +| 4 | Verify | No behavioral change from standard v5 | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8C.2: v5 (anonymous user) -> this branch, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Open, let anonymous user sync | Anonymous user on backend | +| 2 | Turn IV ON in dashboard | IV enabled | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | Anonymous ops purged on HYDRATE | +| 4 | Wait for HYDRATE | SDK in "logged out" state. No anonymous user creation attempted | +| 5 | Login as "alice" with JWT | New user created on backend | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8C.3: v5 (identified user) -> this branch, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Login as "alice" (no JWT). Verify user on backend | Identified user exists | +| 2 | Turn IV ON in dashboard | IV enabled | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | HYDRATE fires | +| 4 | Check logcat | `IdentityVerificationService` detects externalId="alice" but no JWT in JwtTokenStore | +| 5 | Check callback | `onUserJwtInvalidated("alice")` fires. Demo app log shows it | +| 6 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT provided | +| 7 | Check logcat | Ops resume with JWT. Requests now include Authorization header | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8C.4: v5 (identified user) -> this branch, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Login as "alice". Verify user on backend | Identified user | +| 2 | IV remains OFF | No IV | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | Normal startup | +| 4 | Verify | Standard v5 behavior. No JWT required. No Authorization headers | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### 8D: JWT Beta Branch Migration + +#### Test 8D.1: Beta -> this branch, logged in user, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install beta branch demo app. Login as "alice" with JWT | Beta stores JWT on singleton IdentityModel | +| 2 | Upgrade to `feat/identity_verification_5.8` (install over) | Migration | +| 3 | Open app | Persisted ops from beta loaded. Beta ops lack `externalId` field (loaded as null) | +| 4 | Wait for HYDRATE (IV=true) | Ops with null externalId purged by IVS or skipped by OperationRepo | +| 5 | Check logcat | Stale `jwt_token` key on IdentityModel is harmless (not read) | +| 6 | Check callback | `IdentityVerificationService` detects: externalId="alice" + no JWT in new JwtTokenStore -> `onUserJwtInvalidated("alice")` fires | +| 7 | Tap "Update JWT" or re-login as "alice" with JWT | Fresh JWT provided | +| 8 | Check logcat | Ops execute with new JWT. User synced to backend | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8D.2: Beta -> this branch, multi-user stuck state + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install beta branch. Login as "userA" with expired JWT (ops stuck with 401) | Beta's singleton JWT bug: userA's 401 blocks everything | +| 2 | Login as "userB" on beta (overwrites singleton JWT) | Beta may be in inconsistent state | +| 3 | Upgrade to `feat/identity_verification_5.8` | Migration | +| 4 | Open app. Wait for HYDRATE (IV=true) | All stuck beta ops have null externalId -> purged. Clean slate | +| 5 | Login as "userA" with valid JWT | Fresh user creation for userA | +| 6 | Login as "userB" with valid JWT | Fresh user creation for userB | +| 7 | Verify both users on dashboard | Both exist independently | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 9: IV Toggle (Dashboard Changes) + +### Test 9.1: IV OFF -> IV ON (between app sessions) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Login as "alice" (no JWT). User exists on backend | Setup | +| 2 | Close the app (force kill) | App terminated | +| 3 | Turn IV ON in dashboard | IV now enabled | +| 4 | Reopen app | HYDRATE with IV=true | +| 5 | Check logcat | Alice has externalId but no JWT in store. `onUserJwtInvalidated("alice")` fires | +| 6 | Verify ops are gated | No backend requests until JWT provided | +| 7 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT provided | +| 8 | Check logcat | Ops resume with JWT Authorization headers | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 9.2: IV ON -> IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with JWT. User exists | Setup | +| 2 | Close the app | App terminated | +| 3 | Turn IV OFF in dashboard | IV disabled | +| 4 | Reopen app | HYDRATE with IV=false | +| 5 | Check logcat | All ops proceed without JWT gating. No Authorization headers. URLs use `onesignal_id` instead of `external_id` | +| 6 | Add a tag | Tag sent without auth header, via onesignal_id URL | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 9.3: Pre-provision JWT before IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF | IV disabled | +| 2 | Login as "alice" with valid JWT | JWT stored unconditionally in JwtTokenStore. Login proceeds normally without auth header | +| 3 | Verify user on backend via standard flow | Alice exists (created via onesignal_id) | +| 4 | Close app | App terminated | +| 5 | Turn IV ON in dashboard | IV enabled | +| 6 | Reopen app | HYDRATE with IV=true | +| 7 | Check logcat | Stored JWT immediately available. No `onUserJwtInvalidated` callback | +| 8 | Add a tag | Request uses `external_id` URL with Authorization header from pre-provisioned JWT | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 10: Edge Cases and Error Handling + +### Test 10.1: Callback contains correct externalId + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with expired JWT | 401 received | +| 2 | Check `onUserJwtInvalidated` event | `event.externalId` == "alice" (the user whose JWT failed, which IS the current user) | +| 3 | Login as "bob" with valid JWT, then update alice's JWT to expired | Bob current, alice has pending ops with bad JWT | +| 4 | Check callback | `event.externalId` == "alice" (NOT "bob", the current user) | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.2: Rapid login/logout cycles + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Fresh install, wait for HYDRATE | Setup | +| 2 | Login "a" with jwt -> logout -> login "b" with jwt -> logout -> login "c" with jwt (rapidly) | Multiple user switches | +| 3 | Wait for all ops to settle | Only "c" should have active ops that need to execute | +| 4 | Check demo app | Current user is "c" | +| 5 | Verify in dashboard | User "c" exists. No leaked data from "a" or "b" sink users on "c"'s profile | +| 6 | Check that users "a" and "b" exist on backend (their LoginUserOps executed before logout purged sink data) | Depends on timing -- they may or may not exist | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.3: updateUserJwt for non-current user + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with expired JWT (ops stuck). Then login as "bob" with valid JWT | Bob is current user. Alice has pending ops with bad JWT | +| 2 | Tap "Update JWT" -> externalId="alice", JWT=valid token | Alice's JWT updated | +| 3 | Check logcat | Alice's pending ops (from earlier) now execute with the new JWT | +| 4 | Check demo app | Current user remains "bob" | +| 5 | Verify in dashboard | Both alice and bob exist with correct data | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.4: No internet at startup, login, kill, internet on, reopen + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode. Fresh install | No internet | +| 2 | Open app | `initWithContext` called. Anonymous op enqueued. No HYDRATE possible | +| 3 | Login as "alice" with valid JWT | Alice's `LoginUserOperation` enqueued. JWT stored | +| 4 | Force-kill the app | Ops persisted | +| 5 | Disable airplane mode | Internet restored | +| 6 | Reopen app | Persisted ops loaded. HYDRATE arrives (IV=true). Anonymous ops purged | +| 7 | Check logcat | Alice's ops execute with JWT | +| 8 | Verify in dashboard | Alice exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.5: Delete user on server, then new session (IV ON) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with JWT. Verify user on backend | Setup | +| 2 | Delete user "alice" via OneSignal Dashboard or API | User removed from backend | +| 3 | Background app 60+ seconds, reopen | New session triggered | +| 4 | Check logcat | Session-related ops for alice may fail with an error. App should not crash | +| 5 | Check app behavior | SDK handles error gracefully | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 11: IV OFF Regression + +This branch modifies the core operation pipeline for ALL apps, even when Identity Verification is OFF. The most significant change is that `OperationRepo.getNextOps` now returns `null` (holding all ops) whenever `useIdentityVerification == null` -- which happens on every fresh launch before remote params arrive. Additionally, `externalId` is now stamped on all operations unconditionally, and the 401/FAIL_UNAUTHORIZED handler runs regardless of IV status. These tests ensure no regressions. + +### Test 11.1: Anonymous user creation on startup (HYDRATE timing) + +**Precondition**: Fresh install. IV is OFF in dashboard. Good network. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Uninstall app. Build and install from `feat/identity_verification_5.8` | Clean install | +| 2 | Open app. Start a timer | `initWithContext` called. Anonymous `LoginUserOperation` enqueued | +| 3 | Watch logcat for `useIdentityVerification` changing from null to false | HYDRATE arrives. Note the elapsed time | +| 4 | Verify the anonymous user creation request is sent immediately after HYDRATE | Request visible in logcat (POST /users) within seconds of app launch | +| 5 | Verify in dashboard | Anonymous user exists with push subscription | +| 6 | Note total time from app open to user creation | Should be comparable to pre-IV-branch behavior (remote params fetch is the only new gate) | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.2: HYDRATE stall -- cold start with persisted config + +**Precondition**: App was previously launched with IV OFF. Config is persisted. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app, wait for HYDRATE (IV=false), confirm anonymous user created | First launch done. Config persisted with `useIdentityVerification = false` | +| 2 | Force-kill the app | App terminated | +| 3 | Reopen the app. Watch logcat carefully | On cold start, persisted `ConfigModel` should already have `useIdentityVerification = false` | +| 4 | Check if ops are held or execute immediately | Ops should NOT be held waiting for HYDRATE -- persisted config has a known `false` value. Verify there is no unnecessary stall | +| 5 | Add a tag immediately after opening | Tag should be sent promptly without waiting for fresh remote params | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.3: HYDRATE stall -- prolonged offline (no remote params) + +**Precondition**: Fresh install. IV OFF in dashboard. Device in airplane mode. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet | +| 2 | Uninstall and reinstall app | Fresh install, no persisted config | +| 3 | Open app | `initWithContext` called. Anonymous op enqueued. Remote params fetch fails | +| 4 | Check logcat: what is the value of `useIdentityVerification`? | Should be `null` (unknown -- no remote params, no persisted config) | +| 5 | Wait 30 seconds. Check if any ops have executed | Ops should be HELD (queue stalled because IV is null). No network requests attempted for user creation | +| 6 | Add a tag, add an alias | Ops enqueued but also held | +| 7 | Disable airplane mode | Internet restored | +| 8 | Wait for remote params to arrive (HYDRATE) | `useIdentityVerification` set to `false` | +| 9 | Check logcat | All held ops (anonymous user creation, tag, alias) should now flush and execute | +| 10 | Verify in dashboard | Anonymous user exists with tag and alias | + +**Result**: [ ] PASS / [ ] FAIL + +**NOTE**: This test reveals the new queue-stall behavior. On the previous v5 main branch, ops would execute immediately even without remote params. Document any timing difference. + +--- + +### Test 11.4: HYDRATE stall -- remote params never arrive + +**Precondition**: Fresh install. Airplane mode stays ON the entire test. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet for entire test | +| 2 | Uninstall and reinstall app | Fresh install | +| 3 | Open app | Anonymous op enqueued. Remote params unreachable | +| 4 | Wait 2 minutes. Check logcat | Ops should remain held. `useIdentityVerification` stays `null`. The SDK should not crash or log errors beyond network failure | +| 5 | Add tags, aliases, login as "alice" (no JWT) | All ops enqueued but held | +| 6 | Force-kill and reopen app (still offline) | Persisted ops reload. Config still has `useIdentityVerification = null`. Ops still held | +| 7 | Disable airplane mode | Internet restored | +| 8 | Wait for HYDRATE | `useIdentityVerification` set to `false`. All held ops flush | +| 9 | Verify in dashboard | User exists (anonymous or alice depending on order). Tags and aliases present | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.5: Login with externalId (no JWT) + +**Precondition**: Fresh install. IV OFF. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app. Wait for HYDRATE (IV=false) | Anonymous user created | +| 2 | Tap Login -> externalId="alice", leave JWT empty | Login called without JWT | +| 3 | Check logcat | `LoginUserOperation` enqueued with `existingOneSignalId` set (alias-first flow: attach externalId to existing anonymous user). No Authorization header | +| 4 | Check URL in request | Uses `onesignal_id`-based URL (NOT `external_id`) | +| 5 | Verify in dashboard | User "alice" exists. Previous anonymous user's onesignal_id is alice's onesignal_id (merged) | +| 6 | Verify no JWT-related log messages | No "JWT invalidated", no "Authorization: Bearer" in any request | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.6: Login with externalId that already exists on backend (IV OFF) + +**Precondition**: IV OFF. User "alice" already exists on backend (from a previous device or test). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Open app. Wait for HYDRATE | Anonymous user created | +| 2 | Tap Login -> externalId="alice" (no JWT) | Login called | +| 3 | Check logcat | SDK identifies the existing backend user "alice" and associates this device | +| 4 | Verify in dashboard | Push subscription transferred to existing "alice" user | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.7: Logout creates new anonymous user on backend (IV OFF) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Logged in as "alice". Verify in dashboard | Setup | +| 2 | Tap Logout | Logout called | +| 3 | Check logcat | `createAndSwitchToNewUser()` called (NOT `suppressBackendOperation`). `LoginUserOperation` enqueued for new anonymous user | +| 4 | Check logcat for push | Push subscription transferred to new anonymous user (NOT disabled internally) | +| 5 | Verify in dashboard | New anonymous user created. Push subscription belongs to this new user. Alice's profile no longer has this device's push sub | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.8: Tags, aliases, email/SMS (IV OFF) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add tag key="color", value="red" | Tag sent | +| 2 | Check logcat | PATCH to `/users/by/onesignal_id/`. NO Authorization header | +| 3 | Add alias label="my_alias", id="123" | Alias sent | +| 4 | Check logcat | POST to `/users/by/onesignal_id//identity`. NO Authorization header | +| 5 | Add email "alice@test.com" | Email subscription created | +| 6 | Check logcat | POST to create subscription. NO Authorization header | +| 7 | Add SMS "+15551234567" | SMS subscription created | +| 8 | Verify all in dashboard | All data on alice's profile | +| 9 | Remove the alias | Delete request uses `onesignal_id` URL | +| 10 | Remove the tag | PATCH request uses `onesignal_id` URL | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.9: IAM fetching (IV OFF) + +**Precondition**: IV OFF. Logged in. IAM configured in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Background app for 60+ seconds, reopen | New session triggered | +| 2 | Check logcat for IAM fetch | URL uses `onesignal_id` (NOT `external_id`). NO Authorization header | +| 3 | Verify IAM displays | Message appears correctly | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.10: IAM fetching for anonymous user (IV OFF) + +**Precondition**: IV OFF. Anonymous user (no login). IAM configured for "All Users" segment. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Wait for HYDRATE. Anonymous user created | Setup | +| 2 | Background for 60+ seconds, reopen | New session | +| 3 | Check logcat | IAM fetch IS sent for anonymous user (unlike IV ON, where it's skipped). URL uses `onesignal_id` | +| 4 | Verify IAM displays | Message appears | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.11: Cached requests offline/online (IV OFF) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet | +| 2 | Add tag key="offline", value="1" | Op enqueued, network fails | +| 3 | Add alias label="offline_alias", id="789" | Op enqueued | +| 4 | Force-kill the app | Ops persisted | +| 5 | Disable airplane mode | Internet restored | +| 6 | Reopen app | Persisted ops loaded | +| 7 | Wait for ops to flush | Ops execute with `onesignal_id` URLs, no auth headers | +| 8 | Verify in dashboard | Tag and alias on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.12: Multi-user login/logout sequence (IV OFF) + +**Precondition**: IV OFF. Fresh install. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app. Wait for HYDRATE. Anonymous user created | Setup | +| 2 | Login as "alice" (no JWT) | Alice's user created/merged from anonymous | +| 3 | Add tag key="alice_tag", value="1" | Tag sent for alice | +| 4 | Login as "bob" (no JWT) | Bob's user created. New session for bob | +| 5 | Add tag key="bob_tag", value="2" | Tag sent for bob | +| 6 | Logout | New anonymous user created on backend | +| 7 | Login as "alice" (no JWT) | Alice re-identified | +| 8 | Verify in dashboard | alice has "alice_tag". bob has "bob_tag". Push subscription is on alice (last login) | +| 9 | Check logcat throughout | No Authorization headers anywhere. All URLs use `onesignal_id`. No JWT-related log messages | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.13: Login with JWT when IV is OFF (JWT stored but unused) + +**Precondition**: IV OFF. Fresh install. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app. Wait for HYDRATE (IV=false) | Anonymous user created | +| 2 | Login as "alice" with a valid JWT token | Login proceeds | +| 3 | Check logcat | JWT stored in JwtTokenStore (unconditional). BUT login request uses `onesignal_id` URL with NO Authorization header | +| 4 | Add a tag | Tag request: `onesignal_id` URL, no auth header | +| 5 | Verify in dashboard | Alice exists, tag present. Standard v5 flow despite JWT being provided | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.14: 401 response handling when IV is OFF + +**Precondition**: IV OFF. Logged in as "alice" with a JWT stored (from Test 11.13 or similar). This tests the unconditional FAIL_UNAUTHORIZED code path. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Force a 401 scenario (e.g., delete user on backend, then try to add a tag) | Operation sent, backend returns 401 | +| 2 | Check logcat for FAIL_UNAUTHORIZED handling | SDK calls `jwtTokenStore.invalidateJwt("alice")` and fires `onUserJwtInvalidated("alice")` -- even though IV is OFF | +| 3 | Check demo app log | "JWT invalidated for externalId: alice" appears | +| 4 | Verify the app does not crash or enter a bad state | App continues functioning. The callback is informational but does not block anything (IV is OFF, so ops are not JWT-gated) | +| 5 | Check if the failed op is retried or dropped | Verify the retry/drop behavior matches standard v5 error handling | + +**Result**: [ ] PASS / [ ] FAIL + +**NOTE**: This is a new behavior introduced by this branch. Document whether the `onUserJwtInvalidated` callback firing with IV OFF is acceptable or needs to be gated. + +--- + +### Test 11.15: Cold start with IV OFF (returning user) + +**Precondition**: IV OFF. Previously logged in as "alice". App was killed. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice". Add a tag. Verify on backend | Setup complete | +| 2 | Force-kill app | App terminated | +| 3 | Reopen app | Cold start. Persisted config has `useIdentityVerification = false` | +| 4 | Check logcat timing | Ops should NOT be stalled waiting for HYDRATE (persisted config already has `false`) | +| 5 | Check that "alice" is still the current user | ExternalId shown in demo app | +| 6 | Add a new tag immediately | Tag should be sent promptly to backend | +| 7 | Verify in dashboard | New tag on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.16: v4 -> this branch migration (IV OFF) + +**Precondition**: App was on v4 SDK with a registered player. IV OFF in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 demo app. Open, let player register | Legacy player ID in SharedPreferences | +| 2 | Upgrade to `feat/identity_verification_5.8` (install over top) | Migration path triggered | +| 3 | Open app | `LoginUserFromSubscriptionOperation` enqueued. Held until HYDRATE (IV=null) | +| 4 | Wait for HYDRATE (IV=false) | Migration op executes: legacy player linked to new v5 user | +| 5 | Note timing: how long from app open to migration completion? | Should be only the remote-params fetch time (same as standard upgrade) | +| 6 | Verify in dashboard | User has push subscription linked from legacy player | +| 7 | Add tags, aliases | Standard operations work | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.17: v5 (no IV) -> this branch (anonymous user, IV OFF) + +**Precondition**: App was on v5 main (no JWT feature). Anonymous user exists on backend. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Open, let anonymous user sync | Anonymous user on backend | +| 2 | Upgrade to `feat/identity_verification_5.8` | SDK upgrade | +| 3 | Open app | Config persisted from prior session may not have `useIdentityVerification` field | +| 4 | Check logcat: is the queue stalled until HYDRATE? | If prior config lacked `useIdentityVerification`, it will be `null` until HYDRATE. Verify ops are held briefly | +| 5 | Wait for HYDRATE (IV=false) | Ops resume. Existing anonymous user continues | +| 6 | Add tags, login, logout | All standard v5 operations work identically | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.18: v5 (no IV) -> this branch (identified user, IV OFF) + +**Precondition**: App was on v5 main. Logged in as "alice" (no JWT). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Login as "alice". Verify on backend | Identified user exists | +| 2 | Upgrade to `feat/identity_verification_5.8`. IV stays OFF | SDK upgrade | +| 3 | Open app | Config loaded | +| 4 | Check logcat | No `onUserJwtInvalidated` callback (IV is OFF, so IVS does not fire invalidation) | +| 5 | Check demo app | "alice" is still the current user | +| 6 | Add tags, aliases | Standard operations. `onesignal_id` URLs. No auth headers | +| 7 | Logout and re-login | Standard v5 flow | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.19: externalId stamped on operations (IV OFF -- verify no side effects) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add a tag | Tag op enqueued | +| 2 | Check logcat/debug: does the operation carry `externalId = "alice"`? | Yes -- OperationRepo stamps externalId unconditionally on new ops | +| 3 | Verify the presence of `externalId` on the op does NOT cause it to use `external_id` in the URL | URL still uses `onesignal_id` (resolveAlias checks `useIdentityVerification == true` before using external_id) | +| 4 | Verify no Authorization header | No auth header (JWT lookup returns null or is not used for auth when IV is false) | +| 5 | Force-kill, reopen | Persisted op has externalId field | +| 6 | Verify ops reload and execute correctly | No issues from the extra field on persisted ops | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.20: JwtTokenStore pruning does not interfere (IV OFF) + +**Precondition**: IV OFF. Login as "alice" with JWT, then login as "bob" with JWT. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with JWT. Login as "bob" with JWT | JWTs stored for both | +| 2 | Wait for all ops to complete | Both users on backend | +| 3 | Force-kill and reopen | `loadSavedOperations` runs, `pruneToExternalIds` called | +| 4 | Check logcat | JwtTokenStore pruned. Should not cause errors or affect op execution | +| 5 | Add a tag for bob | Tag sent normally. No auth header. `onesignal_id` URL | +| 6 | Verify no interference from JWT store | Operations proceed identically to pre-IV-branch behavior | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Testing Checklist Summary + +For each migration path (New Install, v4, v5 no-IV, Beta), verify: + +| Check | New Install | v4 | v5 (no IV) | Beta | +|-------|:-----------:|:--:|:----------:|:----:| +| **IV ON** | | | | | +| No anonymous user created on backend | [ ] | [ ] | [ ] | [ ] | +| Login with valid JWT creates user | [ ] | [ ] | [ ] | [ ] | +| Login with invalid JWT fires callback | [ ] | [ ] | [ ] | [ ] | +| updateUserJwt unblocks pending ops | [ ] | [ ] | [ ] | [ ] | +| Logout creates local-only sink user, push disabled | [ ] | [ ] | [ ] | [ ] | +| Multi-user JWT isolation (A's bad JWT doesn't block B) | [ ] | [ ] | [ ] | [ ] | +| Cold start restores ops and JWTs correctly | [ ] | [ ] | [ ] | [ ] | +| IAM fetch uses external_id + JWT | [ ] | [ ] | [ ] | [ ] | +| **IV OFF** | | | | | +| HYDRATE stall: ops held until IV resolved, then execute | [ ] | [ ] | [ ] | [ ] | +| Cold start with persisted config: no unnecessary stall | [ ] | [ ] | [ ] | [ ] | +| Prolonged offline: ops held but resume after HYDRATE | [ ] | [ ] | [ ] | [ ] | +| Anonymous user creation timing comparable to pre-IV | [ ] | [ ] | [ ] | [ ] | +| Login/logout standard v5 flow (no auth headers) | [ ] | [ ] | [ ] | [ ] | +| Multi-user login/logout (no JWT interference) | [ ] | [ ] | [ ] | [ ] | +| Tags, aliases, email/SMS via onesignal_id URLs | [ ] | [ ] | [ ] | [ ] | +| IAM fetch for anonymous and identified users | [ ] | [ ] | [ ] | [ ] | +| Offline caching and retry works | [ ] | [ ] | [ ] | [ ] | +| 401 handling does not break app (callback may fire) | [ ] | [ ] | [ ] | [ ] | +| externalId on ops does not affect URL or auth | [ ] | [ ] | [ ] | [ ] | +| **Migration-specific** | | | | | +| Correct handling of legacy player ID / beta JWT / existing identified user | N/A | [ ] | [ ] | [ ] | +| v4 migration completes after HYDRATE stall | N/A | [ ] | N/A | N/A | +| v5 upgrade with no prior IV config field | N/A | N/A | [ ] | [ ] |