From de04a6a475434392c92745d7deea016b7bc03177 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 17 Mar 2026 18:36:25 -0700 Subject: [PATCH 01/13] demo app: update buttons for JWT testing --- .../sdktest/model/MainActivityViewModel.java | 24 ++++++++++++------- .../com/onesignal/sdktest/util/Dialog.java | 8 +++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java index dc84e28a9e..b170f370b7 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java @@ -407,11 +407,17 @@ private void setupAppLayout() { revokeConsentButton.setOnClickListener(v -> togglePrivacyConsent(false)); loginUserButton.setOnClickListener(v -> { - dialog.createUpdateAlertDialog("", Dialog.DialogAction.LOGIN, ProfileUtil.FieldType.EXTERNAL_USER_ID, new UpdateAlertDialogCallback() { + dialog.createAddPairAlertDialog("Login User", "External ID", "JWT Token (optional)", ProfileUtil.FieldType.EXTERNAL_USER_ID, new AddPairAlertDialogCallback() { @Override - public void onSuccess(String update) { - if (update != null && !update.isEmpty()) { - OneSignal.login(update); + public void onSuccess(Pair pair) { + String externalId = pair.first; + String jwt = pair.second != null ? pair.second.toString().trim() : ""; + if (externalId != null && !externalId.isEmpty()) { + if (!jwt.isEmpty()) { + OneSignal.login(externalId, jwt); + } else { + OneSignal.login(externalId); + } refreshState(); } } @@ -444,11 +450,13 @@ private void setupJWTLayout() { OneSignal.updateUserJwt(OneSignal.getUser().getExternalId(), ""); }); updateJwtButton.setOnClickListener(v -> { - dialog.createUpdateAlertDialog("", Dialog.DialogAction.UPDATE, ProfileUtil.FieldType.JWT, new UpdateAlertDialogCallback() { + dialog.createAddPairAlertDialog("Update JWT", "External ID", "JWT Token", ProfileUtil.FieldType.EXTERNAL_USER_ID, new AddPairAlertDialogCallback() { @Override - public void onSuccess(String update) { - if (update != null && !update.isEmpty()) { - OneSignal.updateUserJwt(OneSignal.getUser().getExternalId(), update); + public void onSuccess(Pair pair) { + String externalId = pair.first; + String jwt = pair.second != null ? pair.second.toString().trim() : ""; + if (externalId != null && !externalId.isEmpty() && !jwt.isEmpty()) { + OneSignal.updateUserJwt(externalId, jwt); refreshState(); } } diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java index 797d06372e..6c25bf857d 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java @@ -133,6 +133,10 @@ private void toggleUpdateAlertDialogAttributes(boolean disableAttributes) { * Click OK to verify and update the field being updated */ public void createAddPairAlertDialog(String content, final ProfileUtil.FieldType field, final AddPairAlertDialogCallback callback) { + createAddPairAlertDialog(content, "Key", "Value", field, callback); + } + + public void createAddPairAlertDialog(String content, String keyHint, String valueHint, final ProfileUtil.FieldType field, final AddPairAlertDialogCallback callback) { final View addPairAlertDialogView = layoutInflater.inflate(R.layout.add_pair_alert_dialog_layout, null, false); final TextView addPairAlertDialogTitleTextView = addPairAlertDialogView.findViewById(R.id.add_pair_alert_dialog_title_text_view); @@ -142,8 +146,8 @@ public void createAddPairAlertDialog(String content, final ProfileUtil.FieldType final EditText addPairAlertDialogValueEditText = addPairAlertDialogView.findViewById(R.id.add_pair_alert_dialog_value_edit_text); final ProgressBar addPairAlertDialogProgressBar = addPairAlertDialogView.findViewById(R.id.add_pair_alert_dialog_progress_bar); - addPairAlertDialogKeyTextInputLayout.setHint("Key"); - addPairAlertDialogValueTextInputLayout.setHint("Value"); + addPairAlertDialogKeyTextInputLayout.setHint(keyHint); + addPairAlertDialogValueTextInputLayout.setHint(valueHint); addPairAlertDialogTitleTextView.setText(content); font.applyFont(addPairAlertDialogTitleTextView, font.saralaBold); From 662a21160452afd7d59053e09a7d74538c93c811 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 09:30:22 -0700 Subject: [PATCH 02/13] Add per-operation JWT properties to Operation base class Add operationJwt, operationExternalId, and requiresJwt to Operation for multi-user JWT management. Override requiresJwt=false on UpdateSubscriptionOperation since its backend endpoint has no JWT param. Made-with: Cursor --- .../core/internal/operations/Operation.kt | 25 +++++++++++++++++++ .../operations/UpdateSubscriptionOperation.kt | 1 + 2 files changed, 26 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt index 76f51994ab..6aec9078c5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt @@ -1,6 +1,7 @@ package com.onesignal.core.internal.operations import com.onesignal.common.modeling.Model +import com.onesignal.common.modeling.ModelChangeTags /** * An [Operation] can be enqueued and executed on the [IOperationRepo]. Each concrete-class @@ -49,6 +50,30 @@ abstract class Operation(name: String) : Model() { */ abstract val canStartExecute: Boolean + /** + * The JWT token stamped on this operation at enqueue time. Used by executors to authenticate + * backend requests for the user who created this operation, even if the current user has + * changed since then. + */ + var operationJwt: String? + get() = getOptStringProperty("_jwt") + set(value) { setOptStringProperty("_jwt", value, ModelChangeTags.NO_PROPOGATE, true) } + + /** + * The external ID of the user who created this operation, stamped at enqueue time. + * Used to associate operations with specific users for multi-user JWT management. + */ + var operationExternalId: String? + get() = getOptStringProperty("_externalId") + set(value) { setOptStringProperty("_externalId", value, ModelChangeTags.NO_PROPOGATE, true) } + + /** + * Whether this operation requires JWT authentication when identity verification is enabled. + * Most operations require JWT; override to return false for operations that don't + * (e.g. UpdateSubscriptionOperation). + */ + open val requiresJwt: Boolean get() = true + /** * Called when an operation has resolved a local ID to a backend ID (i.e. successfully * created a backend resource). Any IDs within the operation that could be local IDs should diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt index a051001f83..18495e090f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt @@ -97,6 +97,7 @@ class UpdateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.UP override val groupComparisonType: GroupComparisonType = GroupComparisonType.ALTER override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId) override val applyToRecordId: String get() = subscriptionId + override val requiresJwt: Boolean get() = false constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus, jwt: String? = null) : this() { this.appId = appId From ecafbd1929e85f6e8f22bd7ec7367762b8038bd2 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 09:33:38 -0700 Subject: [PATCH 03/13] Implement multi-user JWT queue logic in OperationRepo - Stamp operationJwt/operationExternalId on operations at enqueue time - Rewrite getNextOps: gate on isInitializedWithRemote, discard anonymous ops when IV is on, skip ops with null JWT (instead of blocking all) - FAIL_UNAUTHORIZED: null JWT per-op, add unauthorizedRetries counter with max 3, fire jwtInvalidatedCallback with correct externalId - Stamp JWT on follow-up operations from executors - Add updateJwtForExternalId to update JWT on queued ops and wake queue - Subscribe to ConfigModelStore to wake queue when remote params arrive - Add listener management for IUserJwtInvalidatedListener on IOperationRepo Made-with: Cursor --- .../internal/operations/IOperationRepo.kt | 14 +++ .../internal/operations/impl/OperationRepo.kt | 109 ++++++++++++++++-- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt index d2dceea5c3..03364e8771 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt @@ -1,5 +1,6 @@ package com.onesignal.core.internal.operations +import com.onesignal.IUserJwtInvalidatedListener import kotlin.reflect.KClass /** @@ -42,6 +43,19 @@ interface IOperationRepo { suspend fun awaitInitialized() fun forceExecuteOperations() + + /** + * Update the JWT on all queued operations for the given external ID. + * Resets the unauthorized retry counter and wakes the queue processor. + */ + fun updateJwtForExternalId( + externalId: String, + jwt: String, + ) + + fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) + + fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) } // Extension function so the syntax containsInstanceOf() can be used over 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 c256eb8ad4..be10f785ba 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 @@ -1,7 +1,13 @@ package com.onesignal.core.internal.operations.impl +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent +import com.onesignal.common.events.EventProducer +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler +import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.common.threading.OSPrimaryCoroutineScope import com.onesignal.common.threading.WaiterWithValue +import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.GroupComparisonType @@ -31,15 +37,20 @@ internal class OperationRepo( private val _identityModelStore: IdentityModelStore, private val _time: ITime, private val _newRecordState: NewRecordsState, -) : IOperationRepo, IStartableService { +) : IOperationRepo, IStartableService, ISingletonModelStoreChangeHandler { + companion object { + private const val FAIL_UNAUTHORIZED_MAX_RETRIES = 3 + } + internal class OperationQueueItem( val operation: Operation, val waiter: WaiterWithValue? = null, val bucket: Int, var retries: Int = 0, + var unauthorizedRetries: Int = 0, ) { override fun toString(): String { - return "bucket:$bucket, retries:$retries, operation:$operation\n" + return "bucket:$bucket, retries:$retries, unauthorizedRetries:$unauthorizedRetries, operation:$operation\n" } } @@ -55,6 +66,7 @@ internal class OperationRepo( private var paused = false private var coroutineScope = CoroutineScope(newSingleThreadContext(name = "OpRepo")) private val initialized = CompletableDeferred() + val jwtInvalidatedCallback = EventProducer() override suspend fun awaitInitialized() { initialized.await() @@ -97,6 +109,7 @@ internal class OperationRepo( } override fun start() { + _configModelStore.subscribe(this) coroutineScope.launch { // load saved operations first then start processing the queue to ensure correct operation order loadSavedOperations() @@ -104,6 +117,20 @@ internal class OperationRepo( } } + override fun onModelReplaced( + model: ConfigModel, + tag: String, + ) { + if (model.isInitializedWithRemote) { + waiter.wake(LoopWaiterMessage(false)) + } + } + + override fun onModelUpdated( + args: ModelChangedArgs, + tag: String, + ) { } + /** * Enqueuing will be performed in a designate coroutine and may not be added instantly. * This is to prevent direct enqueuing from the main thread that may cause a deadlock if loading @@ -147,6 +174,11 @@ internal class OperationRepo( addToStore: Boolean, index: Int? = null, ) { + if (addToStore) { + queueItem.operation.operationJwt = _identityModelStore.model.jwtToken + queueItem.operation.operationExternalId = _identityModelStore.model.externalId + } + synchronized(queue) { val hasExisting = queue.any { it.operation.id == queueItem.operation.id } if (hasExisting) { @@ -262,12 +294,26 @@ internal class OperationRepo( ops.forEach { it.waiter?.wake(true) } } ExecutionResult.FAIL_UNAUTHORIZED -> { - Logging.error("Operation execution failed with invalid jwt") - _identityModelStore.invalidateJwt() + val externalId = ops.first().operation.operationExternalId + Logging.error("Operation execution failed with invalid jwt for externalId: $externalId") - // add back all operations to the front of the queue to be re-executed. synchronized(queue) { - ops.reversed().forEach { queue.add(0, it) } + ops.reversed().forEach { + it.unauthorizedRetries++ + if (it.unauthorizedRetries > FAIL_UNAUTHORIZED_MAX_RETRIES) { + Logging.error("Dropping operation after $FAIL_UNAUTHORIZED_MAX_RETRIES unauthorized retries: ${it.operation}") + _operationModelStore.remove(it.operation.id) + it.waiter?.wake(false) + } else { + it.operation.operationJwt = null + _operationModelStore.persist() + queue.add(0, it) + } + } + } + + if (externalId != null) { + jwtInvalidatedCallback.fire { it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) } } } ExecutionResult.FAIL_NORETRY, @@ -313,9 +359,12 @@ internal class OperationRepo( // if there are operations provided on the result, we need to enqueue them at the // beginning of the queue. if (response.operations != null) { + val startingOp = ops.first().operation synchronized(queue) { for (op in response.operations.reversed()) { op.id = UUID.randomUUID().toString() + op.operationJwt = startingOp.operationJwt + op.operationExternalId = startingOp.operationExternalId val queueItem = OperationQueueItem(op, bucket = 0) queue.add(0, queueItem) _operationModelStore.add(0, queueItem.operation) @@ -374,18 +423,32 @@ internal class OperationRepo( internal fun getNextOps(bucketFilter: Int): List? { return synchronized(queue) { - // Ensure the operation does not have empty JWT if identity verification is on - if (_configModelStore.model.useIdentityVerification && - _identityModelStore.model.jwtToken == null - ) { + Logging.debug("getNextOps queue:\n$queue") + + if (!_configModelStore.model.isInitializedWithRemote) { + Logging.debug("getNextOps: waiting for remote params") return null } + val useIV = _configModelStore.model.useIdentityVerification + if (useIV) { + val toDiscard = queue.filter { + it.operation.operationExternalId == null && it.operation.requiresJwt + } + for (item in toDiscard) { + Logging.debug("getNextOps: discarding anonymous op: ${item.operation}") + queue.remove(item) + _operationModelStore.remove(item.operation.id) + item.waiter?.wake(false) + } + } + val startingOp = queue.firstOrNull { it.operation.canStartExecute && _newRecordState.canAccess(it.operation.applyToRecordId) && - it.bucket <= bucketFilter + it.bucket <= bucketFilter && + (!useIV || !it.operation.requiresJwt || it.operation.operationJwt != null) } if (startingOp != null) { @@ -443,6 +506,30 @@ internal class OperationRepo( return ops } + override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedCallback.subscribe(listener) + } + + override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedCallback.unsubscribe(listener) + } + + override fun updateJwtForExternalId( + externalId: String, + jwt: String, + ) { + synchronized(queue) { + for (item in queue) { + if (item.operation.operationExternalId == externalId) { + item.operation.operationJwt = jwt + item.unauthorizedRetries = 0 + } + } + } + _operationModelStore.persist() + waiter.wake(LoopWaiterMessage(false)) + } + /** * Load saved operations from preference service and add them into the queue * NOTE: Sometimes the loading might take longer than expected due to I/O reads from disk, From efb3fa3670f1cd6ae8797e9b144f6035fa0176d0 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 09:34:23 -0700 Subject: [PATCH 04/13] Update OneSignalImp to use per-operation JWT update and listener - updateUserJwt now calls operationRepo.updateJwtForExternalId to update JWT on all queued operations for the given externalId - Delegate JWT invalidated listener add/remove to OperationRepo instead of UserManager Made-with: Cursor --- .../src/main/java/com/onesignal/internal/OneSignalImp.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 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 b8947a9667..92d7b34076 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 @@ -451,21 +451,20 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { for (model in identityModelStore!!.store.list()) { if (externalId == model.externalId) { identityModelStore!!.model.jwtToken = token - operationRepo!!.forceExecuteOperations() Logging.log(LogLevel.DEBUG, "JWT $token is updated for externalId $externalId") - return + break } } - Logging.log(LogLevel.DEBUG, "No identity found for externalId $externalId") + operationRepo!!.updateJwtForExternalId(externalId, token) } override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - user.addUserJwtInvalidatedListener(listener) + operationRepo!!.addUserJwtInvalidatedListener(listener) } override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - user.removeUserJwtInvalidatedListener(listener) + operationRepo!!.removeUserJwtInvalidatedListener(listener) } private fun createAndSwitchToNewUser( From b3d00a302f87482296a97144faf8942146fa4c27 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 09:36:39 -0700 Subject: [PATCH 05/13] Change all executors to read JWT from operation instead of IdentityModelStore Replace _identityModelStore.model.jwtToken with operation.operationJwt in all 5 executors so each operation uses the JWT stamped at enqueue time rather than the current user's JWT. Made-with: Cursor --- .../operations/impl/executors/IdentityOperationExecutor.kt | 4 ++-- .../operations/impl/executors/LoginUserOperationExecutor.kt | 2 +- .../impl/executors/RefreshUserOperationExecutor.kt | 4 ++-- .../impl/executors/SubscriptionOperationExecutor.kt | 6 +++--- .../impl/executors/UpdateUserOperationExecutor.kt | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt index a2c59553fc..cb7dfc93ad 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt @@ -51,7 +51,7 @@ internal class IdentityOperationExecutor( identityAlias.first, identityAlias.second, mapOf(lastOperation.label to lastOperation.value), - _identityModelStore.model.jwtToken, + lastOperation.operationJwt, ) // ensure the now created alias is in the model as long as the user is still current. @@ -96,7 +96,7 @@ internal class IdentityOperationExecutor( IdentityConstants.ONESIGNAL_ID, lastOperation.onesignalId, lastOperation.label, - _identityModelStore.model.jwtToken, + lastOperation.operationJwt, ) // ensure the now deleted alias is not in the model as long as the user is still current. 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 098aa4e547..5c2161252f 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 @@ -182,7 +182,7 @@ internal class LoginUserOperationExecutor( it.second }, properties, - _identityModelStore.model.jwtToken, + createUserOperation.operationJwt, ) val idTranslations = mutableMapOf() // Add the "local-to-backend" ID translation to the IdentifierTranslator for any operations that were diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt index cb987c3a64..2f950cdedf 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt @@ -59,7 +59,7 @@ internal class RefreshUserOperationExecutor( op.appId, identityAlias.first, identityAlias.second, - _identityModelStore.model.jwtToken, + op.operationJwt, ) if (op.onesignalId != _identityModelStore.model.onesignalId) { @@ -70,7 +70,7 @@ internal class RefreshUserOperationExecutor( for (aliasKVP in response.identities) { identityModel[aliasKVP.key] = aliasKVP.value } - identityModel.jwtToken = _identityModelStore.model.jwtToken + identityModel.jwtToken = op.operationJwt val propertiesModel = PropertiesModel() propertiesModel.onesignalId = op.onesignalId diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 7cc483a727..65ce0bc0b7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -114,7 +114,7 @@ internal class SubscriptionOperationExecutor( identityAlias.first, identityAlias.second, subscription, - _identityModelStore.model.jwtToken, + createOperation.operationJwt, ) ?: return ExecutionResponse(ExecutionResult.SUCCESS) val backendSubscriptionId = result.first @@ -252,7 +252,7 @@ internal class SubscriptionOperationExecutor( startingOperation.subscriptionId, IdentityConstants.ONESIGNAL_ID, startingOperation.onesignalId, - _identityModelStore.model.jwtToken, + startingOperation.operationJwt, ) } catch (ex: BackendException) { val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) @@ -284,7 +284,7 @@ internal class SubscriptionOperationExecutor( private suspend fun deleteSubscription(op: DeleteSubscriptionOperation): ExecutionResponse { try { - _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId, _identityModelStore.model.jwtToken) + _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId, op.operationJwt) // remove the subscription model as a HYDRATE in case for some reason it still exists. _subscriptionModelStore.remove(op.subscriptionId, ModelChangeTags.HYDRATE) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt index aeccd32177..2144cab3cb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt @@ -156,7 +156,7 @@ internal class UpdateUserOperationExecutor( propertiesObject, refreshDeviceMetadata, deltasObject, - _identityModelStore.model.jwtToken, + operations.first().operationJwt, ) if (rywData != null) { From 271e4ccb8dc01c6a1801122f319b743a95fee6ec Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 09:38:13 -0700 Subject: [PATCH 06/13] Remove old JWT invalidation path from UserManager JWT invalidation is now handled by OperationRepo with the correct per-operation externalId. Remove jwtInvalidatedCallback EventProducer, jwtTokenInvalidated tracking, and JWT_TOKEN handling from onModelUpdated. UserManager listener methods are now no-ops since OneSignalImp delegates directly to OperationRepo. Made-with: Cursor --- .../onesignal/user/internal/UserManager.kt | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 8183361d49..45f57c7e38 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -1,7 +1,6 @@ package com.onesignal.user.internal import com.onesignal.IUserJwtInvalidatedListener -import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils import com.onesignal.common.events.EventProducer @@ -43,10 +42,6 @@ internal open class UserManager( val changeHandlersNotifier = EventProducer() - val jwtInvalidatedCallback = EventProducer() - - private var jwtTokenInvalidated: String? = null - override val pushSubscription: IPushSubscription get() = _subscriptionManager.subscriptions.push @@ -251,13 +246,11 @@ internal open class UserManager( } override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - Logging.debug("OneSignal.addUserJwtInvalidatedListener(listener: $listener)") - jwtInvalidatedCallback.subscribe(listener) + Logging.debug("UserManager.addUserJwtInvalidatedListener is a no-op; use OneSignal.addUserJwtInvalidatedListener instead") } override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - Logging.debug("OneSignal.removeUserJwtInvalidatedListener(listener: $listener)") - jwtInvalidatedCallback.unsubscribe(listener) + Logging.debug("UserManager.removeUserJwtInvalidatedListener is a no-op; use OneSignal.removeUserJwtInvalidatedListener instead") } override fun onModelReplaced( @@ -276,21 +269,6 @@ internal open class UserManager( it.onUserStateChange(UserChangedState(newUserState)) } } - IdentityConstants.JWT_TOKEN -> { - // Fire the event when the JWT has been invalidated. - val oldJwt = args.oldValue - val newJwt = args.newValue - - // When newJwt is equals to null, we are invalidating JWT for the current user. - // We need to prevent same JWT from being invalidated twice in a row. - if (jwtTokenInvalidated != oldJwt && newJwt == null) { - jwtInvalidatedCallback.fire { - it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) - } - } - - jwtTokenInvalidated = oldJwt as String? - } } } } From 41d977e552bbd0e99b9a95996084c717ccff25d0 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 09:43:33 -0700 Subject: [PATCH 07/13] Update and add unit tests for multi-user JWT queue logic Update existing JWT tests to match new per-operation behavior. Add tests: - getNextOps skips null-JWT ops, allows requiresJwt=false through - getNextOps discards anonymous ops when IV is on - getNextOps returns null when isInitializedWithRemote is false - getNextOps passes all ops when IV is off - FAIL_UNAUTHORIZED nulls JWT and fires listener with correct externalId - FAIL_UNAUTHORIZED drops ops after max retries - updateJwtForExternalId updates JWT and resets retry count - updateJwtForExternalId only affects matching user - Enqueue stamps JWT/externalId from IdentityModelStore - Follow-up operations inherit JWT/externalId from starting op Made-with: Cursor --- .../internal/operations/OperationRepoTests.kt | 229 +++++++++++++++--- 1 file changed, 202 insertions(+), 27 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 6e9c9a3b26..838cbe38a2 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -1,5 +1,7 @@ package com.onesignal.core.internal.operations +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.threading.OSPrimaryCoroutineScope import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue @@ -39,7 +41,10 @@ import java.util.UUID // Mocks used by every test in this file private class Mocks { - val configModelStore = MockHelper.configModelStore() + val configModelStore = + MockHelper.configModelStore { + it.isInitializedWithRemote = true + } val identityModelStore = MockHelper.identityModelStore { @@ -54,11 +59,13 @@ private class Mocks { every { mockOperationModelStore.loadOperations() } just runs every { mockOperationModelStore.list() } answers { operationStoreList.toList() } every { mockOperationModelStore.add(any()) } answers { operationStoreList.add(firstArg()) } + every { mockOperationModelStore.add(any(), any()) } answers { operationStoreList.add(firstArg(), secondArg()) } every { mockOperationModelStore.remove(any()) } answers { val id = firstArg() val op = operationStoreList.firstOrNull { it.id == id } operationStoreList.remove(op) } + every { mockOperationModelStore.persist() } just runs mockOperationModelStore } @@ -791,52 +798,212 @@ class OperationRepoTests : FunSpec({ opRepo.forceExecuteOperations() } - test("operations that need to be identity verified cannot execute until JWT is provided") { + test("getNextOps skips ops with null JWT when identity verification is on") { val mocks = Mocks() mocks.configModelStore.model.useIdentityVerification = true - val operation = mockOperation() + + val opWithJwt = mockOperation(operationJwt = "valid-jwt", operationExternalId = "userA") + val opWithoutJwt = mockOperation(operationJwt = null, operationExternalId = "userB") + val opRepo = mocks.operationRepo + opRepo.queue.add(OperationQueueItem(opWithoutJwt, bucket = 0)) + opRepo.queue.add(OperationQueueItem(opWithJwt, bucket = 0)) - // When JWT is not supplied - opRepo.start() - opRepo.enqueue(operation) - opRepo.forceExecuteOperations() - val responseBeforeJWT = - withTimeoutOrNull(100) { - opRepo.enqueueAndWait(mockOperation()) - } + // When + val result = opRepo.getNextOps(0) + + // Then - skips opWithoutJwt, returns opWithJwt + result shouldNotBe null + result!!.size shouldBe 1 + result[0].operation shouldBe opWithJwt + } + + test("FAIL_UNAUTHORIZED nulls JWT on operations and fires listener with correct externalId") { + // Given + val mocks = Mocks() + val opRepo = mocks.operationRepo + + val listenerEvents = mutableListOf() + opRepo.addUserJwtInvalidatedListener( + object : IUserJwtInvalidatedListener { + override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { + listenerEvents.add(event.externalId) + } + }, + ) + + coEvery { + mocks.executor.execute(any()) + } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) + + val operation = mockOperation(operationJwt = "test-jwt", operationExternalId = "userA") + val queueItem = OperationQueueItem(operation, bucket = 0) + + // When - call executeOperations directly to test the handler in isolation + opRepo.executeOperations(listOf(queueItem)) + + // Then + verify { operation.operationJwt = null } + listenerEvents.size shouldBe 1 + listenerEvents[0] shouldBe "userA" + opRepo.queue.size shouldBe 1 + } + test("getNextOps allows requiresJwt=false ops through even with null JWT") { + val mocks = Mocks() + mocks.configModelStore.model.useIdentityVerification = true + + val op = mockOperation(operationJwt = null, operationExternalId = null, requiresJwt = false) + mocks.operationRepo.queue.add(OperationQueueItem(op, bucket = 0)) + + // When + val result = mocks.operationRepo.getNextOps(0) + + // Then + result shouldNotBe null + result!!.size shouldBe 1 + result[0].operation shouldBe op + } + + test("getNextOps discards anonymous ops when identity verification is on") { + val mocks = Mocks() + mocks.configModelStore.model.useIdentityVerification = true + + val anonOp = mockOperation(operationExternalId = null, requiresJwt = true) + val anonOpId = anonOp.id + mocks.operationRepo.queue.add(OperationQueueItem(anonOp, bucket = 0)) + + // When + val result = mocks.operationRepo.getNextOps(0) + + // Then - anonymous op discarded, queue is empty + result shouldBe null + mocks.operationRepo.queue.size shouldBe 0 + verify { mocks.operationModelStore.remove(anonOpId) } + } + + test("getNextOps does not discard or skip anything when identity verification is off") { + val mocks = Mocks() + mocks.configModelStore.model.useIdentityVerification = false + + val op = mockOperation(operationJwt = null, operationExternalId = null, requiresJwt = true) + mocks.operationRepo.queue.add(OperationQueueItem(op, bucket = 0)) + + // When + val result = mocks.operationRepo.getNextOps(0) + + // Then + result shouldNotBe null + result!!.size shouldBe 1 + } - // Then response should be null - responseBeforeJWT shouldBe null + test("getNextOps returns null when isInitializedWithRemote is false") { + val mocks = Mocks() + mocks.configModelStore.model.isInitializedWithRemote = false + + val op = mockOperation(operationJwt = "jwt", operationExternalId = "user") + mocks.operationRepo.queue.add(OperationQueueItem(op, bucket = 0)) - // When JWT is updated - mocks.identityModelStore.model.jwtToken = "123" - val opToExecute = opRepo.getNextOps(0) + // When + val result = mocks.operationRepo.getNextOps(0) - // Operation is ready to execute - opToExecute shouldNotBe null + // Then + result shouldBe null + mocks.operationRepo.queue.size shouldBe 1 } - test("JWT will be invalidated when a FAIL_UNAUTHORIZED response is returned") { + test("FAIL_UNAUTHORIZED drops ops after max retries") { // Given val mocks = Mocks() val opRepo = mocks.operationRepo - val identityModelStore = mocks.identityModelStore - coEvery { opRepo.delayBeforeNextExecution(any(), any()) } just runs + coEvery { mocks.executor.execute(any()) - } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) andThen ExecutionResponse(ExecutionResult.SUCCESS) + } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) + + val op = mockOperation(operationJwt = "jwt", operationExternalId = "user") + val opId = op.id + + // Simulate 3 prior unauthorized retries (next one should drop) + val queueItem = OperationQueueItem(op, bucket = 0, unauthorizedRetries = 3) + + // When - call executeOperations directly (getNextOps would have removed from queue) + opRepo.executeOperations(listOf(queueItem)) + + // Then - op should be dropped, not re-added to queue + opRepo.queue.size shouldBe 0 + verify { mocks.operationModelStore.remove(opId) } + } + + test("updateJwtForExternalId updates JWT and resets retry count on matching ops") { + // Given + val mocks = Mocks() + val opRepo = mocks.operationRepo + + val op = mockOperation(operationJwt = null, operationExternalId = "userA") + val item = OperationQueueItem(op, bucket = 0, unauthorizedRetries = 2) + opRepo.queue.add(item) + + // When + opRepo.updateJwtForExternalId("userA", "new-jwt") + + // Then + verify { op.operationJwt = "new-jwt" } + item.unauthorizedRetries shouldBe 0 + verify { mocks.operationModelStore.persist() } + } + + test("updateJwtForExternalId does not affect ops for different users") { + // Given + val mocks = Mocks() + val opRepo = mocks.operationRepo + + val opA = mockOperation(operationJwt = null, operationExternalId = "userA") + val opB = mockOperation(operationJwt = null, operationExternalId = "userB") + opRepo.queue.add(OperationQueueItem(opA, bucket = 0)) + opRepo.queue.add(OperationQueueItem(opB, bucket = 0)) + + // When + opRepo.updateJwtForExternalId("userA", "new-jwt-a") + + // Then + verify { opA.operationJwt = "new-jwt-a" } + verify(exactly = 0) { opB.operationJwt = any() } + } + + test("enqueue stamps JWT and externalId from IdentityModelStore") { + // Given + val mocks = Mocks() + mocks.identityModelStore.model.jwtToken = "current-jwt" + mocks.identityModelStore.model.externalId = "currentUser" + val operation = mockOperation() + // When + mocks.operationRepo.enqueue(operation) + OSPrimaryCoroutineScope.waitForIdle() + + // Then + verify { operation.operationJwt = "current-jwt" } + verify { operation.operationExternalId = "currentUser" } + } + + test("follow-up operations inherit JWT and externalId from starting operation") { + // Given + val mocks = Mocks() + val followUpOp = mockOperation() + val startingOp = mockOperation(operationJwt = "start-jwt", operationExternalId = "startUser") + + coEvery { + mocks.executor.execute(listOf(startingOp)) + } returns ExecutionResponse(ExecutionResult.SUCCESS, operations = listOf(followUpOp)) + // When mocks.operationRepo.start() - mocks.operationRepo.enqueueAndWait(operation) + mocks.operationRepo.executeOperations(listOf(OperationQueueItem(startingOp, bucket = 0))) // Then - coVerifyOrder { - mocks.executor.execute(listOf(operation)) - identityModelStore.invalidateJwt() - } + verify { followUpOp.operationJwt = "start-jwt" } + verify { followUpOp.operationExternalId = "startUser" } } }) { companion object { @@ -849,6 +1016,9 @@ class OperationRepoTests : FunSpec({ modifyComparisonKey: String = "modify-key", operationIdSlot: CapturingSlot? = null, applyToRecordId: String = "", + operationJwt: String? = null, + operationExternalId: String? = null, + requiresJwt: Boolean = true, ): Operation { val operation = mockk() val opIdSlot = operationIdSlot ?: slot() @@ -862,6 +1032,11 @@ class OperationRepoTests : FunSpec({ every { operation.modifyComparisonKey } returns modifyComparisonKey every { operation.translateIds(any()) } just runs every { operation.applyToRecordId } returns applyToRecordId + every { operation.operationJwt } returns operationJwt + every { operation.operationJwt = any() } just runs + every { operation.operationExternalId } returns operationExternalId + every { operation.operationExternalId = any() } just runs + every { operation.requiresJwt } returns requiresJwt return operation } From 246735645c610fc4c12c0604b2b30f1274854c3e Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 11:09:17 -0700 Subject: [PATCH 08/13] Fix executors using current user's identity alias instead of operation's Executors were calling _identityModelStore.getIdentityAlias() to build backend API URL paths, which returns the *current* user's alias. When executing operations for a previous user, this caused the wrong user's identity to be used in the URL (e.g., nan00's refresh-user fetching nan01's data), resulting in 401 errors. Replace with operation-derived alias: use operationExternalId when JWT is present, otherwise fall back to onesignalId from the operation. Made-with: Cursor --- .../impl/executors/IdentityOperationExecutor.kt | 7 ++++++- .../impl/executors/RefreshUserOperationExecutor.kt | 8 +++++++- .../impl/executors/SubscriptionOperationExecutor.kt | 7 ++++++- .../impl/executors/UpdateUserOperationExecutor.kt | 8 +++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt index cb7dfc93ad..a396b4cd19 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt @@ -45,7 +45,12 @@ internal class IdentityOperationExecutor( if (lastOperation is SetAliasOperation) { try { - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (lastOperation.operationJwt != null && lastOperation.operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, lastOperation.operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, lastOperation.onesignalId) + } _identityBackend.setAlias( lastOperation.appId, identityAlias.first, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt index 2f950cdedf..63a59999d5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt @@ -13,6 +13,7 @@ import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IUserBackendService import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService +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.operations.RefreshUserOperation @@ -53,7 +54,12 @@ internal class RefreshUserOperationExecutor( private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse { try { - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (op.operationJwt != null && op.operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, op.operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, op.onesignalId) + } val response = _userBackend.getUser( op.appId, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 65ce0bc0b7..9eeff32bd6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -107,7 +107,12 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (createOperation.operationJwt != null && createOperation.operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, createOperation.operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, createOperation.onesignalId) + } val result = _subscriptionBackend.createSubscription( createOperation.appId, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt index 2144cab3cb..1ff704c9d0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt @@ -13,6 +13,7 @@ import com.onesignal.core.internal.operations.Operation import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IUserBackendService +import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.PropertiesDeltasObject import com.onesignal.user.internal.backend.PropertiesObject import com.onesignal.user.internal.backend.PurchaseObject @@ -147,7 +148,12 @@ internal class UpdateUserOperationExecutor( if (appId != null && onesignalId != null) { try { - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (operations.first().operationJwt != null && operations.first().operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, operations.first().operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, onesignalId) + } val rywData = _userBackend.updateUser( appId, From 9953c4369a35f2658973bd640e32492e40dd0a69 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 12:16:14 -0700 Subject: [PATCH 09/13] Add unit tests for operation-derived identity alias in executors Test that each executor uses the operation's own JWT/externalId to derive the identity alias for API calls, rather than reading from the current user's IdentityModelStore. Covers both JWT-present (external_id path) and no-JWT (onesignal_id path) scenarios for all 4 affected executors: Identity, RefreshUser, Subscription, UpdateUser. Made-with: Cursor --- .../IdentityOperationExecutorTests.kt | 75 ++++++++++++ .../RefreshUserOperationExecutorTests.kt | 97 +++++++++++++++ .../SubscriptionOperationExecutorTests.kt | 113 ++++++++++++++++++ .../UpdateUserOperationExecutorTests.kt | 83 +++++++++++++ 4 files changed, 368 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt index d2ae66bf5d..c49a96be75 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt @@ -268,4 +268,79 @@ class IdentityOperationExecutorTests : FunSpec({ // Then response.result shouldBe ExecutionResult.FAIL_RETRY } + + test("set alias uses operation's external_id when JWT is present, not current user") { + // Given + val mockIdentityBackendService = mockk() + coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf() + + val mockIdentityModel = mockk() + every { mockIdentityModel.onesignalId } returns "currentUserOnesignalId" + every { mockIdentityModel.jwtToken } returns "currentUserJwt" + + val mockIdentityModelStore = mockk() + every { mockIdentityModelStore.model } returns mockIdentityModel + + val mockBuildUserService = mockk() + + val identityOperationExecutor = + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + + val operation = SetAliasOperation("appId", "previousUserOnesignalId", "aliasKey1", "aliasValue1") + operation.operationJwt = "previousUserJwt" + operation.operationExternalId = "previousUserExternalId" + val operations = listOf(operation) + + // When + val response = identityOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockIdentityBackendService.setAlias( + "appId", + IdentityConstants.EXTERNAL_ID, + "previousUserExternalId", + mapOf("aliasKey1" to "aliasValue1"), + "previousUserJwt", + ) + } + } + + test("set alias uses onesignal_id when no JWT is present") { + // Given + val mockIdentityBackendService = mockk() + coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf() + + val mockIdentityModel = mockk() + every { mockIdentityModel.onesignalId } returns "onesignalId" + every { mockIdentityModel.setStringProperty(any(), any(), any()) } just runs + every { mockIdentityModel.jwtToken } returns null + + val mockIdentityModelStore = mockk() + every { mockIdentityModelStore.model } returns mockIdentityModel + + val mockBuildUserService = mockk() + + val identityOperationExecutor = + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + + val operation = SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1") + val operations = listOf(operation) + + // When + val response = identityOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockIdentityBackendService.setAlias( + "appId", + IdentityConstants.ONESIGNAL_ID, + "onesignalId", + mapOf("aliasKey1" to "aliasValue1"), + null, + ) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt index 9dd1c03bfc..33d4aa4580 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt @@ -329,4 +329,101 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId) } } + + test("refresh user uses operation's external_id when JWT is present, not current user") { + // Given + val previousUserExternalId = "previousUser" + val previousUserJwt = "previousUserJwt" + + val mockUserBackendService = mockk() + coEvery { + mockUserBackendService.getUser(appId, IdentityConstants.EXTERNAL_ID, previousUserExternalId, previousUserJwt) + } returns CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId, IdentityConstants.EXTERNAL_ID to previousUserExternalId), + PropertiesObject(), + listOf(), + null, + ) + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockIdentityModel = IdentityModel() + mockIdentityModel.onesignalId = "currentUserOnesignalId" + every { mockIdentityModelStore.model } returns mockIdentityModel + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val refreshUserOperationExecutor = + RefreshUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + ) + + val op = RefreshUserOperation(appId, remoteOneSignalId) + op.operationJwt = previousUserJwt + op.operationExternalId = previousUserExternalId + val operations = listOf(op) + + // When + val response = refreshUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.getUser(appId, IdentityConstants.EXTERNAL_ID, previousUserExternalId, previousUserJwt) + } + } + + test("refresh user uses onesignal_id when no JWT is present") { + // Given + val mockUserBackendService = mockk() + coEvery { + mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, null) + } returns CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(), + null, + ) + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockIdentityModel = IdentityModel() + mockIdentityModel.onesignalId = remoteOneSignalId + every { mockIdentityModelStore.model } returns mockIdentityModel + every { mockIdentityModelStore.replace(any(), any()) } just runs + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + every { mockPropertiesModelStore.replace(any(), any()) } just runs + val mockSubscriptionsModelStore = mockk() + every { mockSubscriptionsModelStore.replaceAll(any(), any()) } just runs + val mockBuildUserService = mockk() + + val refreshUserOperationExecutor = + RefreshUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + ) + + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + + // When + val response = refreshUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, null) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt index b4202a56e4..eadf4632ad 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt @@ -798,4 +798,117 @@ class SubscriptionOperationExecutorTests : mockConsistencyManager.setRywData(remoteOneSignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywData) } } + + test("create subscription uses operation's external_id when JWT is present, not current user") { + // Given + val previousUserExternalId = "previousUserExternalId" + val previousUserJwt = "previousUserJwt" + + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any(), any()) } returns + Pair(remoteSubscriptionId, rywData) + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockIdentityModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val createOp = CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ) + createOp.operationJwt = previousUserJwt + createOp.operationExternalId = previousUserExternalId + val operations = listOf(createOp) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( + appId, + IdentityConstants.EXTERNAL_ID, + previousUserExternalId, + any(), + previousUserJwt, + ) + } + } + + test("create subscription uses onesignal_id when no JWT is present") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any(), any()) } returns + Pair(remoteSubscriptionId, rywData) + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockIdentityModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val createOp = CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ) + val operations = listOf(createOp) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + any(), + null, + ) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt index de2e148ff8..3571e2f75f 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt @@ -394,4 +394,87 @@ class UpdateUserOperationExecutorTests : mockConsistencyManager.setRywData(remoteOneSignalId, IamFetchRywTokenKey.USER, rywData) } } + + test("update user uses operation's external_id when JWT is present, not current user") { + // Given + val previousUserExternalId = "previousUserExternalId" + val previousUserJwt = "previousUserJwt" + + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any(), any()) } returns rywData + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val updateUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val op = SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1") + op.operationJwt = previousUserJwt + op.operationExternalId = previousUserExternalId + val operations = listOf(op) + + // When + val response = updateUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.EXTERNAL_ID, + previousUserExternalId, + any(), + any(), + any(), + previousUserJwt, + ) + } + } + + test("update user uses onesignal_id when no JWT is present") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywData + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val updateUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + + // When + val response = updateUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + any(), + any(), + any(), + ) + } + } }) From c07269e7a3c8f39928747669035f3fd445383958 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 12:28:03 -0700 Subject: [PATCH 10/13] Fix identity alias to use useIdentityVerification config instead of JWT presence The identity alias derivation incorrectly used operationJwt != null to decide between external_id and onesignal_id for the API URL path. This is wrong because even if an operation has a JWT, if identity verification is disabled on the server, the backend expects onesignal_id. Now uses _configModelStore.model.useIdentityVerification as the condition, which reflects the actual server configuration. Injected ConfigModelStore into IdentityOperationExecutor and UpdateUserOperationExecutor (the two that didn't have it). Updated all unit tests accordingly. Made-with: Cursor --- .../executors/IdentityOperationExecutor.kt | 4 ++- .../executors/RefreshUserOperationExecutor.kt | 2 +- .../SubscriptionOperationExecutor.kt | 2 +- .../executors/UpdateUserOperationExecutor.kt | 4 ++- .../IdentityOperationExecutorTests.kt | 30 +++++++++---------- .../RefreshUserOperationExecutorTests.kt | 6 ++-- .../SubscriptionOperationExecutorTests.kt | 6 ++-- .../UpdateUserOperationExecutorTests.kt | 14 +++++++-- 8 files changed, 41 insertions(+), 27 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt index a396b4cd19..64e1922ff7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal.operations.impl.executors import com.onesignal.common.NetworkUtils import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -19,6 +20,7 @@ import com.onesignal.user.internal.operations.impl.states.NewRecordsState internal class IdentityOperationExecutor( private val _identityBackend: IIdentityBackendService, private val _identityModelStore: IdentityModelStore, + private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, ) : IOperationExecutor { @@ -46,7 +48,7 @@ internal class IdentityOperationExecutor( if (lastOperation is SetAliasOperation) { try { val identityAlias = - if (lastOperation.operationJwt != null && lastOperation.operationExternalId != null) { + if (_configModelStore.model.useIdentityVerification && lastOperation.operationExternalId != null) { Pair(IdentityConstants.EXTERNAL_ID, lastOperation.operationExternalId!!) } else { Pair(IdentityConstants.ONESIGNAL_ID, lastOperation.onesignalId) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt index 63a59999d5..8cd776dbbe 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt @@ -55,7 +55,7 @@ internal class RefreshUserOperationExecutor( private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse { try { val identityAlias = - if (op.operationJwt != null && op.operationExternalId != null) { + if (_configModelStore.model.useIdentityVerification && op.operationExternalId != null) { Pair(IdentityConstants.EXTERNAL_ID, op.operationExternalId!!) } else { Pair(IdentityConstants.ONESIGNAL_ID, op.onesignalId) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 9eeff32bd6..4ffb2d852f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -108,7 +108,7 @@ internal class SubscriptionOperationExecutor( ) val identityAlias = - if (createOperation.operationJwt != null && createOperation.operationExternalId != null) { + if (_configModelStore.model.useIdentityVerification && createOperation.operationExternalId != null) { Pair(IdentityConstants.EXTERNAL_ID, createOperation.operationExternalId!!) } else { Pair(IdentityConstants.ONESIGNAL_ID, createOperation.onesignalId) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt index 1ff704c9d0..210b44156b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt @@ -6,6 +6,7 @@ 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.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -32,6 +33,7 @@ internal class UpdateUserOperationExecutor( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, + private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, @@ -149,7 +151,7 @@ internal class UpdateUserOperationExecutor( if (appId != null && onesignalId != null) { try { val identityAlias = - if (operations.first().operationJwt != null && operations.first().operationExternalId != null) { + if (_configModelStore.model.useIdentityVerification && operations.first().operationExternalId != null) { Pair(IdentityConstants.EXTERNAL_ID, operations.first().operationExternalId!!) } else { Pair(IdentityConstants.ONESIGNAL_ID, onesignalId) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt index c49a96be75..3e9f6622cc 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt @@ -41,7 +41,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -77,7 +77,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -98,7 +98,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -119,7 +119,7 @@ class IdentityOperationExecutorTests : FunSpec({ every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -142,7 +142,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockConfigModelStore, mockBuildUserService, newRecordState) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -169,7 +169,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -192,7 +192,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -212,7 +212,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -234,7 +234,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -259,7 +259,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockConfigModelStore, mockBuildUserService, newRecordState) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -269,22 +269,22 @@ class IdentityOperationExecutorTests : FunSpec({ response.result shouldBe ExecutionResult.FAIL_RETRY } - test("set alias uses operation's external_id when JWT is present, not current user") { + test("set alias uses operation's external_id when IV is enabled, not current user") { // Given val mockIdentityBackendService = mockk() coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf() val mockIdentityModel = mockk() every { mockIdentityModel.onesignalId } returns "currentUserOnesignalId" - every { mockIdentityModel.jwtToken } returns "currentUserJwt" val mockIdentityModelStore = mockk() every { mockIdentityModelStore.model } returns mockIdentityModel val mockBuildUserService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { it.useIdentityVerification = true } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockConfigModelStore, mockBuildUserService, getNewRecordState()) val operation = SetAliasOperation("appId", "previousUserOnesignalId", "aliasKey1", "aliasValue1") operation.operationJwt = "previousUserJwt" @@ -307,7 +307,7 @@ class IdentityOperationExecutorTests : FunSpec({ } } - test("set alias uses onesignal_id when no JWT is present") { + test("set alias uses onesignal_id when IV is disabled") { // Given val mockIdentityBackendService = mockk() coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf() @@ -323,7 +323,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operation = SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1") val operations = listOf(operation) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt index 33d4aa4580..f4c727557f 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt @@ -330,7 +330,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ } } - test("refresh user uses operation's external_id when JWT is present, not current user") { + test("refresh user uses operation's external_id when IV is enabled, not current user") { // Given val previousUserExternalId = "previousUser" val previousUserJwt = "previousUserJwt" @@ -360,7 +360,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, - MockHelper.configModelStore(), + MockHelper.configModelStore { it.useIdentityVerification = true }, mockBuildUserService, getNewRecordState(), ) @@ -380,7 +380,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ } } - test("refresh user uses onesignal_id when no JWT is present") { + test("refresh user uses onesignal_id when IV is disabled") { // Given val mockUserBackendService = mockk() coEvery { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt index eadf4632ad..fb4cb91a78 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt @@ -799,7 +799,7 @@ class SubscriptionOperationExecutorTests : } } - test("create subscription uses operation's external_id when JWT is present, not current user") { + test("create subscription uses operation's external_id when IV is enabled, not current user") { // Given val previousUserExternalId = "previousUserExternalId" val previousUserJwt = "previousUserJwt" @@ -823,7 +823,7 @@ class SubscriptionOperationExecutorTests : AndroidMockHelper.applicationService(), mockIdentityModelStore, mockSubscriptionsModelStore, - MockHelper.configModelStore(), + MockHelper.configModelStore { it.useIdentityVerification = true }, mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -858,7 +858,7 @@ class SubscriptionOperationExecutorTests : } } - test("create subscription uses onesignal_id when no JWT is present") { + test("create subscription uses onesignal_id when IV is disabled") { // Given val mockSubscriptionBackendService = mockk() coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any(), any()) } returns diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt index 3571e2f75f..699b9fd20d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt @@ -53,6 +53,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -93,6 +94,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -155,6 +157,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -200,6 +203,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -265,6 +269,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -313,6 +318,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -346,6 +352,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + mockConfigModelStore, mockBuildUserService, newRecordState, mockConsistencyManager, @@ -376,6 +383,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -395,7 +403,7 @@ class UpdateUserOperationExecutorTests : } } - test("update user uses operation's external_id when JWT is present, not current user") { + test("update user uses operation's external_id when IV is enabled, not current user") { // Given val previousUserExternalId = "previousUserExternalId" val previousUserJwt = "previousUserJwt" @@ -412,6 +420,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore { it.useIdentityVerification = true }, mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -440,7 +449,7 @@ class UpdateUserOperationExecutorTests : } } - test("update user uses onesignal_id when no JWT is present") { + test("update user uses onesignal_id when IV is disabled") { // Given val mockUserBackendService = mockk() coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywData @@ -454,6 +463,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, From 8c0e29e3a152613a1b23a023035c3b691a2e884f Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 12:55:28 -0700 Subject: [PATCH 11/13] Create identity-verification-overview.md --- docs/identity-verification-overview.md | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/identity-verification-overview.md diff --git a/docs/identity-verification-overview.md b/docs/identity-verification-overview.md new file mode 100644 index 0000000000..83ab696aac --- /dev/null +++ b/docs/identity-verification-overview.md @@ -0,0 +1,142 @@ +# Identity Verification in the OneSignal Android SDK + +## What is Identity Verification? + +Identity verification prevents impersonation by requiring a server-generated JWT (JSON Web Token) to accompany API requests that act on a user. When enabled, the SDK attaches a `Bearer` token to every outgoing HTTP request, and the OneSignal backend rejects any request that carries an invalid or missing token. + +The feature is **controlled server-side**: the OneSignal dashboard sets a flag (`jwt_required`) that the SDK fetches at startup via the remote params endpoint. The SDK stores this in `ConfigModel.useIdentityVerification`. + +--- + +## End-to-End Flow + +``` +┌──────────┐ login(externalId, jwt) ┌──────────────┐ +│ App │ ─────────────────────────► │ SDK │ +│ Code │ │ │ +│ │ ◄── onUserJwtInvalidated ──│ OperationRepo│ +│ │ │ │ +│ │ updateUserJwt(id, jwt) │ │ +│ │ ─────────────────────────► │ │ +└──────────┘ └──────┬───────┘ + │ + Authorization: Bearer + │ + ▼ + ┌──────────────┐ + │ OneSignal │ + │ Backend │ + └──────────────┘ +``` + +### Step by step + +1. **App fetches remote params** — The SDK calls `android_params.js`. The response contains `"jwt_required": true/false`, which sets `ConfigModel.useIdentityVerification`. + +2. **App logs in with a JWT** — The app calls `OneSignal.login(externalId, jwtBearerToken)`. The JWT is stored in `IdentityModel.jwtToken`. + +3. **Operations are stamped** — When any operation is enqueued into `OperationRepo`, the current JWT and external ID are copied onto the operation (`operationJwt`, `operationExternalId`). This means each operation remembers *which user* created it, even if the active user changes later. + +4. **Queue filtering** — Before executing, `OperationRepo.getNextOps()` applies two filters when identity verification is on: + - **Discards anonymous operations**: operations with no `operationExternalId` that require JWT are dropped. + - **Blocks JWT-less operations**: operations that require JWT but have `operationJwt == null` are skipped (they sit in the queue waiting for a token). + +5. **HTTP request** — The executor passes the JWT to the backend service, which sets the `Authorization: Bearer ` header via `HttpClient`. + +6. **Alias selection** — When identity verification is enabled and the operation has an external ID, executors identify the user by `external_id` instead of `onesignal_id`: + ```kotlin + if (_configModelStore.model.useIdentityVerification && operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, onesignalId) + } + ``` + +--- + +## What Happens on a 401/403 (Unauthorized) + +When the backend returns **401 or 403**, the executor reports `FAIL_UNAUTHORIZED`. The `OperationRepo` then: + +1. **Clears the JWT** on the failed operations (`operationJwt = null`) and re-queues them at the front. +2. **Increments a retry counter** (`unauthorizedRetries`) on each operation. +3. **Fires `IUserJwtInvalidatedListener`** so the app can fetch a fresh token from its own backend. +4. If an operation exceeds **3 unauthorized retries**, it is **dropped permanently**. + +The operations sit in the queue with a null JWT, effectively paused, until the app provides a new token. + +### Recovery + +When the app calls `OneSignal.updateUserJwt(externalId, newToken)`: + +- Every queued operation matching that `externalId` gets the new JWT. +- The retry counter resets to 0. +- The operation queue is woken up to resume processing. + +--- + +## Startup Behavior (`IdentityVerificationService`) + +On app cold start, the `IdentityVerificationService` listens for the config model to hydrate from cached remote params. Once hydrated: + +- If identity verification is **on** and no JWT is cached, it logs a message and **does not enqueue** the login operation. The SDK waits for the app to call `login(externalId, jwt)`. +- If identity verification is **off**, or a JWT **is** cached, it enqueues a `LoginUserOperation` automatically to re-establish the session. + +--- + +## Public API + +### Login with JWT + +```kotlin +OneSignal.login("user_123", jwtToken) +``` + +Both the external ID and JWT are required when identity verification is enabled. Calling `login` without a JWT when verification is on will log a warning and block the login. + +### Update an Expired JWT + +```kotlin +OneSignal.updateUserJwt("user_123", freshJwtToken) +``` + +Call this after receiving the invalidation callback (or proactively when you know the token is about to expire). + +### Listen for JWT Invalidation + +```kotlin +OneSignal.addUserJwtInvalidatedListener { event -> + val externalId = event.externalId + // Fetch a new JWT from your server, then: + OneSignal.updateUserJwt(externalId, newJwt) +} +``` + +### Operations That Skip JWT + +Most operations require JWT when identity verification is on. The exception is `UpdateSubscriptionOperation`, which overrides `requiresJwt` to `false` — subscription updates (e.g. push token refresh) can proceed without a valid JWT. + +--- + +## Key Classes + +| Class | Responsibility | +|---|---| +| `ConfigModel` | Stores `useIdentityVerification` (from `jwt_required` remote param) | +| `IdentityModel` | Stores the current user's `externalId` and `jwtToken` | +| `Operation` | Base class; carries `operationJwt`, `operationExternalId`, and `requiresJwt` | +| `OperationRepo` | Stamps JWT on enqueue, filters queue, handles 401 retry logic, fires invalidation callbacks | +| `IdentityVerificationService` | Bootstrap service that gates the initial login operation on JWT availability | +| `HttpClient` | Attaches `Authorization: Bearer` header when JWT is present | +| `IUserJwtInvalidatedListener` | Callback interface the app implements to be notified of expired tokens | + +--- + +## Summary + +- Identity verification is a **server-controlled** feature toggled by `jwt_required` in remote params. +- The JWT is **stamped per-operation** at enqueue time, tying each operation to a specific user. +- On **401/403**, operations are paused and the app is notified via `IUserJwtInvalidatedListener`. +- The app recovers by calling `updateUserJwt`, which re-arms all queued operations for that user. +- After **3 failed retries**, an operation is permanently dropped. +- `UpdateSubscriptionOperation` is exempt from JWT requirements so push token updates are never blocked. From ca1dc4df972744d2417651d30049c825ee88f443 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 19 Mar 2026 13:08:36 -0700 Subject: [PATCH 12/13] nit: lint fixes --- .../core/internal/operations/Operation.kt | 8 +++- .../internal/operations/impl/OperationRepo.kt | 7 ++-- .../executors/RefreshUserOperationExecutor.kt | 2 +- .../RefreshUserOperationExecutorTests.kt | 26 +++++++------ .../SubscriptionOperationExecutorTests.kt | 38 ++++++++++--------- 5 files changed, 45 insertions(+), 36 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt index 6aec9078c5..ba7e6631f7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt @@ -57,7 +57,9 @@ abstract class Operation(name: String) : Model() { */ var operationJwt: String? get() = getOptStringProperty("_jwt") - set(value) { setOptStringProperty("_jwt", value, ModelChangeTags.NO_PROPOGATE, true) } + set(value) { + setOptStringProperty("_jwt", value, ModelChangeTags.NO_PROPOGATE, true) + } /** * The external ID of the user who created this operation, stamped at enqueue time. @@ -65,7 +67,9 @@ abstract class Operation(name: String) : Model() { */ var operationExternalId: String? get() = getOptStringProperty("_externalId") - set(value) { setOptStringProperty("_externalId", value, ModelChangeTags.NO_PROPOGATE, true) } + set(value) { + setOptStringProperty("_externalId", value, ModelChangeTags.NO_PROPOGATE, true) + } /** * Whether this operation requires JWT authentication when identity verification is enabled. 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 be10f785ba..b6ea597c38 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 @@ -432,9 +432,10 @@ internal class OperationRepo( val useIV = _configModelStore.model.useIdentityVerification if (useIV) { - val toDiscard = queue.filter { - it.operation.operationExternalId == null && it.operation.requiresJwt - } + val toDiscard = + queue.filter { + it.operation.operationExternalId == null && it.operation.requiresJwt + } for (item in toDiscard) { Logging.debug("getNextOps: discarding anonymous op: ${item.operation}") queue.remove(item) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt index 8cd776dbbe..3cec8fba27 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt @@ -11,9 +11,9 @@ import com.onesignal.core.internal.operations.Operation import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IUserBackendService +import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService -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.operations.RefreshUserOperation diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt index f4c727557f..916f67dd17 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt @@ -338,12 +338,13 @@ class RefreshUserOperationExecutorTests : FunSpec({ val mockUserBackendService = mockk() coEvery { mockUserBackendService.getUser(appId, IdentityConstants.EXTERNAL_ID, previousUserExternalId, previousUserJwt) - } returns CreateUserResponse( - mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId, IdentityConstants.EXTERNAL_ID to previousUserExternalId), - PropertiesObject(), - listOf(), - null, - ) + } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId, IdentityConstants.EXTERNAL_ID to previousUserExternalId), + PropertiesObject(), + listOf(), + null, + ) val mockIdentityModelStore = MockHelper.identityModelStore() val mockIdentityModel = IdentityModel() @@ -385,12 +386,13 @@ class RefreshUserOperationExecutorTests : FunSpec({ val mockUserBackendService = mockk() coEvery { mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, null) - } returns CreateUserResponse( - mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), - PropertiesObject(), - listOf(), - null, - ) + } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(), + null, + ) val mockIdentityModelStore = MockHelper.identityModelStore() val mockIdentityModel = IdentityModel() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt index fb4cb91a78..157f78063b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt @@ -829,15 +829,16 @@ class SubscriptionOperationExecutorTests : mockConsistencyManager, ) - val createOp = CreateSubscriptionOperation( - appId, - remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken1", - SubscriptionStatus.SUBSCRIBED, - ) + val createOp = + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ) createOp.operationJwt = previousUserJwt createOp.operationExternalId = previousUserExternalId val operations = listOf(createOp) @@ -885,15 +886,16 @@ class SubscriptionOperationExecutorTests : mockConsistencyManager, ) - val createOp = CreateSubscriptionOperation( - appId, - remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken1", - SubscriptionStatus.SUBSCRIBED, - ) + val createOp = + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ) val operations = listOf(createOp) // When From 63c0d08d886bc4909ad59e91a092e98a9c8befec Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 23 Mar 2026 09:20:20 -0700 Subject: [PATCH 13/13] fix tests --- .../com/onesignal/core/internal/operations/OperationRepoTests.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 838cbe38a2..b92e3c58e5 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -628,6 +628,7 @@ class OperationRepoTests : FunSpec({ // When mocks.operationRepo.start() mocks.operationRepo.enqueue(operation1) + OSPrimaryCoroutineScope.waitForIdle() val job = launch { mocks.operationRepo.enqueueAndWait(operation2) }.also { yield() } mocks.operationRepo.enqueueAndWait(operation3) job.join()