From 03a333ca4d9640194eaf70a5f4fcdedebfc6673a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 20:10:53 -0700 Subject: [PATCH 1/4] Fix race condition: purge anonymous ops AFTER queue is loaded IdentityVerificationService.onModelReplaced (config HYDRATE) could fire before OperationRepo.loadSavedOperations() finished, causing removeOperationsWithoutExternalId() to run against an empty queue. Wrap the HYDRATE handler in suspendifyOnIO + awaitInitialized() so the purge waits for the queue to be fully populated, following the same pattern as RecoverFromDroppedLoginBug. --- .../impl/IdentityVerificationService.kt | 27 +++++++++++-------- .../internal/operations/impl/OperationRepo.kt | 4 +-- 2 files changed, 17 insertions(+), 14 deletions(-) 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 597cd908f..213ec68ed 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 4f37c846d..6694d5448 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. From 89cca43d03ace78736c446aa8e4db01e90150948 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 20:11:33 -0700 Subject: [PATCH 2/4] Replay JWT invalidated event to late-registered listeners fireJwtInvalidated now buffers the externalId under a synchronized lock when no listeners are subscribed (e.g. during SDK init HYDRATE) and replays it when the first IUserJwtInvalidatedListener is added. Clears pending event on user switch (onModelReplaced) to prevent stale replay. --- .../onesignal/user/internal/UserManager.kt | 43 ++++++++++++++----- .../user/internal/UserManagerTests.kt | 26 +++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) 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 e55e03ff6..f02fd720d 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/test/java/com/onesignal/user/internal/UserManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt index d4f3121b8..ab3f49ff2 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 + } }) From b48dc1095936d334fce09a42fafcd0ccb7a5a6a2 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 20:12:10 -0700 Subject: [PATCH 3/4] Fix IAM fetch stuck after login when identity verification is enabled LoginUserOperationExecutor.createUser() was not passing the RYW token from the backend response to the ConsistencyManager, causing the IamFetchRywTokenKey condition to never resolve after login. This meant InAppMessagesManager coroutines awaited forever and IAMs were never fetched for the logged-in user. - Add rywData field to CreateUserResponse - Parse ryw_token/ryw_delay in JSONConverter.convertToCreateUserResponse - Set RYW data in ConsistencyManager after successful createUser - Fix resolveConditionsWithID to only remove matching conditions - Wrap resolveConditionsWithID in mutex for thread safety --- .../consistency/impl/ConsistencyManager.kt | 20 ++++++++-------- .../internal/backend/IUserBackendService.kt | 4 ++++ .../internal/backend/impl/JSONConverter.kt | 8 ++++++- .../executors/LoginUserOperationExecutor.kt | 9 ++++++++ .../LoginUserOperationExecutorTests.kt | 23 ++++++++++++++----- 5 files changed, 48 insertions(+), 16 deletions(-) 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 7169fcbb4..f22d512fa 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/user/internal/backend/IUserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt index b849fc4c4..db9b29d75 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 ff3745b32..a2f3adfdd 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/operations/impl/executors/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt index 013c34334..af29fba89 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/operations/LoginUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt index df9b566f2..f42ff89cd 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 = From f94d78014268608c6f2d3a95db942d6708c9f79f Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 20:12:33 -0700 Subject: [PATCH 4/4] Retry IAM fetch after JWT refresh on 401/403 response When an IAM fetch returns an unauthorized response (401 or 403), the SDK now saves the pending fetch state and automatically retries once the JWT is refreshed for the same user via updateUserJwt. Switching users clears any stale retry. - Add IJwtUpdateListener to JwtTokenStore for post-putJwt notification - InAppMessagesManager subscribes and retries on JWT update - Reset rate-limiter on 401 so retry is not throttled - Use @Volatile on cross-thread fields (lastTimeFetchedIAMs, pendingJwtRetryExternalId, pendingJwtRetryRywData) - RYW-aware fetches bypass rate limiter to avoid stale data - Handle 401 in fetchInAppMessagesWithoutRywToken fallback path --- OneSignalSDK/detekt/detekt-baseline-core.xml | 6 +- .../detekt-baseline-in-app-messages.xml | 8 +- .../user/internal/identity/JwtTokenStore.kt | 22 ++ .../internal/InAppMessagesManager.kt | 65 ++++-- .../internal/backend/IInAppBackendService.kt | 2 +- .../backend/impl/InAppBackendService.kt | 15 +- .../internal/InAppMessagesManagerTests.kt | 200 ++++++++++++++++++ .../backend/InAppBackendServiceTests.kt | 45 ++++ 8 files changed, 336 insertions(+), 27 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index ed239183e..3d3cf23c7 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 da9439705..32b618ef7 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/user/internal/identity/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt index c678d6d21..bf45b0919 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/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 a5f4c6045..ea69d4d56 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 7044d6db3..31260f3b3 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 77a77b5f5..a582533ce 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 60a75847b..b0351369b 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 d2c0eb561..b20215697 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())