diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index ed239183e7..3d3cf23c7e 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -177,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 @@ -217,7 +213,7 @@ 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, 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:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, private val _consistencyManager: IConsistencyManager, ) LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, ) LongParameterList:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _jwtTokenStore: JwtTokenStore, ) 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, ) diff --git a/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml b/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml index da9439705a..32b618ef7c 100644 --- a/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml +++ b/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml @@ -68,7 +68,7 @@ LongMethod:InAppRepository.kt$InAppRepository$override suspend fun cleanCachedInAppMessages() 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 @@ -103,12 +103,12 @@ 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: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 messageHasOnlyDynamicTriggers(message: InAppMessage): Boolean @@ -124,7 +124,7 @@ TooManyFunctions:InAppBackendService.kt$InAppBackendService : IInAppBackendService TooManyFunctions:InAppMessage.kt$InAppMessage : IInAppMessage TooManyFunctions:InAppMessageView.kt$InAppMessageView - TooManyFunctions:InAppMessagesManager.kt$InAppMessagesManager : IInAppMessagesManagerIStartableServiceISubscriptionChangedHandlerISingletonModelStoreChangeHandlerIInAppLifecycleEventHandlerITriggerHandlerISessionLifecycleHandlerIApplicationLifecycleHandler + TooManyFunctions:InAppMessagesManager.kt$InAppMessagesManager : IInAppMessagesManagerIStartableServiceISubscriptionChangedHandlerISingletonModelStoreChangeHandlerIInAppLifecycleEventHandlerITriggerHandlerISessionLifecycleHandlerIApplicationLifecycleHandlerIJwtUpdateListener TooManyFunctions:TriggerController.kt$TriggerController : ITriggerControllerIModelStoreChangeHandler TooManyFunctions:WebViewManager.kt$WebViewManager : IActivityLifecycleHandler UndocumentedPublicClass:TriggerModel.kt$TriggerModel : Model diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt index 7169fcbb4d..f22d512fa5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt @@ -56,19 +56,21 @@ class ConsistencyManager : IConsistencyManager { } override suspend fun resolveConditionsWithID(id: String) { - val completedConditions = mutableListOf>>() + mutex.withLock { + val completedConditions = mutableListOf>>() - for ((condition, deferred) in conditions) { - if (condition.id == id) { - if (!deferred.isCompleted) { - deferred.complete(null) + for ((condition, deferred) in conditions) { + if (condition.id == id) { + if (!deferred.isCompleted) { + deferred.complete(null) + } + completedConditions.add(Pair(condition, deferred)) } } - completedConditions.add(Pair(condition, deferred)) - } - // Remove completed conditions from the list - conditions.removeAll(completedConditions) + // Remove completed conditions from the list + conditions.removeAll(completedConditions) + } } /** 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 index 597cd908f6..213ec68ed8 100644 --- 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 @@ -3,6 +3,7 @@ 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.common.threading.suspendifyOnIO import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo @@ -42,21 +43,25 @@ internal class IdentityVerificationService( val useIV = model.useIdentityVerification - var jwtInvalidatedExternalId: String? = null - if (useIV == true) { - Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations") - _operationRepo.removeOperationsWithoutExternalId() + suspendifyOnIO { + _operationRepo.awaitInitialized() - val externalId = _identityModelStore.model.externalId - if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) { - Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, will fire invalidated event after queue wake") - jwtInvalidatedExternalId = externalId + var jwtInvalidatedExternalId: String? = null + 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, will fire invalidated event after queue wake") + jwtInvalidatedExternalId = externalId + } } - } - _operationRepo.forceExecuteOperations() + _operationRepo.forceExecuteOperations() - jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) } + jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) } + } } override fun onModelUpdated( 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 4f37c846d6..6694d5448f 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 @@ -555,9 +555,7 @@ internal class OperationRepo( _operationModelStore.remove(it.operation.id) it.waiter?.wake(false) } - if (toRemove.isNotEmpty()) { - Logging.debug("OperationRepo: removed ${toRemove.size} anonymous operations (no externalId)") - } + Logging.debug("OperationRepo: removeOperationsWithoutExternalId removed ${toRemove.size} of ${toRemove.size + queue.size} operations") // IV=ON never transfers anonymous state; clear existingOnesignalId so // the executor takes the createUser (upsert) path. 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 e55e03ff63..f02fd720dd 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 @@ -55,8 +55,19 @@ internal open class UserManager( private val jwtInvalidatedAppCallbackScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val jwtInvalidatedLock = Any() + private var pendingJwtInvalidatedExternalId: String? = null + fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - jwtInvalidatedNotifier.subscribe(listener) + val pendingExternalId: String? + synchronized(jwtInvalidatedLock) { + jwtInvalidatedNotifier.subscribe(listener) + pendingExternalId = pendingJwtInvalidatedExternalId + pendingJwtInvalidatedExternalId = null + } + pendingExternalId?.let { + listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(it)) + } } fun removeJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { @@ -64,17 +75,25 @@ internal open class UserManager( } /** - * Schedules [IUserJwtInvalidatedListener] delivery on a background dispatcher so HYDRATE and - * operation-repo paths can finish internal work before app code runs. + * Fires [IUserJwtInvalidatedListener] to all subscribers asynchronously so the caller + * (e.g. OperationRepo) is not blocked by developer code. If no listeners are registered yet + * (e.g. during SDK init), stores the externalId so it can be replayed when the first + * listener is added via [addJwtInvalidatedListener]. */ fun fireJwtInvalidated(externalId: String) { - jwtInvalidatedAppCallbackScope.launch { - runCatching { - jwtInvalidatedNotifier.fire { listener -> - listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + synchronized(jwtInvalidatedLock) { + if (jwtInvalidatedNotifier.hasSubscribers) { + jwtInvalidatedAppCallbackScope.launch { + runCatching { + jwtInvalidatedNotifier.fire { listener -> + listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + } + }.onFailure { + Logging.warn("Failed to deliver JWT invalidated event for externalId=$externalId", it) + } } - }.onFailure { - Logging.warn("Failed to deliver JWT invalidated event for externalId=$externalId", it) + } else { + pendingJwtInvalidatedExternalId = externalId } } } @@ -297,7 +316,11 @@ internal open class UserManager( override fun onModelReplaced( model: IdentityModel, tag: String, - ) { } + ) { + synchronized(jwtInvalidatedLock) { + pendingJwtInvalidatedExternalId = null + } + } override fun onModelUpdated( args: ModelChangedArgs, 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 b849fc4c42..db9b29d758 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 @@ -84,4 +84,8 @@ class CreateUserResponse( * The subscriptions for the user. */ val subscriptions: List, + /** + * Read-your-write consistency data returned by the backend, if any. + */ + val rywData: RywData? = null, ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt index ff3745b32b..a2f3adfdd1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal.backend.impl +import com.onesignal.common.consistency.RywData import com.onesignal.common.expandJSONArray import com.onesignal.common.putJSONArray import com.onesignal.common.putMap @@ -8,6 +9,7 @@ import com.onesignal.common.safeBool import com.onesignal.common.safeDouble import com.onesignal.common.safeInt import com.onesignal.common.safeJSONObject +import com.onesignal.common.safeLong import com.onesignal.common.safeString import com.onesignal.common.toMap import com.onesignal.user.internal.backend.CreateUserResponse @@ -55,7 +57,11 @@ object JSONConverter { return@expandJSONArray null } - return CreateUserResponse(respIdentities, respProperties, respSubscriptions) + val rywToken = jsonObject.safeString("ryw_token") + val rywDelay = jsonObject.safeLong("ryw_delay") + val rywData = if (rywToken != null) RywData(rywToken, rywDelay) else null + + return CreateUserResponse(respIdentities, respProperties, respSubscriptions, rywData) } fun convertToJSON(properties: PropertiesObject): JSONObject { 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 index c678d6d219..bf45b09198 100644 --- 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 @@ -1,5 +1,6 @@ package com.onesignal.user.internal.identity +import com.onesignal.common.events.EventProducer import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores @@ -7,6 +8,14 @@ import com.onesignal.debug.internal.logging.Logging import org.json.JSONException import org.json.JSONObject +/** + * Listener notified when a JWT is stored or replaced for an external ID. + */ +fun interface IJwtUpdateListener { + /** Called after [JwtTokenStore.putJwt] persists a new token for [externalId]. */ + fun onJwtUpdated(externalId: String) +} + /** * 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. @@ -20,6 +29,7 @@ class JwtTokenStore( ) { private val tokens: MutableMap = mutableMapOf() private var isLoaded = false + private val jwtUpdateNotifier = EventProducer() /** Not thread-safe; callers must hold `synchronized(tokens)`. */ private fun ensureLoaded() { @@ -61,6 +71,16 @@ class JwtTokenStore( } } + /** Register a [listener] to be notified when any JWT is updated via [putJwt]. */ + fun subscribe(listener: IJwtUpdateListener) { + jwtUpdateNotifier.subscribe(listener) + } + + /** Remove a previously registered [listener]. */ + fun unsubscribe(listener: IJwtUpdateListener) { + jwtUpdateNotifier.unsubscribe(listener) + } + /** * Stores (or replaces) the JWT for [externalId]. Passing a null [jwt] is a no-op; * use [invalidateJwt] to remove a token. @@ -72,9 +92,11 @@ class JwtTokenStore( if (jwt == null) return synchronized(tokens) { ensureLoaded() + if (tokens[externalId] == jwt) return tokens[externalId] = jwt persist() } + jwtUpdateNotifier.fire { it.onJwtUpdated(externalId) } } /** 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 013c343340..af29fba892 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt @@ -8,6 +8,9 @@ import com.onesignal.common.NetworkUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.RootToolsInternalMethods import com.onesignal.common.TimeUtils +import com.onesignal.common.consistency.IamFetchReadyCondition +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.core.internal.application.IApplicationService @@ -49,6 +52,7 @@ internal class LoginUserOperationExecutor( private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, + private val _consistencyManager: IConsistencyManager, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER) @@ -225,6 +229,11 @@ internal class LoginUserOperationExecutor( backendSubscriptions.remove(backendSubscription) } + if (response.rywData != null) { + _consistencyManager.setRywData(backendOneSignalId, IamFetchRywTokenKey.USER, response.rywData) + } + _consistencyManager.resolveConditionsWithID(IamFetchReadyCondition.ID) + val wasPossiblyAnUpsert = identities.isNotEmpty() val followUpOperations = if (wasPossiblyAnUpsert) { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt index d4f3121b8a..ab3f49ff2d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt @@ -2,6 +2,7 @@ package com.onesignal.user.internal import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.mocks.MockHelper +import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionList import io.kotest.assertions.throwables.shouldNotThrow @@ -15,6 +16,8 @@ import io.mockk.mockk import io.mockk.runs import io.mockk.slot import io.mockk.verify +import kotlin.reflect.full.memberFunctions +import kotlin.reflect.jvm.isAccessible class UserManagerTests : FunSpec({ @@ -235,4 +238,27 @@ class UserManagerTests : FunSpec({ ) } } + + test("onModelReplaced clears pendingJwtInvalidatedExternalId") { + // Given + val mockSubscriptionManager = mockk() + val userManager = + UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), MockHelper.propertiesModelStore(), MockHelper.customEventController(), MockHelper.languageContext()) + + // Fire a JWT invalidated event with no subscribers, so it pends + val fireMethod = UserManager::class.memberFunctions.first { it.name == "fireJwtInvalidated" } + fireMethod.isAccessible = true + fireMethod.call(userManager, "user-alice") + + // Verify pending state is set + val pendingField = UserManager::class.java.getDeclaredField("pendingJwtInvalidatedExternalId") + pendingField.isAccessible = true + pendingField.get(userManager) shouldBe "user-alice" + + // When — user switches (model replaced) + userManager.onModelReplaced(IdentityModel(), "test") + + // Then — pending state should be cleared + pendingField.get(userManager) shouldBe null + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt index df9b566f2f..f42ff89cda 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 @@ -1,6 +1,7 @@ package com.onesignal.user.internal.operations import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult @@ -79,6 +80,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf( @@ -125,6 +127,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf( @@ -153,7 +156,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) + LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), mockk(relaxed = true)) val operations = listOf( LoginUserOperation(appId, localOneSignalId, null, null), @@ -181,7 +184,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) // When @@ -220,6 +223,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) @@ -248,7 +252,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -284,7 +288,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -320,7 +324,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -358,7 +362,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -410,6 +414,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf( @@ -516,6 +521,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf( @@ -606,6 +612,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf( @@ -682,6 +689,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val operations = listOf( @@ -749,6 +757,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) // anonymous Login request val operations = listOf(LoginUserOperation(appId, localOneSignalId, null, null)) @@ -796,6 +805,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) // send PUSH then EMAIL (local IDs 1,2) — order differs from backend response @@ -861,6 +871,7 @@ class LoginUserOperationExecutorTests : FunSpec({ configModelStore, MockHelper.languageContext(), mockk(relaxed = true), + mockk(relaxed = true), ) val ops = 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 a5f4c60455..ea69d4d560 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 @@ -4,6 +4,7 @@ import android.app.AlertDialog import com.onesignal.common.AndroidUtils import com.onesignal.common.IDManager import com.onesignal.common.JSONUtils +import com.onesignal.common.NetworkUtils import com.onesignal.common.consistency.IamFetchReadyCondition import com.onesignal.common.consistency.RywData import com.onesignal.common.consistency.models.IConsistencyManager @@ -46,6 +47,7 @@ import com.onesignal.session.internal.session.ISessionLifecycleHandler import com.onesignal.session.internal.session.ISessionService import com.onesignal.user.IUserManager import com.onesignal.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.identity.IJwtUpdateListener import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.identity.JwtTokenStore @@ -85,7 +87,8 @@ internal class InAppMessagesManager( IInAppLifecycleEventHandler, ITriggerHandler, ISessionLifecycleHandler, - IApplicationLifecycleHandler { + IApplicationLifecycleHandler, + IJwtUpdateListener { private val lifecycleCallback = EventProducer() private val messageClickCallback = EventProducer() @@ -116,6 +119,8 @@ internal class InAppMessagesManager( private val redisplayedInAppMessages: MutableList = mutableListOf() private val fetchIAMMutex = Mutex() + + @Volatile private var lastTimeFetchedIAMs: Long? = null // Tracks whether the first IAM fetch has completed since this cold start @@ -124,12 +129,23 @@ internal class InAppMessagesManager( // Tracks trigger keys added early on cold start (before first fetch completes), for redisplay logic private val earlySessionTriggers: MutableSet = java.util.Collections.synchronizedSet(mutableSetOf()) + // Pending IAM retry state for 401 (expired JWT) responses. + // Stores the externalId and rywData from the failed fetch so we can retry after JWT refresh. + @Volatile + private var pendingJwtRetryExternalId: String? = null + + @Volatile + private var pendingJwtRetryRywData: RywData? = null + private val identityModelChangeHandler = object : ISingletonModelStoreChangeHandler { override fun onModelReplaced( model: IdentityModel, tag: String, - ) { } + ) { + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + } override fun onModelUpdated( args: ModelChangedArgs, @@ -144,10 +160,8 @@ internal class InAppMessagesManager( suspendifyOnIO { val updateConditionDeferred = _consistencyManager.getRywDataFromAwaitableCondition(IamFetchReadyCondition(newOneSignalId)) - val rywToken = updateConditionDeferred.await() - if (rywToken != null) { - fetchMessages(rywToken) - } + val rywData = updateConditionDeferred.await() + fetchMessages(rywData) } } } @@ -192,6 +206,7 @@ internal class InAppMessagesManager( _sessionService.subscribe(this) _applicationService.addApplicationLifecycleHandler(this) _identityModelStore.subscribe(identityModelChangeHandler) + _jwtTokenStore.subscribe(this) suspendifyOnIO { _repository.cleanCachedInAppMessages() @@ -277,15 +292,11 @@ internal class InAppMessagesManager( val iamFetchCondition = _consistencyManager.getRywDataFromAwaitableCondition(IamFetchReadyCondition(onesignalId)) val rywData = iamFetchCondition.await() - - if (rywData != null) { - fetchMessages(rywData) - } + fetchMessages(rywData) } } - // called when a new push subscription is added, or the app id is updated, or a new session starts - private suspend fun fetchMessages(rywData: RywData) { + private suspend fun fetchMessages(rywData: RywData?) { // We only want to fetch IAMs if we know the app is in the // foreground, as we don't want to do this for background // events (such as push received), wasting resources for @@ -318,7 +329,7 @@ internal class InAppMessagesManager( fetchIAMMutex.withLock { val now = _time.currentTimeMillis - if (lastTimeFetchedIAMs != null && (now - lastTimeFetchedIAMs!!) < _configModelStore.model.fetchIAMMinInterval) { + if (rywData == null && lastTimeFetchedIAMs != null && (now - lastTimeFetchedIAMs!!) < _configModelStore.model.fetchIAMMinInterval) { return } @@ -333,7 +344,18 @@ internal class InAppMessagesManager( ) // lambda so that it is updated on each potential retry val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime } - val newMessages = _backend.listInAppMessages(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + val newMessages = + try { + _backend.listInAppMessages(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + } catch (ex: BackendException) { + if (NetworkUtils.getResponseStatusType(ex.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + Logging.debug("InAppMessagesManager.fetchMessages: ${ex.statusCode} response. Will retry after JWT refresh for externalId=$externalId") + lastTimeFetchedIAMs = null + pendingJwtRetryRywData = rywData + pendingJwtRetryExternalId = externalId + } + null + } if (newMessages != null) { this.messages = newMessages as MutableList @@ -1024,6 +1046,21 @@ internal class InAppMessagesManager( .show() } + override fun onJwtUpdated(externalId: String) { + val retryExternalId = pendingJwtRetryExternalId ?: return + if (externalId != retryExternalId) return + + val retryRywData = pendingJwtRetryRywData + + Logging.debug("InAppMessagesManager.onJwtUpdated: JWT refreshed for $externalId, retrying IAM fetch") + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + + suspendifyOnIO { + fetchMessages(retryRywData) + } + } + override fun onFocus(firedOnSubscribe: Boolean) { } override fun onUnfocused() { } 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 7044d6db3b..31260f3b3b 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 @@ -24,7 +24,7 @@ internal interface IInAppBackendService { aliasLabel: String, aliasValue: String, subscriptionId: String, - rywData: RywData, + 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 77a77b5f5f..a582533ce5 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 @@ -29,14 +29,19 @@ internal class InAppBackendService( aliasLabel: String, aliasValue: String, subscriptionId: String, - rywData: RywData, + rywData: RywData?, sessionDurationProvider: () -> Long, jwt: String?, ): List? { + val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" + + if (rywData == null) { + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) + } + val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS - delay(rywDelay) // Delay by the specified amount + delay(rywDelay) - val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } @@ -239,6 +244,8 @@ internal class InAppBackendService( response.retryAfterSeconds?.let { delay(it * 1_000L) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else if (response.statusCode in 500..599) { return null } else { @@ -269,6 +276,8 @@ internal class InAppBackendService( if (response.isSuccess) { val jsonResponse = response.payload?.let { JSONObject(it) } return jsonResponse?.let { hydrateInAppMessages(it) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else { return null } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 60a75847bd..b0351369ba 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 @@ -6,6 +6,7 @@ import com.onesignal.common.consistency.IamFetchReadyCondition import com.onesignal.common.consistency.RywData import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.core.internal.config.ConfigModel import com.onesignal.debug.LogLevel @@ -31,6 +32,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.IdentityModel import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -45,6 +47,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.runs +import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.verify @@ -169,6 +172,20 @@ private class Mocks { return property.get(manager) as Boolean } + fun getPendingJwtRetryExternalId(manager: InAppMessagesManager): String? { + val property = InAppMessagesManager::class.memberProperties + .first { it.name == "pendingJwtRetryExternalId" } + property.isAccessible = true + return property.get(manager) as String? + } + + fun getPendingJwtRetryRywData(manager: InAppMessagesManager): RywData? { + val property = InAppMessagesManager::class.memberProperties + .first { it.name == "pendingJwtRetryRywData" } + property.isAccessible = true + return property.get(manager) as RywData? + } + // Helper function to create InAppMessagesManager with all dependencies val inAppMessagesManager = InAppMessagesManager( applicationService, @@ -1418,4 +1435,187 @@ class InAppMessagesManagerTests : FunSpec({ messageAfterClear.isTriggerChanged shouldBe false } } + + context("Null RYW data") { + test("fetchMessagesWhenConditionIsMet fetches without ryw token when condition resolves with null") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + + val nullRywDeferred = mockk> { + coEvery { await() } returns null + } + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns nullRywDeferred + coEvery { + mocks.backend.listInAppMessages(any(), any(), any(), any(), isNull(), any(), any()) + } returns listOf(mocks.createInAppMessage()) + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then — should call listInAppMessages with null rywData + coVerify(exactly = 1) { + mocks.backend.listInAppMessages(any(), any(), any(), any(), isNull(), any(), any()) + } + } + + test("onJwtUpdated retries with null rywData when pendingJwtRetryExternalId is set") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + mocks.identityModelStore.model.externalId = "test-external-id" + + val nullRywDeferred = mockk> { + coEvery { await() } returns null + } + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns nullRywDeferred + + // First call throws 401, second (retry) succeeds + coEvery { + mocks.backend.listInAppMessages(any(), any(), any(), any(), isNull(), any(), any()) + } throws BackendException(401, "Unauthorized") andThen listOf(mocks.createInAppMessage()) + + // Trigger initial fetch that will 401 + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id" + mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null + + // When — JWT is refreshed + mocks.inAppMessagesManager.onJwtUpdated("test-external-id") + awaitIO() + + // Then — should have retried, pending state cleared + coVerify(exactly = 2) { + mocks.backend.listInAppMessages(any(), any(), any(), any(), isNull(), any(), any()) + } + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null + } + } + + context("JWT 401 Retry") { + test("fetchMessages stores pending retry state on 401 BackendException") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + mocks.identityModelStore.model.externalId = "test-external-id" + coEvery { + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) + } throws BackendException(401, "Unauthorized") + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id" + mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe mocks.rywData + } + + test("onJwtUpdated retries fetch when externalId matches pending retry") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + mocks.identityModelStore.model.externalId = "test-external-id" + + // First call throws 401, second call succeeds + coEvery { + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) + } throws BackendException(401, "Unauthorized") andThen listOf(mocks.createInAppMessage()) + + // Trigger the initial fetch that will 401 + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Verify pending state was set + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id" + + // When - JWT is updated for the same external ID + mocks.inAppMessagesManager.onJwtUpdated("test-external-id") + awaitIO() + + // Then - should have retried and cleared the pending state + coVerify(exactly = 2) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null + mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null + } + + test("onJwtUpdated does not retry when externalId does not match pending retry") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + mocks.identityModelStore.model.externalId = "test-external-id" + coEvery { + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) + } throws BackendException(401, "Unauthorized") + + // Trigger the initial fetch that will 401 + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When - JWT is updated for a DIFFERENT external ID + mocks.inAppMessagesManager.onJwtUpdated("different-external-id") + awaitIO() + + // Then - should NOT have retried, pending state remains + coVerify(exactly = 1) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id" + } + + test("onJwtUpdated does nothing when no pending retry") { + // Given - no 401 has happened, so no pending retry + + // When + mocks.inAppMessagesManager.onJwtUpdated("any-external-id") + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } + } + + test("pending retry state is cleared on user switch (identity model replaced)") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + mocks.identityModelStore.model.externalId = "test-external-id" + coEvery { + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) + } throws BackendException(401, "Unauthorized") + + // Capture the handler passed to identityModelStore.subscribe + val handlerSlot = slot>() + every { mocks.identityModelStore.subscribe(capture(handlerSlot)) } just runs + + // Start the manager to subscribe + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + mocks.inAppMessagesManager.start() + awaitIO() + + // Trigger 401 + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe "test-external-id" + + // When - simulate user switch via the captured handler + handlerSlot.captured.onModelReplaced(IdentityModel(), "test") + + // Then - pending retry state should be cleared + mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null + mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null + } + } }) 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 d2c0eb561e..b20215697c 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 @@ -87,6 +87,51 @@ class InAppBackendServiceTests : coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } + test("listInAppMessages throws BackendException on 401 response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(401, "{\"errors\":[\"Invalid token\"]}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When / Then + val exception = + shouldThrowUnit { + inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider, "expired-jwt") + } + + exception.statusCode shouldBe 401 + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } + } + + test("listInAppMessages throws BackendException on 401 from fallback (no RYW token) path") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + + // Exhaust retries with 425 then return 401 on the fallback request (without RYW token) + coEvery { + mockHttpClient.get(any(), any()) + } returnsMany + listOf( + HttpResponse(425, null, retryAfterSeconds = 0, retryLimit = 0), + HttpResponse(401, "{\"errors\":[\"Invalid token\"]}"), + ) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When / Then + val exception = + shouldThrowUnit { + inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider, "expired-jwt") + } + + exception.statusCode shouldBe 401 + // First call is the retry attempt (with RYW), second is the fallback (without RYW) + coVerify(exactly = 2) { mockHttpClient.get(any(), any()) } + } + test("listInAppMessages returns null when non-success response") { // Given val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore())