From a7d44d1f6032dac89a8bb1a72b3284f02ebbf906 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 1 May 2026 08:50:25 -0700 Subject: [PATCH 01/12] feat(iv): logout IV behavior + RYW plumbing for IAM consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logout under IV-required (Step A): - Add SubscriptionModel.isDisabledInternally — internal-only flag that tells SubscriptionModelStoreListener to suppress backend ops for the marked subscription. - LogoutHelperIvExtensions.switchUserIv: when ivBehaviorActive, mark the current push sub as internally disabled and switchUser with suppressBackendOperation=true. The new device-scoped (anonymous) user can't authenticate without a JWT, so we must NOT enqueue an anonymous LoginUserOperation that would block the queue. - LogoutHelper.switchUser: outer gate (newCodePathsRun) dispatches to the extension; legacy logout flow runs untouched for Phase 1/3. RYW plumbing (Step B): - CreateUserResponse.rywData (nullable) — backend sends ryw_token under IV so InAppMessagesManager can await user-record propagation before the IAM fetch. - JSONConverter.convertToCreateUserResponse parses ryw_token / ryw_delay. - LoginUserOperationExecutor.createUser sets RYW data in ConsistencyManager (gated on newCodePathsRun) so the IamFetchRywToken condition resolves and unblocks the IAM fetch. Co-Authored-By: Claude Sonnet 4.6 --- .../com/onesignal/internal/OneSignalImp.kt | 3 ++ .../onesignal/user/internal/LogoutHelper.kt | 25 ++++++++++-- .../user/internal/LogoutHelperIvExtensions.kt | 40 +++++++++++++++++++ .../internal/backend/IUserBackendService.kt | 7 ++++ .../internal/backend/impl/JSONConverter.kt | 9 ++++- .../executors/LoginUserOperationExecutor.kt | 13 ++++++ .../SubscriptionModelStoreListener.kt | 7 ++++ .../subscriptions/SubscriptionModel.kt | 17 ++++++++ 8 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index b3043623ed..a2f6e24c7d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -17,6 +17,7 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.operations.IOperationRepo @@ -234,6 +235,8 @@ internal class OneSignalImp( userSwitcher = userSwitcher, operationRepo = operationRepo, configModel = configModel, + subscriptionModelStore = subscriptionModelStore, + identityVerificationService = services.getService(), lock = loginLogoutLock, ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt index e0017d5b3d..1f58533369 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt @@ -1,15 +1,19 @@ package com.onesignal.user.internal import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore -class LogoutHelper( +internal class LogoutHelper( private val identityModelStore: IdentityModelStore, private val userSwitcher: UserSwitcher, private val operationRepo: IOperationRepo, private val configModel: ConfigModel, + private val subscriptionModelStore: SubscriptionModelStore, + private val identityVerificationService: IdentityVerificationService, private val lock: Any, ) { internal data class LogoutEnqueueContext( @@ -29,11 +33,26 @@ class LogoutHelper( return null } + // Outer gate: dispatch to IV extension only on new code paths. The extension's + // inner gate (ivBehaviorActive) keeps Phase 3 users on the legacy logout flow. + val handled = + identityVerificationService.newCodePathsRun && + switchUserIv( + userSwitcher, + subscriptionModelStore, + configModel, + identityVerificationService.ivBehaviorActive, + ) + if (handled) { + // IV-required: subscription is internally disabled and the user-switch + // suppressed backend op enqueue. Don't enqueue anonymous LoginUserOperation — + // the anonymous user cannot authenticate without a JWT. + return null + } + // Create new device-scoped user (clears external ID) userSwitcher.createAndSwitchToNewUser() - // TODO: remove JWT Token for all future requests. - return LogoutEnqueueContext(configModel.appId, identityModelStore.model.onesignalId) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt new file mode 100644 index 0000000000..22eaf7eb6b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt @@ -0,0 +1,40 @@ +package com.onesignal.user.internal + +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore + +/** + * IV-specific behavior for [LogoutHelper]. The base-class call site dispatches via + * `if (newCodePathsRun) switchUserIv(...) else legacyLogout()`; this extension + * internally short-circuits on `ivBehaviorActive` to keep Phase 3 users (new code path on, + * IV behavior off) on the legacy logout flow. + */ + +/** + * Performs the IV-aware logout user-switch when [ivBehaviorActive] is true. + * + * Under IV-required, the new device-scoped (anonymous) user can't authenticate against + * the backend without a JWT. To prevent the model-store listener from generating create- + * subscription ops that would 401/permanently-block the queue: + * 1. Mark the current push subscription as internally disabled. + * 2. Switch users with [UserSwitcher.createAndSwitchToNewUser] in `suppressBackendOperation` + * mode so subscription replacement does NOT propagate to listeners that would enqueue + * backend ops. + * + * Returns `true` when IV-specific handling was applied (caller skips legacy enqueue), + * or `false` when IV behavior is inactive (caller falls through to the legacy logout). + */ +internal fun switchUserIv( + userSwitcher: UserSwitcher, + subscriptionModelStore: SubscriptionModelStore, + configModel: ConfigModel, + ivBehaviorActive: Boolean, +): Boolean { + if (!ivBehaviorActive) return false + + configModel.pushSubscriptionId?.let { pushSubId -> + subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true } + } + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + return true +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt index b849fc4c42..7cc509d450 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt @@ -84,4 +84,11 @@ class CreateUserResponse( * The subscriptions for the user. */ val subscriptions: List, + /** + * Read-your-write data for IAM fetch consistency, when the backend supplies it. + * Populated under Identity Verification so [com.onesignal.inAppMessages] can await + * the user record's propagation before fetching IAMs. `null` for non-IV apps and + * older backends that don't include `ryw_token` in the response. + */ + val rywData: com.onesignal.common.consistency.RywData? = null, ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt index ff3745b32b..5c971df6e5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal.backend.impl +import com.onesignal.common.consistency.RywData import com.onesignal.common.expandJSONArray import com.onesignal.common.putJSONArray import com.onesignal.common.putMap @@ -8,6 +9,7 @@ import com.onesignal.common.safeBool import com.onesignal.common.safeDouble import com.onesignal.common.safeInt import com.onesignal.common.safeJSONObject +import com.onesignal.common.safeLong import com.onesignal.common.safeString import com.onesignal.common.toMap import com.onesignal.user.internal.backend.CreateUserResponse @@ -55,7 +57,12 @@ object JSONConverter { return@expandJSONArray null } - return CreateUserResponse(respIdentities, respProperties, respSubscriptions) + // Backend may include `ryw_token` (and optional `ryw_delay`) under Identity Verification + // so InAppMessagesManager can gate IAM fetch on read-your-write consistency. + val rywToken = jsonObject.safeString("ryw_token") + val rywData = rywToken?.let { RywData(it, jsonObject.safeLong("ryw_delay")) } + + return CreateUserResponse(respIdentities, respProperties, respSubscriptions, rywData) } fun convertToJSON(properties: PropertiesObject): JSONObject { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt index 4b980e801a..80560d7eb0 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 @@ -2,6 +2,8 @@ package com.onesignal.user.internal.operations.impl.executors import android.os.Build import com.onesignal.common.AndroidUtils +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.DeviceUtils import com.onesignal.common.IDManager import com.onesignal.common.NetworkUtils @@ -51,6 +53,7 @@ internal class LoginUserOperationExecutor( private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, + private val _consistencyManager: IConsistencyManager, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER) @@ -235,6 +238,16 @@ internal class LoginUserOperationExecutor( backendSubscriptions.remove(backendSubscription) } + // Forward the create-user RYW data to ConsistencyManager so InAppMessagesManager + // can fetch IAMs once the user record has propagated. Gated on newCodePathsRun: + // Phase 1 users don't await RYW (legacy IAM fetch path), so storing it would be + // a no-op anyway. Backend only sends rywData under IV. + if (_identityVerificationService.newCodePathsRun) { + response.rywData?.let { rywData -> + _consistencyManager.setRywData(backendOneSignalId, IamFetchRywTokenKey.USER, rywData) + } + } + val wasPossiblyAnUpsert = identities.isNotEmpty() val followUpOperations = if (wasPossiblyAnUpsert) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt index a4af81c0f3..c210193e69 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt @@ -63,6 +63,13 @@ internal class SubscriptionModelStoreListener( companion object { fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair { + // Internal-disabled subscription (e.g. the post-logout anonymous user under IV) + // must not generate backend ops; report as disabled+unsubscribe regardless of + // optedIn/status. See [SubscriptionModel.isDisabledInternally]. + if (model.isDisabledInternally) { + return Pair(false, SubscriptionStatus.UNSUBSCRIBE) + } + val status: SubscriptionStatus val enabled: Boolean diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt index c7bde3aae8..a3d1bcfe0b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt @@ -92,6 +92,23 @@ class SubscriptionModel : Model() { setBooleanProperty(::optedIn.name, value) } + /** + * Internal-only flag (not surfaced via the public API) used to suppress backend + * subscription operations for this model. Set to `true` on logout under Identity + * Verification: the new device-scoped (anonymous) user can't authenticate without + * a JWT, so the SDK must not generate create-subscription ops for it. The + * [SubscriptionModelStoreListener] honors this flag by short-circuiting to + * `(enabled = false, status = UNSUBSCRIBE)` regardless of [optedIn] / [status]. + * + * Defaults to `false`. On the next login, [com.onesignal.user.internal.UserSwitcher] + * creates a fresh model that does not carry this flag, restoring the real state. + */ + var isDisabledInternally: Boolean + get() = getBooleanProperty(::isDisabledInternally.name) { false } + set(value) { + setBooleanProperty(::isDisabledInternally.name, value) + } + var type: SubscriptionType get() = getEnumProperty(::type.name) set(value) { From ff1834523aa18fdfe7efa98c25c07f5614a0b86a Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 1 May 2026 09:15:51 -0700 Subject: [PATCH 02/12] =?UTF-8?q?feat(iv):=20IAM=20IV=20integration=20?= =?UTF-8?q?=E2=80=94=20alias-based=20fetch=20+=20JWT=20retry=20on=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IInAppBackendService.listInAppMessagesIv (alias-based URL, JWT bearer support, throws BackendException on 401/403) alongside the existing legacy listInAppMessages so Phase 1 users keep the legacy endpoint untouched. InAppMessagesManager: - Inject JwtTokenStore + IdentityVerificationService. - Subscribe to JwtTokenStore as IJwtUpdateListener so the IAM fetch can retry once the developer supplies a fresh JWT (via updateUserJwt) after a 401. - fetchMessages outer-gates on newCodePathsRun: dispatches to fetchIvOrSaveRetry under IV, falls through to legacy otherwise. - fetchIvOrSaveRetry inner-gates on ivBehaviorActive: external_id+JWT under IV; onesignal_id+null JWT for Phase 3 (exercises new endpoint structurally, no IV behavior). - @Volatile pendingJwtRetry{ExternalId,RywData}; cleared on user-switch via existing identityModelChangeHandler.onModelReplaced. - 401 from IV fetch resets the rate-limiter so the retry isn't throttled. Cross-module visibility: drop `internal` modifier from JwtTokenStore, IdentityVerificationService, IJwtUpdateListener, IFeatureManager, FeatureFlag, FeatureActivationMode (still package-internal by convention via `.internal.` paths; Kotlin-visible cross-module). Test fixtures updated: InAppMessagesManagerTests, LogoutHelperTests, LoginUserOperationExecutorTests get the new ctor params. Co-Authored-By: Claude Sonnet 4.6 --- .../impl/IdentityVerificationService.kt | 2 +- .../core/internal/features/FeatureFlag.kt | 4 +- .../core/internal/features/FeatureManager.kt | 2 +- .../user/internal/jwt/IJwtUpdateListener.kt | 2 +- .../user/internal/jwt/JwtTokenStore.kt | 2 +- .../user/internal/LogoutHelperTests.kt | 20 ++++ .../LoginUserOperationExecutorTests.kt | 33 +++---- .../internal/InAppMessagesManager.kt | 95 ++++++++++++++++++- .../internal/backend/IInAppBackendService.kt | 23 +++++ .../backend/impl/InAppBackendService.kt | 36 ++++++- .../internal/InAppMessagesManagerTests.kt | 11 +++ 11 files changed, 203 insertions(+), 27 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 dc2eb6d3ce..8af1ae17a8 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 @@ -24,7 +24,7 @@ import com.onesignal.user.internal.jwt.JwtRequirement * Consumers (e.g. OperationRepo) wire post-HYDRATE behavior via [setOnJwtConfigHydratedHandler]; * the handler fires once per HYDRATE with `ivRequired = useIdentityVerification == REQUIRED`. */ -internal class IdentityVerificationService( +class IdentityVerificationService( private val featureManager: IFeatureManager, private val configModelStore: ConfigModelStore, ) : IStartableService, ISingletonModelStoreChangeHandler { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt index a2a5b33155..c7fb69804b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -3,7 +3,7 @@ package com.onesignal.core.internal.features /** * Controls when remote config changes for a feature are applied. */ -internal enum class FeatureActivationMode { +enum class FeatureActivationMode { /** * Apply config changes immediately during the current app run. */ @@ -20,7 +20,7 @@ internal enum class FeatureActivationMode { * * [key] values are **lowercase** strings as returned from remote config / Turbine `features` arrays. */ -internal enum class FeatureFlag( +enum class FeatureFlag( val key: String, val activationMode: FeatureActivationMode ) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index c9e43bad68..41f54c68a8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -10,7 +10,7 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.debug.internal.logging.Logging import kotlinx.serialization.json.JsonObject -internal interface IFeatureManager { +interface IFeatureManager { fun isEnabled(feature: FeatureFlag): Boolean /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt index 1e8630a336..4126029005 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt @@ -6,7 +6,7 @@ package com.onesignal.user.internal.jwt * developer-facing 401-invalidation event is delivered separately via * [com.onesignal.IUserJwtInvalidatedListener] (see [JwtTokenStore.addUserJwtInvalidatedListener]). */ -internal interface IJwtUpdateListener { +interface IJwtUpdateListener { /** Fired when a JWT was added or refreshed (`putJwt`), or when stale entries are pruned. */ fun onJwtUpdated(externalId: String) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt index f78de61355..6e53c4a045 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt @@ -22,7 +22,7 @@ import org.json.JSONObject * ([addUserJwtInvalidatedListener]). Pure pub/sub: only listeners subscribed at the time * of [invalidateJwt] receive the event. Matches iOS — no buffering for late subscribers. */ -internal class JwtTokenStore( +class JwtTokenStore( private val _prefs: IPreferencesService, ) { private val tokens: MutableMap = mutableMapOf() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt index f783408681..4de526f8f9 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt @@ -49,6 +49,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -80,6 +85,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -120,6 +130,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -153,6 +168,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) 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 0085360235..14121ca0cc 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt @@ -79,7 +79,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -124,7 +124,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -153,7 +153,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf( LoginUserOperation(appId, localOneSignalId, null, null), @@ -181,7 +181,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) // When @@ -219,7 +219,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) @@ -248,7 +248,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -284,7 +284,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -320,7 +320,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -358,7 +358,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -409,7 +409,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -514,7 +514,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -603,7 +603,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -678,7 +678,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -744,7 +744,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) // anonymous Login request val operations = listOf(LoginUserOperation(appId, localOneSignalId, null, null)) @@ -791,7 +791,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) // send PUSH then EMAIL (local IDs 1,2) — order differs from backend response @@ -856,7 +856,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, configModelStore, MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val ops = @@ -915,6 +915,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true), + mockk(relaxed = true), ) // LoginUserOperation has existingOnesignalId AND externalId — the input shape that diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index b4994a0aeb..d9863eca2e 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -18,6 +18,7 @@ import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime @@ -48,6 +49,8 @@ import com.onesignal.user.IUserManager import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.jwt.IJwtUpdateListener +import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionChangedHandler import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -76,6 +79,8 @@ internal class InAppMessagesManager( private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, + private val _identityVerificationService: IdentityVerificationService, ) : IInAppMessagesManager, IStartableService, ISubscriptionChangedHandler, @@ -83,7 +88,8 @@ internal class InAppMessagesManager( IInAppLifecycleEventHandler, ITriggerHandler, ISessionLifecycleHandler, - IApplicationLifecycleHandler { + IApplicationLifecycleHandler, + IJwtUpdateListener { private val lifecycleCallback = EventProducer() private val messageClickCallback = EventProducer() @@ -114,8 +120,17 @@ internal class InAppMessagesManager( private val redisplayedInAppMessages: MutableList = mutableListOf() private val fetchIAMMutex = Mutex() + @Volatile private var lastTimeFetchedIAMs: Long? = null + // Pending JWT-retry state under IV. When the IAM fetch returns 401/403 we save + // the externalId we were trying to fetch for + the rywData; on the next JwtTokenStore + // update for the same externalId, IAMs are re-fetched. Cleared on user-switch. + @Volatile + private var pendingJwtRetryExternalId: String? = null + @Volatile + private var pendingJwtRetryRywData: RywData? = null + // Tracks whether the first IAM fetch has completed since this cold start private var hasCompletedFirstFetch: Boolean = false @@ -127,7 +142,12 @@ internal class InAppMessagesManager( override fun onModelReplaced( model: IdentityModel, tag: String, - ) { } + ) { + // User-switch (login or logout): drop any pending JWT retry — the externalId + // we were waiting on isn't the current user anymore. + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + } override fun onModelUpdated( args: ModelChangedArgs, @@ -190,6 +210,10 @@ internal class InAppMessagesManager( _sessionService.subscribe(this) _applicationService.addApplicationLifecycleHandler(this) _identityModelStore.subscribe(identityModelChangeHandler) + // Subscribe to JwtTokenStore so a JWT refresh (developer responding to a previous 401) + // can drive a deferred IAM re-fetch under IV. Subscription is ungated; the listener + // body checks for a pending retry, which is only set on the IV fetch path. + _jwtTokenStore.subscribe(this) suspendifyOnIO { _repository.cleanCachedInAppMessages() @@ -310,7 +334,12 @@ internal class InAppMessagesManager( // lambda so that it is updated on each potential retry val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime } - val newMessages = _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider) + val newMessages = + if (_identityVerificationService.newCodePathsRun) { + fetchIvOrSaveRetry(appId, subscriptionId, rywData, sessionDurationProvider) + } else { + _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider) + } if (newMessages != null) { this.messages = newMessages as MutableList @@ -338,6 +367,66 @@ internal class InAppMessagesManager( } } + /** + * IV-aware IAM fetch. Resolves alias + JWT based on `ivBehaviorActive`, calls the + * alias-based backend endpoint, and on 401/403 saves retry state so [onJwtUpdated] + * can re-fetch once the developer supplies a refreshed JWT. + * + * Phase 3 path (newCodePathsRun=true, ivBehaviorActive=false): uses onesignal_id alias + * with no JWT — exercises the new endpoint structurally without IV-specific behavior. + */ + private suspend fun fetchIvOrSaveRetry( + appId: String, + subscriptionId: String, + rywData: RywData, + sessionDurationProvider: () -> Long, + ): List? { + val ivBehaviorActive = _identityVerificationService.ivBehaviorActive + val externalId = _identityModelStore.model.externalId + val onesignalId = _identityModelStore.model.onesignalId + + val (aliasLabel, aliasValue, jwt) = + if (ivBehaviorActive && externalId != null) { + Triple(IdentityConstants.EXTERNAL_ID, externalId, _jwtTokenStore.getJwt(externalId)) + } else { + Triple(IdentityConstants.ONESIGNAL_ID, onesignalId, null) + } + + return try { + _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + } catch (ex: BackendException) { + // 401/403 from the IV-aware fetch — save retry state so [onJwtUpdated] + // can re-fetch when the developer supplies a fresh JWT. + if (ivBehaviorActive && externalId != null) { + pendingJwtRetryExternalId = externalId + pendingJwtRetryRywData = rywData + Logging.info("InAppMessagesManager: IAM fetch returned ${ex.statusCode}, awaiting JWT refresh for $externalId") + // Reset the rate-limiter so the retry isn't throttled. + lastTimeFetchedIAMs = null + } else { + Logging.warn("InAppMessagesManager: IAM fetch returned ${ex.statusCode}: ${ex.response}") + } + null + } + } + + // IJwtUpdateListener — fires when JwtTokenStore.putJwt or invalidateJwt runs for an externalId. + override fun onJwtUpdated(externalId: String) { + val pending = pendingJwtRetryExternalId + val pendingRyw = pendingJwtRetryRywData + if (pending == null || pending != externalId || pendingRyw == null) return + // Clear before retry so concurrent fires don't re-enter. + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + Logging.info("InAppMessagesManager: JWT refreshed for $externalId, retrying IAM fetch") + suspendifyOnIO { fetchMessages(pendingRyw) } + } + + override fun onJwtInvalidated(externalId: String) { + // No-op: the developer-facing invalidation is handled by UserManager. We only care + // about the JWT-update side (onJwtUpdated) to drive the deferred retry. + } + /** * Iterate through the messages and determine if they should be shown to the user. */ diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt index 6755b6eb5a..563413f664 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt @@ -26,6 +26,29 @@ internal interface IInAppBackendService { sessionDurationProvider: () -> Long, ): List? + /** + * IV-aware list of in-app messages. Hits the alias-based endpoint + * (`apps/{appId}/users/by/{aliasLabel}/{aliasValue}/subscriptions/{subscriptionId}/iams`) + * and attaches a JWT bearer token when supplied. Used only when + * `IdentityVerificationService.newCodePathsRun` is true; Phase 1 callers continue to use + * [listInAppMessages]. + * + * Throws [BackendException] on 401/403 so the caller can save retry state and re-fetch + * once the JWT is refreshed (via `IUserJwtInvalidatedListener` → `updateUserJwt`). + * + * [rywData] may be null when this is a fallback retry after the RYW-aware path exhausted + * its retry budget — in which case the request is sent without the RYW token. + */ + suspend fun listInAppMessagesIv( + appId: String, + aliasLabel: String, + aliasValue: String, + subscriptionId: String, + rywData: RywData?, + sessionDurationProvider: () -> Long, + jwt: String?, + ): List? + /** * Retrieve the data for a specific In App Message. * diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index 9bbd738d55..7e381f3f6d 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -34,7 +34,29 @@ internal class InAppBackendService( delay(rywDelay) // Delay by the specified amount val baseUrl = "apps/$appId/subscriptions/$subscriptionId/iams" - return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider) + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt = null) + } + + override suspend fun listInAppMessagesIv( + appId: String, + aliasLabel: String, + aliasValue: String, + subscriptionId: String, + rywData: RywData?, + sessionDurationProvider: () -> Long, + jwt: String?, + ): List? { + val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" + + // Fallback path: caller exhausted RYW-aware retries and is asking for a no-RYW fetch + // (e.g. after stale RYW token). Skip the RYW-token header; let the request through. + if (rywData == null) { + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) + } + + val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS + delay(rywDelay) + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } override suspend fun getIAMData( @@ -209,6 +231,7 @@ internal class InAppBackendService( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String?, ): List? { var attempts = 0 var retryLimit: Int = 0 // retry limit is remote defined & set dynamically below @@ -220,6 +243,7 @@ internal class InAppBackendService( rywToken = rywData.rywToken, sessionDuration = sessionDurationProvider(), retryCount = retryCount, + jwt = jwt, ) val response = _httpClient.get(baseUrl, values) @@ -234,6 +258,10 @@ internal class InAppBackendService( response.retryAfterSeconds?.let { delay(it * 1_000L) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + // 401/403 — caller (InAppMessagesManager IV path) needs to surface this so it can + // save retry state and re-fetch once the JWT is refreshed. + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else if (response.statusCode in 500..599) { return null } else { @@ -244,24 +272,28 @@ internal class InAppBackendService( } while (attempts <= retryLimit) // Final attempt without the RYW token if retries fail - return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider) + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) } private suspend fun fetchInAppMessagesWithoutRywToken( url: String, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? { val response = _httpClient.get( url, OptionalHeaders( sessionDuration = sessionDurationProvider(), + jwt = jwt, ), ) if (response.isSuccess) { val jsonResponse = response.payload?.let { JSONObject(it) } return jsonResponse?.let { hydrateInAppMessages(it) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else { return null } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 418cce53cc..3f9525bf15 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -89,6 +89,15 @@ private class Mocks { coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred } + val jwtTokenStore = mockk(relaxed = true) { + every { getJwt(any()) } returns null + } + + val identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + } + val subscriptionManager = mockk(relaxed = true) { every { subscriptions } returns mockk { every { push } returns pushSubscription @@ -187,6 +196,8 @@ private class Mocks { languageContext, time, consistencyManager, + jwtTokenStore, + identityVerificationService, ) } From bbd5c6c8ac87015fd9439ce81fc4a6194838a66c Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 4 May 2026 11:46:31 -0700 Subject: [PATCH 03/12] fix(iv): reorder switchUserIv to avoid unsubscribing the OLD user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting isDisabledInternally = true on the OLD push sub model BEFORE createAndSwitchToNewUser fires a NORMAL-tagged change that propagates through SubscriptionModelStoreListener.getUpdateOperation. The listener reads the still-current OLD identity (alice / OS-A) and enqueues an UpdateSubscriptionOperation(externalId = "alice", enabled = false, status = UNSUBSCRIBE) — which then dispatches with alice's still-valid JWT and unsubscribes the just-departed user server-side. Reorder so the user-switch happens first (suppressBackendOperation = true so the new model's add doesn't fire), then set the flag on the NEW push sub with NO_PROPOGATE so the listener filters it. The new push sub reuses the old id, so configModel.pushSubscriptionId after the switch points at the new model. Also fixes the secondary issue from the same review: previously the new SubscriptionModel was created without isDisabledInternally, so the flag never landed on the post-logout anonymous user. With the new ordering, the flag is set directly on the new model. Add tests for the ordering and the Phase 3 short-circuit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../user/internal/LogoutHelperIvExtensions.kt | 26 +++++++--- .../user/internal/LogoutHelperTests.kt | 50 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt index 22eaf7eb6b..462f79982f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt @@ -1,6 +1,8 @@ package com.onesignal.user.internal +import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore /** @@ -16,10 +18,18 @@ import com.onesignal.user.internal.subscriptions.SubscriptionModelStore * Under IV-required, the new device-scoped (anonymous) user can't authenticate against * the backend without a JWT. To prevent the model-store listener from generating create- * subscription ops that would 401/permanently-block the queue: - * 1. Mark the current push subscription as internally disabled. - * 2. Switch users with [UserSwitcher.createAndSwitchToNewUser] in `suppressBackendOperation` - * mode so subscription replacement does NOT propagate to listeners that would enqueue - * backend ops. + * 1. Switch to the anonymous user with [UserSwitcher.createAndSwitchToNewUser] in + * `suppressBackendOperation` mode so subscription replacement does not propagate to + * listeners that would enqueue backend ops. + * 2. Mark the new push subscription as internally disabled with [ModelChangeTags.NO_PROPOGATE] + * so subsequent property mutations (FCM token refresh, permission change, etc.) + * short-circuit through [com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener.getSubscriptionEnabledAndStatus] + * instead of enqueueing real ops. + * + * Order matters: setting the flag on the OLD model first (with default NORMAL tag) would + * fire `getUpdateOperation` against the OLD user with their still-valid JWT — the listener + * would build an `UpdateSubscriptionOperation(externalId = OLD)` carrying `(false, UNSUBSCRIBE)`, + * dispatch it, and unsubscribe the just-departed user server-side. * * Returns `true` when IV-specific handling was applied (caller skips legacy enqueue), * or `false` when IV behavior is inactive (caller falls through to the legacy logout). @@ -32,9 +42,13 @@ internal fun switchUserIv( ): Boolean { if (!ivBehaviorActive) return false + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) configModel.pushSubscriptionId?.let { pushSubId -> - subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true } + subscriptionModelStore.get(pushSubId)?.setBooleanProperty( + SubscriptionModel::isDisabledInternally.name, + true, + ModelChangeTags.NO_PROPOGATE, + ) } - userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) return true } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt index 4de526f8f9..f3c380e590 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt @@ -6,6 +6,9 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.subscriptions.SubscriptionModel +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionType import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -192,4 +195,51 @@ class LogoutHelperTests : FunSpec({ verify(atLeast = 1) { mockUserSwitcher.createAndSwitchToNewUser() } verify(atLeast = 1) { mockOperationRepo.enqueue(any()) } } + + test("switchUserIv switches user before marking new push sub as internally disabled") { + // Given - IV active, push sub model in store + val pushSubId = "push-sub-id" + val newPushSubModel = + SubscriptionModel().apply { + id = pushSubId + type = SubscriptionType.PUSH + } + val mockSubscriptionModelStore = mockk(relaxed = true) + every { mockSubscriptionModelStore.get(pushSubId) } returns newPushSubModel + val mockUserSwitcher = mockk(relaxed = true) + val mockConfigModel = mockk() + every { mockConfigModel.pushSubscriptionId } returns pushSubId + + // When + val handled = switchUserIv(mockUserSwitcher, mockSubscriptionModelStore, mockConfigModel, ivBehaviorActive = true) + + // Then + handled shouldBe true + + // Order: user-switch must happen BEFORE the flag is set on the new push sub. + // Setting the flag on the OLD model first would fire an UpdateSubscriptionOperation + // against the OLD user with their valid JWT and unsubscribe them server-side. + verifyOrder { + mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + mockSubscriptionModelStore.get(pushSubId) + } + + // The flag is set on the model (verified via the underlying property). + newPushSubModel.isDisabledInternally shouldBe true + } + + test("switchUserIv returns false when IV behavior is inactive") { + // Given - Phase 3: new code path on, IV behavior off + val mockSubscriptionModelStore = mockk(relaxed = true) + val mockUserSwitcher = mockk(relaxed = true) + val mockConfigModel = mockk(relaxed = true) + + // When + val handled = switchUserIv(mockUserSwitcher, mockSubscriptionModelStore, mockConfigModel, ivBehaviorActive = false) + + // Then - falls through to legacy logout flow; no IV-specific calls. + handled shouldBe false + verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any()) } + verify(exactly = 0) { mockSubscriptionModelStore.get(any()) } + } }) From 6360d742164aa5d791f649470cf412d0fcacab22 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 5 May 2026 11:03:50 -0700 Subject: [PATCH 04/12] fix(iv): clear stale existingOnesignalId on anon-purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When jwt_required hydrates true and removeOperationsWithoutExternalId drops the anon ops, surviving LoginUserOperations may still carry existingOnesignalId pointing at the just-dropped anon user (the merge-anon-into-identified link set by LoginHelper). Under IV that link is permanently unresolvable — anon user creation needs a JWT-less call the backend rejects — so canStartExecute=false sticks forever and deadlocks the queue (no other op can dispatch since they all wait on the login to resolve the local onesignal_id). Clear existingOnesignalId on every surviving LoginUserOperation so the executor takes the createUser (upsert) path. Matches the same fix in reference branches #2599 and #2613. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/internal/operations/impl/OperationRepo.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 c27527a05a..5a4a0608c8 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 @@ -272,6 +272,18 @@ internal class OperationRepo( val anonymous = queue.filter { it.operation.externalId == null } anonymous.forEach { it.waiter?.wake(false) } queue.removeAll(anonymous) + // IV=ON never transfers anonymous state; clear existingOnesignalId so the + // executor takes the createUser (upsert) path. The merge-anon-into-identified + // path can't dispatch under IV — anon user creation requires a JWT-less call + // the backend rejects — and a stale local-id existingOnesignalId would leave + // canStartExecute=false forever, deadlocking the queue. + queue.forEach { item -> + val op = item.operation + if (op is LoginUserOperation && op.existingOnesignalId != null) { + Logging.debug("OperationRepo: cleared existingOnesignalId on LoginUserOperation (was ${op.existingOnesignalId})") + op.existingOnesignalId = null + } + } Logging.debug("OperationRepo: removeOperationsWithoutExternalId removed ${anonymous.size} of ${anonymous.size + queue.size} operations") anonymous.map { it.operation.id } } From d53db6e9524d6f9b5032360b68f37c54f6b9ad1d Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 5 May 2026 11:26:07 -0700 Subject: [PATCH 05/12] fix(iv): restore logout unsubscribe-on-OLD-user behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the order swap from dc9399594 — that commit reordered switchUserIv to switch users first and then set isDisabledInternally on the new push sub with NO_PROPOGATE, on the bot review's claim that firing an UpdateSubscriptionOperation against the OLD user was a bug. It is not a bug — it is the intentional behavior in reference branches #2599 and #2613: setting the flag on the CURRENT push sub with the default NORMAL tag fires an UpdateSubscriptionOperation that tells the backend "this device is unsubscribing as the user logs out", dispatched with the OLD user's still-valid JWT before the switch. Without this, logout under IV silently leaves the just-departed user's push sub subscribed server-side. The new (anonymous) user's model not carrying isDisabledInternally is fine: anon ops are filtered by hasValidJwtIfRequired (externalId=null under IV-active), so they accumulate but never dispatch. Update the test to verify the corrected order. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../user/internal/LogoutHelperIvExtensions.kt | 36 +++++++------------ .../user/internal/LogoutHelperTests.kt | 17 ++++----- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt index 462f79982f..f0ff49f1f4 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt @@ -1,8 +1,6 @@ package com.onesignal.user.internal -import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore /** @@ -15,21 +13,17 @@ import com.onesignal.user.internal.subscriptions.SubscriptionModelStore /** * Performs the IV-aware logout user-switch when [ivBehaviorActive] is true. * - * Under IV-required, the new device-scoped (anonymous) user can't authenticate against - * the backend without a JWT. To prevent the model-store listener from generating create- - * subscription ops that would 401/permanently-block the queue: - * 1. Switch to the anonymous user with [UserSwitcher.createAndSwitchToNewUser] in - * `suppressBackendOperation` mode so subscription replacement does not propagate to - * listeners that would enqueue backend ops. - * 2. Mark the new push subscription as internally disabled with [ModelChangeTags.NO_PROPOGATE] - * so subsequent property mutations (FCM token refresh, permission change, etc.) - * short-circuit through [com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener.getSubscriptionEnabledAndStatus] - * instead of enqueueing real ops. - * - * Order matters: setting the flag on the OLD model first (with default NORMAL tag) would - * fire `getUpdateOperation` against the OLD user with their still-valid JWT — the listener - * would build an `UpdateSubscriptionOperation(externalId = OLD)` carrying `(false, UNSUBSCRIBE)`, - * dispatch it, and unsubscribe the just-departed user server-side. + * Order matters and is intentional (mirrors reference branches #2599 and #2613): + * 1. Set `isDisabledInternally = true` on the CURRENT push subscription with the default + * NORMAL tag. This propagates through [com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener.getUpdateOperation], + * which reads the still-current OLD identity and enqueues an `UpdateSubscriptionOperation` + * carrying `(enabled = false, status = UNSUBSCRIBE)` — letting the backend know this device's + * push subscription is unsubscribing as the user logs out. The OLD user's JWT is still valid + * here, so the op dispatches successfully. + * 2. Switch to the new device-scoped (anonymous) user via + * [UserSwitcher.createAndSwitchToNewUser] with `suppressBackendOperation = true` so the + * subscription replacement does NOT propagate to listeners — the new anonymous user has no + * JWT and any create-subscription op for it would 401 indefinitely. * * Returns `true` when IV-specific handling was applied (caller skips legacy enqueue), * or `false` when IV behavior is inactive (caller falls through to the legacy logout). @@ -42,13 +36,9 @@ internal fun switchUserIv( ): Boolean { if (!ivBehaviorActive) return false - userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) configModel.pushSubscriptionId?.let { pushSubId -> - subscriptionModelStore.get(pushSubId)?.setBooleanProperty( - SubscriptionModel::isDisabledInternally.name, - true, - ModelChangeTags.NO_PROPOGATE, - ) + subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true } } + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) return true } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt index f3c380e590..c804dc4569 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt @@ -196,16 +196,16 @@ class LogoutHelperTests : FunSpec({ verify(atLeast = 1) { mockOperationRepo.enqueue(any()) } } - test("switchUserIv switches user before marking new push sub as internally disabled") { + test("switchUserIv marks current push sub as internally disabled before switching users") { // Given - IV active, push sub model in store val pushSubId = "push-sub-id" - val newPushSubModel = + val pushSubModel = SubscriptionModel().apply { id = pushSubId type = SubscriptionType.PUSH } val mockSubscriptionModelStore = mockk(relaxed = true) - every { mockSubscriptionModelStore.get(pushSubId) } returns newPushSubModel + every { mockSubscriptionModelStore.get(pushSubId) } returns pushSubModel val mockUserSwitcher = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.pushSubscriptionId } returns pushSubId @@ -216,16 +216,17 @@ class LogoutHelperTests : FunSpec({ // Then handled shouldBe true - // Order: user-switch must happen BEFORE the flag is set on the new push sub. - // Setting the flag on the OLD model first would fire an UpdateSubscriptionOperation - // against the OLD user with their valid JWT and unsubscribe them server-side. + // Order: flag must be set on the OLD push sub BEFORE the user-switch — setting it + // with the default NORMAL tag fires an UpdateSubscriptionOperation against the OLD + // user with their still-valid JWT, telling the backend that this device's push + // subscription is unsubscribing as the user logs out. verifyOrder { - mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) mockSubscriptionModelStore.get(pushSubId) + mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) } // The flag is set on the model (verified via the underlying property). - newPushSubModel.isDisabledInternally shouldBe true + pushSubModel.isDisabledInternally shouldBe true } test("switchUserIv returns false when IV behavior is inactive") { From a3204c1231b9dde18bc541e04dff7313be8bf3ae Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 5 May 2026 11:37:05 -0700 Subject: [PATCH 06/12] fix(iv): skip existingOnesignalId on login under IV-required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId when the prior user was anonymous (currentExternalId == null). Under IV-required that anon user was never created server-side — no JWT — so its onesignalId stays a local id forever. Carrying it as existingOnesignalId on the new LoginUserOperation makes canStartExecute=false stick, deadlocking the queue across logout→login cycles. Skip the merge-link entirely when useIdentityVerification == REQUIRED so the executor takes the createUser (upsert) path. Matches reference branches #2599 and #2613. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../onesignal/user/internal/LoginHelper.kt | 13 +++- .../user/internal/LoginHelperTests.kt | 69 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt index 9d3a8db1aa..b86f366db5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt @@ -4,6 +4,7 @@ import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation @@ -56,8 +57,18 @@ internal class LoginHelper( } val newOneSignalId = identityModelStore.model.onesignalId + // Under IV-required, the merge-anon-into-identified path can't dispatch — the + // anon user was never created server-side (no JWT) so the local-id reference + // would deadlock LoginUserOperation.canStartExecute. Skip the link entirely so + // the executor takes the createUser (upsert) path. val existingOneSignalId = - if (currentExternalId == null) currentOneSignalId else null + if (configModel.useIdentityVerification == JwtRequirement.REQUIRED) { + null + } else if (currentExternalId == null) { + currentOneSignalId + } else { + null + } return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt index c53c0f682f..11fe4834bc 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt @@ -7,6 +7,7 @@ import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.properties.PropertiesModel @@ -50,6 +51,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val loginHelper = @@ -91,6 +93,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -158,6 +161,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -202,6 +206,68 @@ class LoginHelperTests : FunSpec({ } } + test("login under IV-required does NOT carry existingOnesignalId from anonymous user") { + // Given - anonymous user, IV is REQUIRED. The anon user was never created server-side + // (no JWT), so its onesignalId would be permanently local. Carrying it as + // existingOnesignalId on the new LoginUserOperation would deadlock canStartExecute. + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = null + model.onesignalId = currentOneSignalId + } + + val newIdentityModel = + IdentityModel().apply { + externalId = newExternalId + onesignalId = newOneSignalId + } + + val mockUserSwitcher = mockk() + val mockOperationRepo = mockk() + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.REQUIRED + + val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() + every { + mockUserSwitcher.createAndSwitchToNewUser( + suppressBackendOperation = any(), + modify = capture(userSwitcherSlot), + ) + } answers { + userSwitcherSlot.captured(newIdentityModel, PropertiesModel()) + every { mockIdentityModelStore.model } returns newIdentityModel + } + + coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true + + val loginHelper = + LoginHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + ) + + // When + runBlocking { + val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "fresh-jwt") + if (context != null) loginHelper.enqueueLogin(context) + } + + // Then — under IV, the executor must take the createUser (upsert) path; no merge link. + coVerify(exactly = 1) { + mockOperationRepo.enqueueAndWait( + withArg { operation -> + operation.externalId shouldBe newExternalId + operation.existingOnesignalId shouldBe null + }, + ) + } + } + test("login logs error when operation fails") { // Given val mockIdentityModelStore = @@ -220,6 +286,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -279,6 +346,7 @@ class LoginHelperTests : FunSpec({ coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val jwtTokenStore = JwtTokenStore(MockPreferencesService()) val loginHelper = @@ -312,6 +380,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val jwtTokenStore = JwtTokenStore(MockPreferencesService()) jwtTokenStore.putJwt(currentExternalId, "old-jwt") From 7eece7c2bb70efa553c8cc6aa609e5b9b680210d Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 5 May 2026 11:48:19 -0700 Subject: [PATCH 07/12] fix(iv): suppress anonymous ops at enqueue under IV-required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shouldSuppressAnonymousOp from reference branch #2599: any non- LoginUserOperation enqueued without an externalId is dropped at the enqueue boundary when useIdentityVerification == REQUIRED, since it can't authenticate and would otherwise sit in the queue forever blocked by hasValidJwtIfRequired. LoginUserOperation is exempt — it's enqueued intentionally during logout and purged later by removeOperationsWithoutExternalId if needed. Outer-gated on _identityVerificationService.newCodePathsRun so Phase 1 customers stay byte-for-byte on the legacy enqueue path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/operations/impl/OperationRepo.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 5a4a0608c8..c1a8db7ba2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -135,6 +135,8 @@ internal class OperationRepo( operation: Operation, flush: Boolean, ) { + if (shouldSuppressAnonymousOp(operation)) return + Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)") operation.id = UUID.randomUUID().toString() @@ -147,6 +149,8 @@ internal class OperationRepo( operation: Operation, flush: Boolean, ): Boolean { + if (shouldSuppressAnonymousOp(operation)) return false + Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)") operation.id = UUID.randomUUID().toString() @@ -157,6 +161,26 @@ internal class OperationRepo( return waiter.waitForWake() } + /** + * Drop anonymous (externalId == null) operations at enqueue time when IV is required — + * they cannot be authenticated and would otherwise sit in the queue forever, blocked by + * `hasValidJwtIfRequired`. LoginUserOperation is exempt because it's enqueued + * intentionally during logout and purged later by [removeOperationsWithoutExternalId] + * if needed. Outer-gated on `newCodePathsRun` so Phase 1 customers stay byte-for-byte + * on the legacy enqueue path. + */ + private fun shouldSuppressAnonymousOp(op: Operation): Boolean { + if (!_identityVerificationService.newCodePathsRun) return false + if (op is LoginUserOperation) return false + val suppress = + _configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED && + op.externalId == null + if (suppress) { + Logging.debug("OperationRepo: suppressing anonymous op under IV-required: $op") + } + return suppress + } + /** * Only used inside this class, adds OperationQueueItem to queue * WARNING: Never set flush=true until budget rules are added, even for internal use! From f9f558e9425cbce2ded049b7b110c08a2c0918c1 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 5 May 2026 10:36:04 -0700 Subject: [PATCH 08/12] demo app: add JWT buttons and use Identity verification toggle to make requests - Cache the JWT token on login and updateUserJwt calls. When Identity Verification is enabled, include the Authorization Bearer header in the demo app's fetch user request. --- .../sdktest/data/network/OneSignalService.kt | 16 +++-- .../data/repository/OneSignalRepository.kt | 17 +++-- .../sdktest/ui/components/Dialogs.kt | 56 ++++++++++++++-- .../onesignal/sdktest/ui/main/MainScreen.kt | 26 ++++++-- .../sdktest/ui/main/MainViewModel.kt | 65 ++++++++++++++++--- .../com/onesignal/sdktest/ui/main/Sections.kt | 17 ++++- .../sdktest/util/SharedPreferenceUtil.kt | 18 +++++ 7 files changed, 183 insertions(+), 32 deletions(-) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt index 8982aefc85..09a65333df 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt @@ -167,12 +167,13 @@ object OneSignalService { * Fetch user data from OneSignal API. * Note: This endpoint does not require authentication. * - * @param onesignalId The OneSignal user ID + * @param aliasLabel The alias type to look up by (e.g. "onesignal_id" or "external_id") + * @param aliasValue The alias value * @return UserData object containing aliases, tags, emails, and SMS numbers, or null on error */ - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - if (onesignalId.isEmpty()) { - LogManager.w(TAG, "Cannot fetch user - onesignalId is empty") + suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { + if (aliasValue.isEmpty()) { + LogManager.w(TAG, "Cannot fetch user - aliasValue is empty") return@withContext null } @@ -180,9 +181,9 @@ object OneSignalService { LogManager.w(TAG, "Cannot fetch user - appId not set") return@withContext null } - + try { - val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/onesignal_id/$onesignalId" + val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/$aliasLabel/$aliasValue" LogManager.d(TAG, "Fetching user data from: $url") val connection = (URL(url).openConnection() as HttpURLConnection).apply { @@ -190,6 +191,9 @@ object OneSignalService { connectTimeout = 30000 readTimeout = 30000 setRequestProperty("Accept", "application/json") + if (jwt != null) { + setRequestProperty("Authorization", "Bearer $jwt") + } requestMethod = "GET" } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt index 70696e54fd..774b03fd97 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt @@ -19,12 +19,17 @@ class OneSignalRepository { } // User operations - suspend fun loginUser(externalUserId: String) = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging in user with externalUserId: $externalUserId") - OneSignal.login(externalUserId) + suspend fun loginUser(externalUserId: String, jwtToken: String? = null) = withContext(Dispatchers.IO) { + Log.d(TAG, "Logging in user with externalUserId: $externalUserId, jwt: ${if (jwtToken != null) "provided" else "none"}") + OneSignal.login(externalUserId, jwtToken) Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}") } + suspend fun updateUserJwt(externalUserId: String, jwtToken: String) = withContext(Dispatchers.IO) { + Log.d(TAG, "Updating JWT for externalUserId: $externalUserId") + OneSignal.updateUserJwt(externalUserId, jwtToken) + } + suspend fun logoutUser() = withContext(Dispatchers.IO) { Log.d(TAG, "Logging out user") OneSignal.logout() @@ -236,8 +241,8 @@ class OneSignalRepository { } // Fetch user data from API - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching user data for: $onesignalId") - OneSignalService.fetchUser(onesignalId) + suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { + Log.d(TAG, "Fetching user data by $aliasLabel: $aliasValue") + OneSignalService.fetchUser(aliasLabel, aliasValue, jwt) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt index 8045664984..f4dcb99ba2 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt @@ -341,18 +341,60 @@ fun MultiSelectRemoveDialog( } /** - * Dialog for login/switch user. + * Dialog for login/switch user with optional JWT token. */ @Composable fun LoginDialog( onDismiss: () -> Unit, - onConfirm: (String) -> Unit + onConfirm: (String, String?) -> Unit ) { - SingleInputDialog( - title = "Login User", - label = "External User Id", - onDismiss = onDismiss, - onConfirm = onConfirm + var externalId by remember { mutableStateOf("") } + var jwtToken by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), + properties = DialogProperties(usePlatformDefaultWidth = false), + title = { + Text("Login User", style = MaterialTheme.typography.titleMedium) + }, + text = { + Column { + OutlinedTextField( + value = externalId, + onValueChange = { externalId = it }, + label = { Text("External User Id") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = jwtToken, + onValueChange = { jwtToken = it }, + label = { Text("JWT Token (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(externalId, jwtToken.ifBlank { null }) }, + enabled = externalId.isNotBlank() + ) { + Text("Login") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) ) } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt index 82dfc01183..6d0ccfb100 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt @@ -69,6 +69,7 @@ fun MainScreen(viewModel: MainViewModel) { val consentRequired by viewModel.consentRequired.observeAsState(false) val privacyConsentGiven by viewModel.privacyConsentGiven.observeAsState(false) val externalUserId by viewModel.externalUserId.observeAsState() + val useIdentityVerification by viewModel.useIdentityVerification.observeAsState(false) val aliases by viewModel.aliases.observeAsState(emptyList()) val emails by viewModel.emails.observeAsState(emptyList()) val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList()) @@ -80,6 +81,7 @@ fun MainScreen(viewModel: MainViewModel) { // Dialog states var showLoginDialog by remember { mutableStateOf(false) } + var showUpdateJwtDialog by remember { mutableStateOf(false) } var showAddAliasDialog by remember { mutableStateOf(false) } var showAddMultipleAliasDialog by remember { mutableStateOf(false) } var showAddEmailDialog by remember { mutableStateOf(false) } @@ -159,8 +161,11 @@ fun MainScreen(viewModel: MainViewModel) { // === USER SECTION === UserSection( externalUserId = externalUserId, + useIdentityVerification = useIdentityVerification, + onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, onLoginClick = { showLoginDialog = true }, - onLogoutClick = { viewModel.logoutUser() } + onLogoutClick = { viewModel.logoutUser() }, + onUpdateJwtClick = { showUpdateJwtDialog = true } ) // === PUSH SECTION === @@ -284,13 +289,26 @@ fun MainScreen(viewModel: MainViewModel) { if (showLoginDialog) { LoginDialog( onDismiss = { showLoginDialog = false }, - onConfirm = { userId -> - viewModel.loginUser(userId) + onConfirm = { userId, jwt -> + viewModel.loginUser(userId, jwt) showLoginDialog = false } ) } - + + if (showUpdateJwtDialog) { + PairInputDialog( + title = "Update User JWT", + keyLabel = "External User Id", + valueLabel = "JWT Token", + onDismiss = { showUpdateJwtDialog = false }, + onConfirm = { externalId, token -> + viewModel.updateUserJwt(externalId, token) + showUpdateJwtDialog = false + } + ) + } + if (showAddAliasDialog) { PairInputDialog( title = "Add Alias", diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index e65af736d2..d5f08ca78d 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -5,7 +5,9 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.onesignal.IUserJwtInvalidatedListener import com.onesignal.OneSignal +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.notifications.IPermissionObserver import com.onesignal.sdktest.data.model.NotificationType import com.onesignal.sdktest.data.repository.OneSignalRepository @@ -19,7 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver { +class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver, IUserJwtInvalidatedListener { private val repository = OneSignalRepository() @@ -74,6 +76,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private val _locationShared = MutableLiveData() val locationShared: LiveData = _locationShared + // Identity Verification toggle (demo app only, controls alias used for API calls) + private val _useIdentityVerification = MutableLiveData() + val useIdentityVerification: LiveData = _useIdentityVerification + // Toast messages private val _toastMessage = MutableLiveData() val toastMessage: LiveData = _toastMessage @@ -99,6 +105,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I OneSignal.User.pushSubscription.addObserver(this) OneSignal.Notifications.addPermissionObserver(this) OneSignal.User.addObserver(this) + OneSignal.addUserJwtInvalidatedListener(this) android.util.Log.d("MainViewModel", "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") LogManager.debug("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") } @@ -127,6 +134,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _privacyConsentGiven.value = repository.getPrivacyConsent() _inAppMessagesPaused.value = repository.isInAppMessagesPaused() _locationShared.value = repository.isLocationShared() + _useIdentityVerification.value = SharedPreferenceUtil.getCachedIdentityVerification(context) val externalId = OneSignal.User.externalId _externalUserId.value = if (externalId.isEmpty()) null else externalId @@ -145,16 +153,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } fun fetchUserDataFromApi() { - val onesignalId = OneSignal.User.onesignalId - if (onesignalId.isNullOrEmpty()) { - _isLoading.value = false - return + val useIV = _useIdentityVerification.value == true + val aliasLabel: String + val aliasValue: String + + if (useIV) { + val externalId = _externalUserId.value + if (externalId.isNullOrEmpty()) { + _isLoading.value = false + return + } + aliasLabel = "external_id" + aliasValue = externalId + } else { + val onesignalId = OneSignal.User.onesignalId + if (onesignalId.isNullOrEmpty()) { + _isLoading.value = false + return + } + aliasLabel = "onesignal_id" + aliasValue = onesignalId } + val jwt = if (useIV) SharedPreferenceUtil.getCachedJwtToken(getApplication()) else null + _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { try { - val userData = repository.fetchUser(onesignalId) + val userData = repository.fetchUser(aliasLabel, aliasValue, jwt) withContext(Dispatchers.Main) { if (userData != null) { aliasesList.clear() @@ -217,12 +243,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private fun refreshTriggers() { _triggers.value = triggersList.toList() } // User operations - fun loginUser(externalUserId: String) { + fun loginUser(externalUserId: String, jwtToken: String? = null) { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { - repository.loginUser(externalUserId) + repository.loginUser(externalUserId, jwtToken) withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) _externalUserId.value = externalUserId showToast("Logged in as: $externalUserId") aliasesList.clear() @@ -240,6 +267,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + fun updateUserJwt(externalUserId: String, jwtToken: String) { + viewModelScope.launch(Dispatchers.IO) { + repository.updateUserJwt(externalUserId, jwtToken) + withContext(Dispatchers.Main) { + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) + showToast("Updated JWT for: $externalUserId") + } + } + } + fun logoutUser() { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { @@ -262,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + fun setUseIdentityVerification(enabled: Boolean) { + SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled) + _useIdentityVerification.value = enabled + showToast(if (enabled) "Identity verification enabled" else "Identity verification disabled") + } + // Consent required fun setConsentRequired(required: Boolean) { repository.setConsentRequired(required) @@ -619,8 +662,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _pushEnabled.postValue(state.current.optedIn) } + override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { + LogManager.warn("JWT invalidated for externalId: ${event.externalId}") + showToast("JWT invalidated for: ${event.externalId}") + } + override fun onCleared() { super.onCleared() OneSignal.User.pushSubscription.removeObserver(this) + OneSignal.removeUserJwtInvalidatedListener(this) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt index f672d322c1..7cc769d838 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt @@ -137,12 +137,22 @@ fun AppSection( @Composable fun UserSection( externalUserId: String?, + useIdentityVerification: Boolean, + onUseIdentityVerificationChange: (Boolean) -> Unit, onLoginClick: () -> Unit, - onLogoutClick: () -> Unit + onLogoutClick: () -> Unit, + onUpdateJwtClick: () -> Unit ) { val isLoggedIn = !externalUserId.isNullOrEmpty() SectionCard(title = "User") { + ToggleRow( + label = "Identity Verification", + description = "Use external_id for API calls", + checked = useIdentityVerification, + onCheckedChange = onUseIdentityVerificationChange + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) // Status Row( modifier = Modifier @@ -200,6 +210,11 @@ fun UserSection( onClick = onLogoutClick ) } + + OutlineButton( + text = "UPDATE USER JWT", + onClick = onUpdateJwtClick + ) } // === PUSH SECTION === diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt index f3b93dfb00..1cef40b592 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt @@ -12,6 +12,8 @@ object SharedPreferenceUtil { private const val LOCATION_SHARED_PREF = "LOCATION_SHARED_PREF" private const val IN_APP_MESSAGING_PAUSED_PREF = "IN_APP_MESSAGING_PAUSED_PREF" private const val CONSENT_REQUIRED_PREF = "CONSENT_REQUIRED_PREF" + private const val IDENTITY_VERIFICATION_PREF = "IDENTITY_VERIFICATION_PREF" + private const val JWT_TOKEN_PREF = "JWT_TOKEN_PREF" private fun getSharedPreference(context: Context): SharedPreferences { return context.getSharedPreferences(APP_SHARED_PREFS, Context.MODE_PRIVATE) @@ -69,4 +71,20 @@ object SharedPreferenceUtil { fun cacheConsentRequired(context: Context, required: Boolean) { getSharedPreference(context).edit().putBoolean(CONSENT_REQUIRED_PREF, required).apply() } + + fun getCachedIdentityVerification(context: Context): Boolean { + return getSharedPreference(context).getBoolean(IDENTITY_VERIFICATION_PREF, false) + } + + fun cacheIdentityVerification(context: Context, enabled: Boolean) { + getSharedPreference(context).edit().putBoolean(IDENTITY_VERIFICATION_PREF, enabled).apply() + } + + fun getCachedJwtToken(context: Context): String? { + return getSharedPreference(context).getString(JWT_TOKEN_PREF, null) + } + + fun cacheJwtToken(context: Context, token: String?) { + getSharedPreference(context).edit().putString(JWT_TOKEN_PREF, token).apply() + } } From b1c766ab0bc69ee79fb914a792b88210c5c8e146 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 5 May 2026 12:35:33 -0700 Subject: [PATCH 09/12] demo app: dismiss loading overlay immediately after login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the loginUser block left the loading overlay on under the expectation that onUserStateChange → fetchUserDataFromApi() would dismiss it. Under IV, login can fail (bad JWT, anon-purge, etc.) and onUserStateChange may never fire, leaving the overlay up indefinitely. Match #2599 commit 0f1ad82a9 — dismiss the loader as soon as the SDK call returns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index d5f08ca78d..46a7d4f50d 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -262,7 +262,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshTriggers() loadExistingTags() refreshPushSubscription() - // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it + _isLoading.value = false } } } From 2bef5336bfbf4c68c03b700d261aa6aee14ca32f Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 6 May 2026 16:31:30 -0700 Subject: [PATCH 10/12] fix to after rebase nit: fix import order in LoginUserOperationExecutor --- .../impl/executors/LoginUserOperationExecutor.kt | 4 ++-- .../inAppMessages/internal/InAppMessagesManager.kt | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) 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 80560d7eb0..0d9e975f0f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt @@ -2,14 +2,14 @@ package com.onesignal.user.internal.operations.impl.executors import android.os.Build import com.onesignal.common.AndroidUtils -import com.onesignal.common.consistency.enums.IamFetchRywTokenKey -import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.DeviceUtils import com.onesignal.common.IDManager import com.onesignal.common.NetworkUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.RootToolsInternalMethods import com.onesignal.common.TimeUtils +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.core.internal.application.IApplicationService 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 d9863eca2e..17defab873 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 @@ -120,6 +120,7 @@ internal class InAppMessagesManager( private val redisplayedInAppMessages: MutableList = mutableListOf() private val fetchIAMMutex = Mutex() + @Volatile private var lastTimeFetchedIAMs: Long? = null @@ -128,6 +129,7 @@ internal class InAppMessagesManager( // update for the same externalId, IAMs are re-fetched. Cleared on user-switch. @Volatile private var pendingJwtRetryExternalId: String? = null + @Volatile private var pendingJwtRetryRywData: RywData? = null @@ -213,7 +215,7 @@ internal class InAppMessagesManager( // Subscribe to JwtTokenStore so a JWT refresh (developer responding to a previous 401) // can drive a deferred IAM re-fetch under IV. Subscription is ungated; the listener // body checks for a pending retry, which is only set on the IV fetch path. - _jwtTokenStore.subscribe(this) + _jwtTokenStore.addInternalUpdateListener(this) suspendifyOnIO { _repository.cleanCachedInAppMessages() @@ -422,11 +424,6 @@ internal class InAppMessagesManager( suspendifyOnIO { fetchMessages(pendingRyw) } } - override fun onJwtInvalidated(externalId: String) { - // No-op: the developer-facing invalidation is handled by UserManager. We only care - // about the JWT-update side (onJwtUpdated) to drive the deferred retry. - } - /** * Iterate through the messages and determine if they should be shown to the user. */ From ea2aaad2bd1a1ea8e2a3a5eb68303cf82288281f Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 6 May 2026 19:12:01 -0700 Subject: [PATCH 11/12] fix(iv): close retry race + extract IdentityVerificationService lazy address comments --- .../java/com/onesignal/internal/OneSignalImp.kt | 3 ++- .../inAppMessages/internal/InAppMessagesManager.kt | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index a2f6e24c7d..9694d617f2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -146,6 +146,7 @@ internal class OneSignalImp( private val subscriptionModelStore: SubscriptionModelStore by lazy { services.getService() } private val preferencesService: IPreferencesService by lazy { services.getService() } private val jwtTokenStore: JwtTokenStore by lazy { services.getService() } + private val identityVerificationService: IdentityVerificationService by lazy { services.getService() } private val listOfModules = listOf( "com.onesignal.notifications.NotificationsModule", @@ -236,7 +237,7 @@ internal class OneSignalImp( operationRepo = operationRepo, configModel = configModel, subscriptionModelStore = subscriptionModelStore, - identityVerificationService = services.getService(), + identityVerificationService = identityVerificationService, lock = loginLogoutLock, ) } 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 17defab873..cc42e2371c 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -394,14 +394,18 @@ internal class InAppMessagesManager( Triple(IdentityConstants.ONESIGNAL_ID, onesignalId, null) } + // Set pending state before the call so a mid-flight onJwtUpdated finds it; clear on success. + if (ivBehaviorActive && externalId != null) { + pendingJwtRetryExternalId = externalId + pendingJwtRetryRywData = rywData + } return try { - _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + val result = _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + result } catch (ex: BackendException) { - // 401/403 from the IV-aware fetch — save retry state so [onJwtUpdated] - // can re-fetch when the developer supplies a fresh JWT. if (ivBehaviorActive && externalId != null) { - pendingJwtRetryExternalId = externalId - pendingJwtRetryRywData = rywData Logging.info("InAppMessagesManager: IAM fetch returned ${ex.statusCode}, awaiting JWT refresh for $externalId") // Reset the rate-limiter so the retry isn't throttled. lastTimeFetchedIAMs = null From 0062df7ac6507b766c8f6b620fdab4dacc923e1e Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 6 May 2026 19:33:06 -0700 Subject: [PATCH 12/12] chore: refresh detekt baseline --- OneSignalSDK/detekt/detekt-baseline-core.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 62ec28c7c2..d80e7a8aac 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -52,6 +52,7 @@ ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _application: IApplicationService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _consistencyManager: IConsistencyManager ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _deviceService: IDeviceService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityOperationExecutor: IdentityOperationExecutor @@ -155,7 +156,6 @@ ConstructorParameterNaming:UserBackendService.kt$UserBackendService$private val _httpClient: IHttpClient ConstructorParameterNaming:UserManager.kt$UserManager$private val _customEventController: ICustomEventController ConstructorParameterNaming:UserManager.kt$UserManager$private val _identityModelStore: IdentityModelStore - ConstructorParameterNaming:UserManager.kt$UserManager$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:UserManager.kt$UserManager$private val _languageContext: ILanguageContext ConstructorParameterNaming:UserManager.kt$UserManager$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:UserManager.kt$UserManager$private val _subscriptionManager: ISubscriptionManager @@ -174,7 +174,6 @@ ForbiddenComment:HttpClient.kt$HttpClient$// TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT? ForbiddenComment:IPreferencesService.kt$PreferenceOneSignalKeys$* (String) The serialized IAMs TODO: This isn't currently used, determine if actually needed for cold start IAM fetch delay ForbiddenComment:IUserBackendService.kt$IUserBackendService$// TODO: Change to send only the push subscription, optimally - ForbiddenComment:LogoutHelper.kt$LogoutHelper$// TODO: remove JWT Token for all future requests. 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 @@ -213,7 +212,7 @@ LongParameterList:IDatabase.kt$IDatabase$( table: String, columns: Array<String>? = null, whereClause: String? = null, whereArgs: Array<String>? = null, groupBy: String? = null, having: String? = null, orderBy: String? = null, limit: String? = null, action: (ICursor) -> Unit, ) LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, ) LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, jwt: String? = null, ) - LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, ) + LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, private val _consistencyManager: IConsistencyManager, ) LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, ) LongParameterList:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, ) LongParameterList:SubscriptionObject.kt$SubscriptionObject$( val id: String? = null, val type: SubscriptionObjectType? = null, val token: String? = null, val enabled: Boolean? = null, val notificationTypes: Int? = null, val sdk: String? = null, val deviceModel: String? = null, val deviceOS: String? = null, val rooted: Boolean? = null, val netType: Int? = null, val carrier: String? = null, val appVersion: String? = null, ) @@ -311,6 +310,7 @@ ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean ReturnCount:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private suspend fun loginUser(loginUserOp: LoginUserFromSubscriptionOperation): ExecutionResponse ReturnCount:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + ReturnCount:LogoutHelper.kt$LogoutHelper$internal fun switchUser(): LogoutEnqueueContext? ReturnCount:Model.kt$Model$protected fun getOptBigDecimalProperty( name: String, create: (() -> BigDecimal?)? = null, ): BigDecimal? ReturnCount:Model.kt$Model$protected fun getOptDoubleProperty( name: String, create: (() -> Double?)? = null, ): Double? ReturnCount:Model.kt$Model$protected fun getOptFloatProperty( name: String, create: (() -> Float?)? = null, ): Float? @@ -320,6 +320,7 @@ ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean + ReturnCount:OperationRepo.kt$OperationRepo$private fun shouldSuppressAnonymousOp(op: Operation): Boolean ReturnCount:OperationRepoIvExtensions.kt$internal fun OperationRepo.handleFailUnauthorized( startingOp: OperationRepo.OperationQueueItem, ops: List<OperationRepo.OperationQueueItem>, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, ): Boolean ReturnCount:OperationRepoIvExtensions.kt$internal fun OperationRepo.hasValidJwtIfRequired( jwtTokenStore: JwtTokenStore, op: com.onesignal.core.internal.operations.Operation, ivBehaviorActive: Boolean, ): Boolean ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? @@ -413,7 +414,7 @@ TooManyFunctions:OutcomeEventsController.kt$OutcomeEventsController : IOutcomeEventsControllerIStartableServiceISessionLifecycleHandler TooManyFunctions:PreferencesService.kt$PreferencesService : IPreferencesServiceIStartableService TooManyFunctions:SubscriptionManager.kt$SubscriptionManager : ISubscriptionManagerIModelStoreChangeHandlerISessionLifecycleHandler - TooManyFunctions:UserManager.kt$UserManager : IUserManagerISingletonModelStoreChangeHandlerIJwtUpdateListener + TooManyFunctions:UserManager.kt$UserManager : IUserManagerISingletonModelStoreChangeHandler UndocumentedPublicClass:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$Callback UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils$SchemaType @@ -458,7 +459,6 @@ UndocumentedPublicClass:JSONConverter.kt$JSONConverter UndocumentedPublicClass:JSONUtils.kt$JSONUtils UndocumentedPublicClass:Logging.kt$Logging - UndocumentedPublicClass:LogoutHelper.kt$LogoutHelper UndocumentedPublicClass:MigrationRecovery.kt$MigrationRecovery : IMigrationRecovery UndocumentedPublicClass:NetworkUtils.kt$NetworkUtils UndocumentedPublicClass:NetworkUtils.kt$NetworkUtils$ResponseStatusType