diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml
index 62ec28c7c..d80e7a8aa 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
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 dc2eb6d3c..8af1ae17a 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 a2a5b3315..c7fb69804 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 c9e43bad6..41f54c68a 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/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt
index c27527a05..c1a8db7ba 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!
@@ -272,6 +296,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 }
}
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 b3043623e..9694d617f 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
@@ -145,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",
@@ -234,6 +236,8 @@ internal class OneSignalImp(
userSwitcher = userSwitcher,
operationRepo = operationRepo,
configModel = configModel,
+ subscriptionModelStore = subscriptionModelStore,
+ identityVerificationService = identityVerificationService,
lock = loginLogoutLock,
)
}
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 9d3a8db1a..b86f366db 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/main/java/com/onesignal/user/internal/LogoutHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt
index e0017d5b3..1f5853336 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 000000000..f0ff49f1f
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt
@@ -0,0 +1,44 @@
+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.
+ *
+ * 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).
+ */
+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 b849fc4c4..7cc509d45 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 ff3745b32..5c971df6e 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/jwt/IJwtUpdateListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt
index 1e8630a33..412602900 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 f78de6135..6e53c4a04 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/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 4b980e801..0d9e975f0 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt
@@ -8,6 +8,8 @@ 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
@@ -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 a4af81c0f..c210193e6 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 c7bde3aae..a3d1bcfe0 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) {
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 c53c0f682..11fe4834b 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")
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 f78340868..c804dc456 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
@@ -49,6 +52,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 +88,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 +133,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 +171,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,
)
@@ -172,4 +195,52 @@ class LogoutHelperTests : FunSpec({
verify(atLeast = 1) { mockUserSwitcher.createAndSwitchToNewUser() }
verify(atLeast = 1) { mockOperationRepo.enqueue(any()) }
}
+
+ 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 pushSubModel =
+ SubscriptionModel().apply {
+ id = pushSubId
+ type = SubscriptionType.PUSH
+ }
+ val mockSubscriptionModelStore = mockk(relaxed = true)
+ every { mockSubscriptionModelStore.get(pushSubId) } returns pushSubModel
+ 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: 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 {
+ mockSubscriptionModelStore.get(pushSubId)
+ mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true)
+ }
+
+ // The flag is set on the model (verified via the underlying property).
+ pushSubModel.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()) }
+ }
})
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 008536023..14121ca0c 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 b4994a0ae..cc42e2371 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,19 @@ 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 +144,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 +212,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.addInternalUpdateListener(this)
suspendifyOnIO {
_repository.cleanCachedInAppMessages()
@@ -310,7 +336,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 +369,65 @@ 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)
+ }
+
+ // 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 {
+ val result = _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt)
+ pendingJwtRetryExternalId = null
+ pendingJwtRetryRywData = null
+ result
+ } catch (ex: BackendException) {
+ if (ivBehaviorActive && externalId != null) {
+ 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) }
+ }
+
/**
* 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 6755b6eb5..563413f66 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 9bbd738d5..7e381f3f6 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 418cce53c..3f9525bf1 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,
)
}
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 8982aefc8..09a65333d 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 70696e54f..774b03fd9 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 804566498..f4dcb99ba 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 82dfc0118..6d0ccfb10 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 e65af736d..46a7d4f50 100644
--- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt
+++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt
@@ -5,7 +5,9 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
+import com.onesignal.IUserJwtInvalidatedListener
import com.onesignal.OneSignal
+import com.onesignal.UserJwtInvalidatedEvent
import com.onesignal.notifications.IPermissionObserver
import com.onesignal.sdktest.data.model.NotificationType
import com.onesignal.sdktest.data.repository.OneSignalRepository
@@ -19,7 +21,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver {
+class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver, IUserJwtInvalidatedListener {
private val repository = OneSignalRepository()
@@ -74,6 +76,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
private val _locationShared = MutableLiveData()
val locationShared: LiveData = _locationShared
+ // Identity Verification toggle (demo app only, controls alias used for API calls)
+ private val _useIdentityVerification = MutableLiveData()
+ val useIdentityVerification: LiveData = _useIdentityVerification
+
// Toast messages
private val _toastMessage = MutableLiveData()
val toastMessage: LiveData = _toastMessage
@@ -99,6 +105,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
OneSignal.User.pushSubscription.addObserver(this)
OneSignal.Notifications.addPermissionObserver(this)
OneSignal.User.addObserver(this)
+ OneSignal.addUserJwtInvalidatedListener(this)
android.util.Log.d("MainViewModel", "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}")
LogManager.debug("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}")
}
@@ -127,6 +134,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
_privacyConsentGiven.value = repository.getPrivacyConsent()
_inAppMessagesPaused.value = repository.isInAppMessagesPaused()
_locationShared.value = repository.isLocationShared()
+ _useIdentityVerification.value = SharedPreferenceUtil.getCachedIdentityVerification(context)
val externalId = OneSignal.User.externalId
_externalUserId.value = if (externalId.isEmpty()) null else externalId
@@ -145,16 +153,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
}
fun fetchUserDataFromApi() {
- val onesignalId = OneSignal.User.onesignalId
- if (onesignalId.isNullOrEmpty()) {
- _isLoading.value = false
- return
+ val useIV = _useIdentityVerification.value == true
+ val aliasLabel: String
+ val aliasValue: String
+
+ if (useIV) {
+ val externalId = _externalUserId.value
+ if (externalId.isNullOrEmpty()) {
+ _isLoading.value = false
+ return
+ }
+ aliasLabel = "external_id"
+ aliasValue = externalId
+ } else {
+ val onesignalId = OneSignal.User.onesignalId
+ if (onesignalId.isNullOrEmpty()) {
+ _isLoading.value = false
+ return
+ }
+ aliasLabel = "onesignal_id"
+ aliasValue = onesignalId
}
+ val jwt = if (useIV) SharedPreferenceUtil.getCachedJwtToken(getApplication()) else null
+
_isLoading.value = true
viewModelScope.launch(Dispatchers.IO) {
try {
- val userData = repository.fetchUser(onesignalId)
+ val userData = repository.fetchUser(aliasLabel, aliasValue, jwt)
withContext(Dispatchers.Main) {
if (userData != null) {
aliasesList.clear()
@@ -217,12 +243,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
private fun refreshTriggers() { _triggers.value = triggersList.toList() }
// User operations
- fun loginUser(externalUserId: String) {
+ fun loginUser(externalUserId: String, jwtToken: String? = null) {
_isLoading.value = true
viewModelScope.launch(Dispatchers.IO) {
- repository.loginUser(externalUserId)
+ repository.loginUser(externalUserId, jwtToken)
withContext(Dispatchers.Main) {
SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId)
+ SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken)
_externalUserId.value = externalUserId
showToast("Logged in as: $externalUserId")
aliasesList.clear()
@@ -235,7 +262,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
refreshTriggers()
loadExistingTags()
refreshPushSubscription()
- // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it
+ _isLoading.value = false
+ }
+ }
+ }
+
+ fun updateUserJwt(externalUserId: String, jwtToken: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.updateUserJwt(externalUserId, jwtToken)
+ withContext(Dispatchers.Main) {
+ SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken)
+ showToast("Updated JWT for: $externalUserId")
}
}
}
@@ -262,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
}
}
+ fun setUseIdentityVerification(enabled: Boolean) {
+ SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled)
+ _useIdentityVerification.value = enabled
+ showToast(if (enabled) "Identity verification enabled" else "Identity verification disabled")
+ }
+
// Consent required
fun setConsentRequired(required: Boolean) {
repository.setConsentRequired(required)
@@ -619,8 +662,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I
_pushEnabled.postValue(state.current.optedIn)
}
+ override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) {
+ LogManager.warn("JWT invalidated for externalId: ${event.externalId}")
+ showToast("JWT invalidated for: ${event.externalId}")
+ }
+
override fun onCleared() {
super.onCleared()
OneSignal.User.pushSubscription.removeObserver(this)
+ OneSignal.removeUserJwtInvalidatedListener(this)
}
}
diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt
index f672d322c..7cc769d83 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 f3b93dfb0..1cef40b59 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()
+ }
}