Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions OneSignalSDK/detekt/detekt-baseline-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,6 @@
<ID>ForbiddenComment:HttpClient.kt$HttpClient$// TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT?</ID>
<ID>ForbiddenComment:IPreferencesService.kt$PreferenceOneSignalKeys$* (String) The serialized IAMs TODO: This isn't currently used, determine if actually needed for cold start IAM fetch delay</ID>
<ID>ForbiddenComment:IUserBackendService.kt$IUserBackendService$// TODO: Change to send only the push subscription, optimally</ID>
<ID>ForbiddenComment:LoginHelper.kt$LoginHelper$// TODO: Set JWT Token for all future requests.</ID>
<ID>ForbiddenComment:LogoutHelper.kt$LogoutHelper$// TODO: remove JWT Token for all future requests.</ID>
<ID>ForbiddenComment:OperationRepo.kt$OperationRepo$// TODO: Need to provide callback for app to reset JWT. For now, fail with no retry.</ID>
<ID>ForbiddenComment:ParamsBackendService.kt$ParamsBackendService$// TODO: New</ID>
<ID>ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO after we remove IAM from being an activity window we may be able to remove this handler</ID>
<ID>ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO improve this method</ID>
<ID>ForbiddenComment:PermissionsViewModel.kt$PermissionsViewModel.Companion$// TODO this will be removed once the handler is deleted</ID>
Expand Down Expand Up @@ -217,7 +213,7 @@
<ID>LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, )</ID>
<ID>LongParameterList:IParamsBackendService.kt$ParamsObject$( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, var notificationChannels: JSONArray? = null, var firebaseAnalytics: Boolean? = null, var restoreTTLFilter: Boolean? = null, var clearGroupOnSummaryClick: Boolean? = null, var receiveReceiptEnabled: Boolean? = null, var disableGMSMissingPrompt: Boolean? = null, var unsubscribeWhenNotificationsDisabled: Boolean? = null, var locationShared: Boolean? = null, var requiresUserPrivacyConsent: Boolean? = null, var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, )</ID>
<ID>LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, jwt: String? = null, )</ID>
<ID>LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, )</ID>
<ID>LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, private val _consistencyManager: IConsistencyManager, )</ID>
<ID>LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, )</ID>
<ID>LongParameterList:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _jwtTokenStore: JwtTokenStore, )</ID>
<ID>LongParameterList:SubscriptionObject.kt$SubscriptionObject$( val id: String? = null, val type: SubscriptionObjectType? = null, val token: String? = null, val enabled: Boolean? = null, val notificationTypes: Int? = null, val sdk: String? = null, val deviceModel: String? = null, val deviceOS: String? = null, val rooted: Boolean? = null, val netType: Int? = null, val carrier: String? = null, val appVersion: String? = null, )</ID>
Comment on lines 213 to 219

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The PR correctly updates the LongParameterList baseline entry for LoginUserOperationExecutor to include _consistencyManager: IConsistencyManager, but omits the required ConstructorParameterNaming suppression entry for that parameter. All 10 other underscore-prefixed constructor parameters in LoginUserOperationExecutor have individual ConstructorParameterNaming entries, and both peer executors (SubscriptionOperationExecutor, UpdateUserOperationExecutor) have the analogous entry for their own _consistencyManager parameters. Add the missing baseline entry to prevent the detekt CI check from failing.

Extended reasoning...

What the bug is and how it manifests

The detekt static-analysis tool enforces a ConstructorParameterNaming rule that flags underscore-prefixed constructor parameters (e.g. private val _consistencyManager). These violations are suppressed by adding per-parameter entries to the detekt baseline XML file. This PR adds private val _consistencyManager: IConsistencyManager as a new constructor parameter to LoginUserOperationExecutor and correctly updates the LongParameterList baseline entry to reflect the new parameter, but does not add the corresponding ConstructorParameterNaming entry. When detekt runs in CI baseline-check mode, any violation not present in the baseline XML is treated as a new unbaselined finding and fails the build.

The specific code path that triggers it

When the CI pipeline runs ./gradlew detekt (or the equivalent baseline-check task) against LoginUserOperationExecutor.kt, detekt scans the constructor and finds private val _consistencyManager: IConsistencyManager. It raises a ConstructorParameterNaming violation. The baseline checker compares this against detekt-baseline-core.xml and finds no matching suppression entry, so it reports one new unbaselined violation and returns a non-zero exit code.

Why existing code does not prevent it

The LongParameterList and ConstructorParameterNaming rules are tracked independently in the XML, under separate <ID> entries. Updating the LongParameterList entry (which lists all parameters in a single string) has no effect on the ConstructorParameterNaming rule, which requires one entry per parameter. The two-rule pattern is well-established in this codebase: every other underscore-prefixed parameter in LoginUserOperationExecutor (_application, _configModelStore, _deviceService, _identityModelStore, _identityOperationExecutor, _jwtTokenStore, _languageContext, _propertiesModelStore, _subscriptionsModelStore, _userBackend) has its own ConstructorParameterNaming entry. The omission of _consistencyManager is an oversight.

What the impact would be

The detekt CI check will fail after this PR is merged, blocking the release build pipeline. There is no functional impact on SDK behavior; this is purely a build-tooling issue. The build failure is deterministic and will reproduce on every CI run until the entry is added.

How to fix it

Add the following entry to the <CurrentIssues> section of OneSignalSDK/detekt/detekt-baseline-core.xml, in alphabetical order among the other ConstructorParameterNaming:LoginUserOperationExecutor entries:

<ID>ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _consistencyManager: IConsistencyManager</ID>

Step-by-step proof

  1. PR adds private val _consistencyManager: IConsistencyManager to LoginUserOperationExecutor's constructor.
  2. The LongParameterList baseline entry is updated to include _consistencyManager — this is correct.
  3. Detekt runs ConstructorParameterNaming on the constructor; all 10 existing underscore-prefixed parameters are suppressed via baseline entries.
  4. Detekt finds _consistencyManager with no matching ConstructorParameterNaming baseline entry.
  5. Detekt reports one new unbaselined violation; CI baseline-check mode fails the build.
  6. For comparison, SubscriptionOperationExecutor and UpdateUserOperationExecutor both already have ConstructorParameterNaming entries for their _consistencyManager parameters, confirming the expected pattern.

Expand Down
8 changes: 4 additions & 4 deletions OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
<ID>LongMethod:InAppRepository.kt$InAppRepository$override suspend fun cleanCachedInAppMessages()</ID>
<ID>LongParameterList:IInAppBackendService.kt$IInAppBackendService$( appId: String, subscriptionId: String, variantId: String?, messageId: String, clickId: String?, isFirstClick: Boolean, )</ID>
<ID>LongParameterList:InAppDisplayer.kt$InAppDisplayer$( private val _applicationService: IApplicationService, private val _lifecycle: IInAppLifecycleService, private val _promptFactory: IInAppMessagePromptFactory, private val _backend: IInAppBackendService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _time: ITime, )</ID>
<ID>LongParameterList:InAppMessagesManager.kt$InAppMessagesManager$( private val _applicationService: IApplicationService, private val _sessionService: ISessionService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _userManager: IUserManager, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _outcomeEventsController: IOutcomeEventsController, private val _state: InAppStateService, private val _prefs: IInAppPreferencesController, private val _repository: IInAppRepository, private val _backend: IInAppBackendService, private val _triggerController: ITriggerController, private val _triggerModelStore: TriggerModelStore, private val _displayer: IInAppDisplayer, private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, )</ID>
<ID>LongParameterList:InAppMessagesManager.kt$InAppMessagesManager$( private val _applicationService: IApplicationService, private val _sessionService: ISessionService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _userManager: IUserManager, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _outcomeEventsController: IOutcomeEventsController, private val _state: InAppStateService, private val _prefs: IInAppPreferencesController, private val _repository: IInAppRepository, private val _backend: IInAppBackendService, private val _triggerController: ITriggerController, private val _triggerModelStore: TriggerModelStore, private val _displayer: IInAppDisplayer, private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, private val _jwtTokenStore: JwtTokenStore, )</ID>
<ID>LongParameterList:OneSignalAnimate.kt$OneSignalAnimate$( view: View, deltaFromY: Float, deltaToY: Float, duration: Int, interpolator: Interpolator?, animCallback: Animation.AnimationListener?, )</ID>
<ID>MagicNumber:DraggableRelativeLayout.kt$DraggableRelativeLayout$3</ID>
<ID>MagicNumber:DraggableRelativeLayout.kt$DraggableRelativeLayout$3000</ID>
Comment on lines 68 to 74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The PR adds _jwtTokenStore: JwtTokenStore as a new constructor parameter to InAppMessagesManager and correctly updates the LongParameterList baseline entry, but omits the corresponding ConstructorParameterNaming baseline entry. All 19 other underscore-prefixed constructor parameters have their own ConstructorParameterNaming suppression entries; _jwtTokenStore is the sole omission. Add the missing entry: ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _jwtTokenStore: JwtTokenStore.

Extended reasoning...

What the bug is

The PR introduces private val _jwtTokenStore: JwtTokenStore as a new constructor parameter to InAppMessagesManager. Detekt enforces a ConstructorParameterNaming rule that flags underscore-prefixed constructor parameters. All 19 existing underscore-prefixed constructor parameters in InAppMessagesManager have their own ConstructorParameterNaming suppression entries in detekt-baseline-in-app-messages.xml (from _applicationService through _userManager), but the new _jwtTokenStore parameter has no corresponding entry.

The specific code path that triggers it

When detekt runs in CI, it scans InAppMessagesManager.kt and finds private val _jwtTokenStore: JwtTokenStore in the constructor. It raises a ConstructorParameterNaming violation. Because no baseline entry suppresses this violation, it is treated as a new unbaselined finding. Detekt baseline-check mode fails the build on any new unbaselined violations.

Why existing code does not prevent it

The PR author correctly updated the LongParameterList baseline entry (line 71) to include _jwtTokenStore. However, ConstructorParameterNaming is a separate rule requiring its own per-parameter baseline entry. The two rules are tracked independently in the XML. The omission is easy to miss because LongParameterList and ConstructorParameterNaming entries appear in different sections of the file.

What the impact would be

The detekt CI check will fail when the PR is merged, blocking the release build. No functional behavior is affected; this is purely a build-tooling issue.

How to fix it

Add the following entry to the <CurrentIssues> section of detekt-baseline-in-app-messages.xml, alphabetically between the _influenceManager and _languageContext entries:

<ID>ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _jwtTokenStore: JwtTokenStore</ID>

Step-by-step proof

  1. PR adds private val _jwtTokenStore: JwtTokenStore to InAppMessagesManager constructor.
  2. Detekt scans the constructor; all 19 existing underscore-prefixed parameters are suppressed via baseline entries.
  3. Detekt finds _jwtTokenStore with no baseline entry.
  4. Detekt reports one new ConstructorParameterNaming violation.
  5. CI baseline check: new violation not in baseline → build fails.

Expand Down Expand Up @@ -103,12 +103,12 @@
<ID>ReturnCount:DraggableRelativeLayout.kt$DraggableRelativeLayout.&lt;no name provided&gt;$override fun clampViewPositionVertical( child: View, top: Int, dy: Int, ): Int</ID>
<ID>ReturnCount:DynamicTriggerController.kt$DynamicTriggerController$fun dynamicTriggerShouldFire(trigger: Trigger): Boolean</ID>
<ID>ReturnCount:InAppBackendService.kt$InAppBackendService$override suspend fun getIAMData( appId: String, messageId: String, variantId: String?, ): GetIAMDataResponse</ID>
<ID>ReturnCount:InAppBackendService.kt$InAppBackendService$private suspend fun attemptFetchWithRetries( baseUrl: String, rywData: RywData, sessionDurationProvider: () -&gt; Long, ): List&lt;InAppMessage&gt;?</ID>
<ID>ReturnCount:InAppBackendService.kt$InAppBackendService$private suspend fun attemptFetchWithRetries( baseUrl: String, rywData: RywData, sessionDurationProvider: () -&gt; Long, jwt: String? = null, ): List&lt;InAppMessage&gt;?</ID>
<ID>ReturnCount:InAppHydrator.kt$InAppHydrator$fun hydrateIAMMessageContent(jsonObject: JSONObject): InAppMessageContent?</ID>
<ID>ReturnCount:InAppMessage.kt$InAppMessage$private fun parseEndTimeJson(json: JSONObject): Date?</ID>
<ID>ReturnCount:InAppMessagePreviewHandler.kt$InAppMessagePreviewHandler$private fun inAppPreviewPushUUID(payload: JSONObject): String?</ID>
<ID>ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$override fun onMessageWasDisplayed(message: InAppMessage)</ID>
<ID>ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData)</ID>
<ID>ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData?)</ID>
<ID>ReturnCount:TriggerController.kt$TriggerController$override fun evaluateMessageTriggers(message: InAppMessage): Boolean</ID>
<ID>ReturnCount:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection&lt;String&gt;, ): Boolean</ID>
<ID>ReturnCount:TriggerController.kt$TriggerController$override fun messageHasOnlyDynamicTriggers(message: InAppMessage): Boolean</ID>
Expand All @@ -124,7 +124,7 @@
<ID>TooManyFunctions:InAppBackendService.kt$InAppBackendService : IInAppBackendService</ID>
<ID>TooManyFunctions:InAppMessage.kt$InAppMessage : IInAppMessage</ID>
<ID>TooManyFunctions:InAppMessageView.kt$InAppMessageView</ID>
<ID>TooManyFunctions:InAppMessagesManager.kt$InAppMessagesManager : IInAppMessagesManagerIStartableServiceISubscriptionChangedHandlerISingletonModelStoreChangeHandlerIInAppLifecycleEventHandlerITriggerHandlerISessionLifecycleHandlerIApplicationLifecycleHandler</ID>
<ID>TooManyFunctions:InAppMessagesManager.kt$InAppMessagesManager : IInAppMessagesManagerIStartableServiceISubscriptionChangedHandlerISingletonModelStoreChangeHandlerIInAppLifecycleEventHandlerITriggerHandlerISessionLifecycleHandlerIApplicationLifecycleHandlerIJwtUpdateListener</ID>
<ID>TooManyFunctions:TriggerController.kt$TriggerController : ITriggerControllerIModelStoreChangeHandler</ID>
<ID>TooManyFunctions:WebViewManager.kt$WebViewManager : IActivityLifecycleHandler</ID>
<ID>UndocumentedPublicClass:TriggerModel.kt$TriggerModel : Model</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,21 @@ class ConsistencyManager : IConsistencyManager {
}

override suspend fun resolveConditionsWithID(id: String) {
val completedConditions = mutableListOf<Pair<ICondition, CompletableDeferred<RywData?>>>()
mutex.withLock {
val completedConditions = mutableListOf<Pair<ICondition, CompletableDeferred<RywData?>>>()

for ((condition, deferred) in conditions) {
if (condition.id == id) {
if (!deferred.isCompleted) {
deferred.complete(null)
for ((condition, deferred) in conditions) {
if (condition.id == id) {
if (!deferred.isCompleted) {
deferred.complete(null)
}
completedConditions.add(Pair(condition, deferred))
}
}
completedConditions.add(Pair(condition, deferred))
}

// Remove completed conditions from the list
conditions.removeAll(completedConditions)
// Remove completed conditions from the list
conditions.removeAll(completedConditions)
}
Comment thread
claude[bot] marked this conversation as resolved.
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.onesignal.core.internal.config.impl
import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.common.modeling.ModelChangedArgs
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.operations.IOperationRepo
Expand Down Expand Up @@ -42,21 +43,25 @@ internal class IdentityVerificationService(

val useIV = model.useIdentityVerification

var jwtInvalidatedExternalId: String? = null
if (useIV == true) {
Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations")
_operationRepo.removeOperationsWithoutExternalId()
suspendifyOnIO {
_operationRepo.awaitInitialized()

val externalId = _identityModelStore.model.externalId
if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) {
Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, will fire invalidated event after queue wake")
jwtInvalidatedExternalId = externalId
var jwtInvalidatedExternalId: String? = null
if (useIV == true) {
Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations")
_operationRepo.removeOperationsWithoutExternalId()

val externalId = _identityModelStore.model.externalId
if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) {
Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, will fire invalidated event after queue wake")
jwtInvalidatedExternalId = externalId
}
}
}

_operationRepo.forceExecuteOperations()
_operationRepo.forceExecuteOperations()

jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) }
jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) }
}
Comment on lines 44 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In the PR's refactored IdentityVerificationService.onModelReplaced(), useIV is correctly captured before the suspendifyOnIO block but val externalId = _identityModelStore.model.externalId is read inside the coroutine after awaitInitialized() returns, creating a race window where a concurrent OneSignal.login("otherUser") replaces the identity model and causes fireJwtInvalidated to fire for the wrong user. Fix: capture externalId alongside useIV before the suspendifyOnIO block, matching how useIV is already captured from the model parameter.

Extended reasoning...

What the bug is and how it manifests

The PR wraps the entire HYDRATE handler body in suspendifyOnIO { _operationRepo.awaitInitialized() ... } to fix a purge-before-initialization race. While useIV = model.useIdentityVerification is correctly captured from the model parameter before the coroutine (line 44), the read val externalId = _identityModelStore.model.externalId (line 54) was moved inside the coroutine, after awaitInitialized() suspends. The _identityModelStore.model reference is a live reference to the current model, not the model at HYDRATE time, so it can change underneath the coroutine while it is suspended.

The specific code path that triggers it

awaitInitialized() suspends the coroutine while OperationRepo.loadSavedOperations() loads persisted operations from disk — a window that can be tens to hundreds of milliseconds on slower devices. During this window, if the developer calls OneSignal.login("userB"), the SDK replaces the IdentityModel in _identityModelStore. When awaitInitialized() resumes, _identityModelStore.model.externalId returns userB's externalId rather than the one present at HYDRATE time.

Why existing code does not prevent it

Capturing useIV from the model constructor parameter (which is a snapshot passed at HYDRATE time) is correct and safe. However, _identityModelStore.model is a live property getter that always returns the current model. There is no snapshot or copy made of the externalId before the suspension point; the read is simply deferred into the coroutine body, past the only concurrency boundary introduced by this PR.

What the impact would be

Two failure modes: (1) If userB has a valid JWT, jwtInvalidatedExternalId remains null — the original user who triggered the beta migration path never receives fireJwtInvalidated, their operations remain gated on a JWT event that will never arrive. (2) If userB has no JWT, fireJwtInvalidated("userB") fires; the developer calls updateUserJwt for userB while the original user's operations remain blocked indefinitely. This affects the beta migration path (first app open after IV is enabled) with a concurrent login.

How to fix it

Capture externalId at the same point useIV is captured, before the suspendifyOnIO block:

val useIV = model.useIdentityVerification
val externalId = _identityModelStore.model.externalId  // capture before coroutine
suspendifyOnIO {
    _operationRepo.awaitInitialized()
    if (useIV == true) {
        _operationRepo.removeOperationsWithoutExternalId()
        if (externalId \!= null && _jwtTokenStore.getJwt(externalId) == null) {
            jwtInvalidatedExternalId = externalId
        }
    }
    ...
}

Step-by-step proof

  1. App starts with IV=true; config HYDRATE arrives. onModelReplaced fires with useIV=true. The coroutine is launched and suspends at awaitInitialized().
  2. awaitInitialized() is pending (loading saved ops from disk). During this window, the developer calls OneSignal.login("userB").
  3. The SDK replaces _identityModelStore.model with userB's IdentityModel; _identityModelStore.model.externalId now returns "userB".
  4. awaitInitialized() completes. The coroutine resumes and reads val externalId = _identityModelStore.model.externalId"userB".
  5. If userB has no JWT: fireJwtInvalidated("userB") fires. Developer provides JWT for userB. The original user's ops are never unblocked.
  6. If userB has a valid JWT: jwtInvalidatedExternalId stays null. The original user who triggered the IV migration never gets the event. Operations for the original user remain blocked forever.

}

override fun onModelUpdated(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,7 @@ internal class OperationRepo(
_operationModelStore.remove(it.operation.id)
it.waiter?.wake(false)
}
if (toRemove.isNotEmpty()) {
Logging.debug("OperationRepo: removed ${toRemove.size} anonymous operations (no externalId)")
}
Logging.debug("OperationRepo: removeOperationsWithoutExternalId removed ${toRemove.size} of ${toRemove.size + queue.size} operations")

// IV=ON never transfers anonymous state; clear existingOnesignalId so
// the executor takes the createUser (upsert) path.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,45 @@ internal open class UserManager(
private val jwtInvalidatedAppCallbackScope =
CoroutineScope(SupervisorJob() + Dispatchers.Default)

private val jwtInvalidatedLock = Any()
private var pendingJwtInvalidatedExternalId: String? = null

fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
jwtInvalidatedNotifier.subscribe(listener)
val pendingExternalId: String?
synchronized(jwtInvalidatedLock) {
jwtInvalidatedNotifier.subscribe(listener)
pendingExternalId = pendingJwtInvalidatedExternalId
pendingJwtInvalidatedExternalId = null
}
pendingExternalId?.let {
listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(it))
}
}

fun removeJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
jwtInvalidatedNotifier.unsubscribe(listener)
}
Comment thread
claude[bot] marked this conversation as resolved.

/**
* Schedules [IUserJwtInvalidatedListener] delivery on a background dispatcher so HYDRATE and
* operation-repo paths can finish internal work before app code runs.
* Fires [IUserJwtInvalidatedListener] to all subscribers asynchronously so the caller
* (e.g. OperationRepo) is not blocked by developer code. If no listeners are registered yet
* (e.g. during SDK init), stores the externalId so it can be replayed when the first
* listener is added via [addJwtInvalidatedListener].
*/
fun fireJwtInvalidated(externalId: String) {
jwtInvalidatedAppCallbackScope.launch {
runCatching {
jwtInvalidatedNotifier.fire { listener ->
listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId))
synchronized(jwtInvalidatedLock) {
if (jwtInvalidatedNotifier.hasSubscribers) {
jwtInvalidatedAppCallbackScope.launch {
runCatching {
jwtInvalidatedNotifier.fire { listener ->
listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId))
}
}.onFailure {
Logging.warn("Failed to deliver JWT invalidated event for externalId=$externalId", it)
}
}
}.onFailure {
Logging.warn("Failed to deliver JWT invalidated event for externalId=$externalId", it)
} else {
pendingJwtInvalidatedExternalId = externalId
}
}
}
Expand Down Expand Up @@ -297,7 +316,11 @@ internal open class UserManager(
override fun onModelReplaced(
model: IdentityModel,
tag: String,
) { }
) {
synchronized(jwtInvalidatedLock) {
pendingJwtInvalidatedExternalId = null
}
}

override fun onModelUpdated(
args: ModelChangedArgs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,8 @@ class CreateUserResponse(
* The subscriptions for the user.
*/
val subscriptions: List<SubscriptionObject>,
/**
* Read-your-write consistency data returned by the backend, if any.
*/
val rywData: RywData? = null,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.onesignal.user.internal.backend.impl

import com.onesignal.common.consistency.RywData
import com.onesignal.common.expandJSONArray
import com.onesignal.common.putJSONArray
import com.onesignal.common.putMap
Expand All @@ -8,6 +9,7 @@ import com.onesignal.common.safeBool
import com.onesignal.common.safeDouble
import com.onesignal.common.safeInt
import com.onesignal.common.safeJSONObject
import com.onesignal.common.safeLong
import com.onesignal.common.safeString
import com.onesignal.common.toMap
import com.onesignal.user.internal.backend.CreateUserResponse
Expand Down Expand Up @@ -55,7 +57,11 @@ object JSONConverter {
return@expandJSONArray null
}

return CreateUserResponse(respIdentities, respProperties, respSubscriptions)
val rywToken = jsonObject.safeString("ryw_token")
val rywDelay = jsonObject.safeLong("ryw_delay")
val rywData = if (rywToken != null) RywData(rywToken, rywDelay) else null

return CreateUserResponse(respIdentities, respProperties, respSubscriptions, rywData)
}

fun convertToJSON(properties: PropertiesObject): JSONObject {
Expand Down
Loading
Loading