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())