Skip to content

Commit 97a3799

Browse files
nan-liclaude
andcommitted
feat(iv): logout IV behavior + RYW plumbing for IAM consistency
Logout under IV-required (Step A): - Add SubscriptionModel.isDisabledInternally — internal-only flag that tells SubscriptionModelStoreListener to suppress backend ops for the marked subscription. - LogoutHelperIvExtensions.switchUserIv: when ivBehaviorActive, mark the current push sub as internally disabled and switchUser with suppressBackendOperation=true. The new device-scoped (anonymous) user can't authenticate without a JWT, so we must NOT enqueue an anonymous LoginUserOperation that would block the queue. - LogoutHelper.switchUser: outer gate (newCodePathsRun) dispatches to the extension; legacy logout flow runs untouched for Phase 1/3. RYW plumbing (Step B): - CreateUserResponse.rywData (nullable) — backend sends ryw_token under IV so InAppMessagesManager can await user-record propagation before the IAM fetch. - JSONConverter.convertToCreateUserResponse parses ryw_token / ryw_delay. - LoginUserOperationExecutor.createUser sets RYW data in ConsistencyManager (gated on newCodePathsRun) so the IamFetchRywToken condition resolves and unblocks the IAM fetch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eb44594 commit 97a3799

8 files changed

Lines changed: 117 additions & 4 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.onesignal.core.internal.application.IApplicationService
1717
import com.onesignal.core.internal.application.impl.ApplicationService
1818
import com.onesignal.core.internal.config.ConfigModel
1919
import com.onesignal.core.internal.config.ConfigModelStore
20+
import com.onesignal.core.internal.config.impl.IdentityVerificationService
2021
import com.onesignal.core.internal.features.FeatureFlag
2122
import com.onesignal.core.internal.features.IFeatureManager
2223
import com.onesignal.core.internal.operations.IOperationRepo
@@ -235,6 +236,8 @@ internal class OneSignalImp(
235236
userSwitcher = userSwitcher,
236237
operationRepo = operationRepo,
237238
configModel = configModel,
239+
subscriptionModelStore = subscriptionModelStore,
240+
identityVerificationService = services.getService<IdentityVerificationService>(),
238241
lock = loginLogoutLock,
239242
)
240243
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package com.onesignal.user.internal
22

33
import com.onesignal.core.internal.config.ConfigModel
4+
import com.onesignal.core.internal.config.impl.IdentityVerificationService
45
import com.onesignal.core.internal.operations.IOperationRepo
56
import com.onesignal.user.internal.identity.IdentityModelStore
67
import com.onesignal.user.internal.operations.LoginUserOperation
8+
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
79

8-
class LogoutHelper(
10+
internal class LogoutHelper(
911
private val identityModelStore: IdentityModelStore,
1012
private val userSwitcher: UserSwitcher,
1113
private val operationRepo: IOperationRepo,
1214
private val configModel: ConfigModel,
15+
private val subscriptionModelStore: SubscriptionModelStore,
16+
private val identityVerificationService: IdentityVerificationService,
1317
private val lock: Any,
1418
) {
1519
internal data class LogoutEnqueueContext(
@@ -29,11 +33,26 @@ class LogoutHelper(
2933
return null
3034
}
3135

36+
// Outer gate: dispatch to IV extension only on new code paths. The extension's
37+
// inner gate (ivBehaviorActive) keeps Phase 3 users on the legacy logout flow.
38+
val handled =
39+
identityVerificationService.newCodePathsRun &&
40+
switchUserIv(
41+
userSwitcher,
42+
subscriptionModelStore,
43+
configModel,
44+
identityVerificationService.ivBehaviorActive,
45+
)
46+
if (handled) {
47+
// IV-required: subscription is internally disabled and the user-switch
48+
// suppressed backend op enqueue. Don't enqueue anonymous LoginUserOperation —
49+
// the anonymous user cannot authenticate without a JWT.
50+
return null
51+
}
52+
3253
// Create new device-scoped user (clears external ID)
3354
userSwitcher.createAndSwitchToNewUser()
3455

35-
// TODO: remove JWT Token for all future requests.
36-
3756
return LogoutEnqueueContext(configModel.appId, identityModelStore.model.onesignalId)
3857
}
3958
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.onesignal.user.internal
2+
3+
import com.onesignal.core.internal.config.ConfigModel
4+
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
5+
6+
/**
7+
* IV-specific behavior for [LogoutHelper]. The base-class call site dispatches via
8+
* `if (newCodePathsRun) switchUserIv(...) else legacyLogout()`; this extension
9+
* internally short-circuits on `ivBehaviorActive` to keep Phase 3 users (new code path on,
10+
* IV behavior off) on the legacy logout flow.
11+
*/
12+
13+
/**
14+
* Performs the IV-aware logout user-switch when [ivBehaviorActive] is true.
15+
*
16+
* Under IV-required, the new device-scoped (anonymous) user can't authenticate against
17+
* the backend without a JWT. To prevent the model-store listener from generating create-
18+
* subscription ops that would 401/permanently-block the queue:
19+
* 1. Mark the current push subscription as internally disabled.
20+
* 2. Switch users with [UserSwitcher.createAndSwitchToNewUser] in `suppressBackendOperation`
21+
* mode so subscription replacement does NOT propagate to listeners that would enqueue
22+
* backend ops.
23+
*
24+
* Returns `true` when IV-specific handling was applied (caller skips legacy enqueue),
25+
* or `false` when IV behavior is inactive (caller falls through to the legacy logout).
26+
*/
27+
internal fun switchUserIv(
28+
userSwitcher: UserSwitcher,
29+
subscriptionModelStore: SubscriptionModelStore,
30+
configModel: ConfigModel,
31+
ivBehaviorActive: Boolean,
32+
): Boolean {
33+
if (!ivBehaviorActive) return false
34+
35+
configModel.pushSubscriptionId?.let { pushSubId ->
36+
subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true }
37+
}
38+
userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true)
39+
return true
40+
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,11 @@ class CreateUserResponse(
8484
* The subscriptions for the user.
8585
*/
8686
val subscriptions: List<SubscriptionObject>,
87+
/**
88+
* Read-your-write data for IAM fetch consistency, when the backend supplies it.
89+
* Populated under Identity Verification so [com.onesignal.inAppMessages] can await
90+
* the user record's propagation before fetching IAMs. `null` for non-IV apps and
91+
* older backends that don't include `ryw_token` in the response.
92+
*/
93+
val rywData: com.onesignal.common.consistency.RywData? = null,
8794
)

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.onesignal.user.internal.backend.impl
22

3+
import com.onesignal.common.consistency.RywData
34
import com.onesignal.common.expandJSONArray
45
import com.onesignal.common.putJSONArray
56
import com.onesignal.common.putMap
@@ -8,6 +9,7 @@ import com.onesignal.common.safeBool
89
import com.onesignal.common.safeDouble
910
import com.onesignal.common.safeInt
1011
import com.onesignal.common.safeJSONObject
12+
import com.onesignal.common.safeLong
1113
import com.onesignal.common.safeString
1214
import com.onesignal.common.toMap
1315
import com.onesignal.user.internal.backend.CreateUserResponse
@@ -55,7 +57,12 @@ object JSONConverter {
5557
return@expandJSONArray null
5658
}
5759

58-
return CreateUserResponse(respIdentities, respProperties, respSubscriptions)
60+
// Backend may include `ryw_token` (and optional `ryw_delay`) under Identity Verification
61+
// so InAppMessagesManager can gate IAM fetch on read-your-write consistency.
62+
val rywToken = jsonObject.safeString("ryw_token")
63+
val rywData = rywToken?.let { RywData(it, jsonObject.safeLong("ryw_delay")) }
64+
65+
return CreateUserResponse(respIdentities, respProperties, respSubscriptions, rywData)
5966
}
6067

6168
fun convertToJSON(properties: PropertiesObject): JSONObject {

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.onesignal.user.internal.operations.impl.executors
22

33
import android.os.Build
44
import com.onesignal.common.AndroidUtils
5+
import com.onesignal.common.consistency.enums.IamFetchRywTokenKey
6+
import com.onesignal.common.consistency.models.IConsistencyManager
57
import com.onesignal.common.DeviceUtils
68
import com.onesignal.common.IDManager
79
import com.onesignal.common.NetworkUtils
@@ -51,6 +53,7 @@ internal class LoginUserOperationExecutor(
5153
private val _languageContext: ILanguageContext,
5254
private val _jwtTokenStore: JwtTokenStore,
5355
private val _identityVerificationService: IdentityVerificationService,
56+
private val _consistencyManager: IConsistencyManager,
5457
) : IOperationExecutor {
5558
override val operations: List<String>
5659
get() = listOf(LOGIN_USER)
@@ -235,6 +238,16 @@ internal class LoginUserOperationExecutor(
235238
backendSubscriptions.remove(backendSubscription)
236239
}
237240

241+
// Forward the create-user RYW data to ConsistencyManager so InAppMessagesManager
242+
// can fetch IAMs once the user record has propagated. Gated on newCodePathsRun:
243+
// Phase 1 users don't await RYW (legacy IAM fetch path), so storing it would be
244+
// a no-op anyway. Backend only sends rywData under IV.
245+
if (_identityVerificationService.newCodePathsRun) {
246+
response.rywData?.let { rywData ->
247+
_consistencyManager.setRywData(backendOneSignalId, IamFetchRywTokenKey.USER, rywData)
248+
}
249+
}
250+
238251
val wasPossiblyAnUpsert = identities.isNotEmpty()
239252
val followUpOperations =
240253
if (wasPossiblyAnUpsert) {

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ internal class SubscriptionModelStoreListener(
6363

6464
companion object {
6565
fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair<Boolean, SubscriptionStatus> {
66+
// Internal-disabled subscription (e.g. the post-logout anonymous user under IV)
67+
// must not generate backend ops; report as disabled+unsubscribe regardless of
68+
// optedIn/status. See [SubscriptionModel.isDisabledInternally].
69+
if (model.isDisabledInternally) {
70+
return Pair(false, SubscriptionStatus.UNSUBSCRIBE)
71+
}
72+
6673
val status: SubscriptionStatus
6774
val enabled: Boolean
6875

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,23 @@ class SubscriptionModel : Model() {
9292
setBooleanProperty(::optedIn.name, value)
9393
}
9494

95+
/**
96+
* Internal-only flag (not surfaced via the public API) used to suppress backend
97+
* subscription operations for this model. Set to `true` on logout under Identity
98+
* Verification: the new device-scoped (anonymous) user can't authenticate without
99+
* a JWT, so the SDK must not generate create-subscription ops for it. The
100+
* [SubscriptionModelStoreListener] honors this flag by short-circuiting to
101+
* `(enabled = false, status = UNSUBSCRIBE)` regardless of [optedIn] / [status].
102+
*
103+
* Defaults to `false`. On the next login, [com.onesignal.user.internal.UserSwitcher]
104+
* creates a fresh model that does not carry this flag, restoring the real state.
105+
*/
106+
var isDisabledInternally: Boolean
107+
get() = getBooleanProperty(::isDisabledInternally.name) { false }
108+
set(value) {
109+
setBooleanProperty(::isDisabledInternally.name, value)
110+
}
111+
95112
var type: SubscriptionType
96113
get() = getEnumProperty(::type.name)
97114
set(value) {

0 commit comments

Comments
 (0)