Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import com.onesignal.user.internal.jwt.JwtRequirement
* Consumers (e.g. OperationRepo) wire post-HYDRATE behavior via [setOnJwtConfigHydratedHandler];
* the handler fires once per HYDRATE with `ivRequired = useIdentityVerification == REQUIRED`.
*/
internal class IdentityVerificationService(
class IdentityVerificationService(
private val featureManager: IFeatureManager,
private val configModelStore: ConfigModelStore,
) : IStartableService, ISingletonModelStoreChangeHandler<ConfigModel> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.onesignal.core.internal.features
/**
* Controls when remote config changes for a feature are applied.
*/
internal enum class FeatureActivationMode {
enum class FeatureActivationMode {
/**
* Apply config changes immediately during the current app run.
*/
Expand All @@ -20,7 +20,7 @@ internal enum class FeatureActivationMode {
*
* [key] values are **lowercase** strings as returned from remote config / Turbine `features` arrays.
*/
internal enum class FeatureFlag(
enum class FeatureFlag(
val key: String,
val activationMode: FeatureActivationMode
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.debug.internal.logging.Logging
import kotlinx.serialization.json.JsonObject

internal interface IFeatureManager {
interface IFeatureManager {
fun isEnabled(feature: FeatureFlag): Boolean

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ internal class OperationRepo(
operation: Operation,
flush: Boolean,
) {
if (shouldSuppressAnonymousOp(operation)) return

Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)")

operation.id = UUID.randomUUID().toString()
Expand All @@ -147,6 +149,8 @@ internal class OperationRepo(
operation: Operation,
flush: Boolean,
): Boolean {
if (shouldSuppressAnonymousOp(operation)) return false

Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)")

operation.id = UUID.randomUUID().toString()
Expand All @@ -157,6 +161,26 @@ internal class OperationRepo(
return waiter.waitForWake()
}

/**
* Drop anonymous (externalId == null) operations at enqueue time when IV is required —
* they cannot be authenticated and would otherwise sit in the queue forever, blocked by
* `hasValidJwtIfRequired`. LoginUserOperation is exempt because it's enqueued
* intentionally during logout and purged later by [removeOperationsWithoutExternalId]
* if needed. Outer-gated on `newCodePathsRun` so Phase 1 customers stay byte-for-byte
* on the legacy enqueue path.
*/
private fun shouldSuppressAnonymousOp(op: Operation): Boolean {
if (!_identityVerificationService.newCodePathsRun) return false
if (op is LoginUserOperation) return false
val suppress =
_configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED &&
op.externalId == null
if (suppress) {
Logging.debug("OperationRepo: suppressing anonymous op under IV-required: $op")
}
return suppress
}

/**
* Only used inside this class, adds OperationQueueItem to queue
* WARNING: Never set flush=true until budget rules are added, even for internal use!
Expand Down Expand Up @@ -272,6 +296,18 @@ internal class OperationRepo(
val anonymous = queue.filter { it.operation.externalId == null }
anonymous.forEach { it.waiter?.wake(false) }
queue.removeAll(anonymous)
// IV=ON never transfers anonymous state; clear existingOnesignalId so the
// executor takes the createUser (upsert) path. The merge-anon-into-identified
// path can't dispatch under IV — anon user creation requires a JWT-less call
// the backend rejects — and a stale local-id existingOnesignalId would leave
// canStartExecute=false forever, deadlocking the queue.
queue.forEach { item ->
val op = item.operation
if (op is LoginUserOperation && op.existingOnesignalId != null) {
Logging.debug("OperationRepo: cleared existingOnesignalId on LoginUserOperation (was ${op.existingOnesignalId})")
op.existingOnesignalId = null
}
}
Logging.debug("OperationRepo: removeOperationsWithoutExternalId removed ${anonymous.size} of ${anonymous.size + queue.size} operations")
anonymous.map { it.operation.id }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.application.impl.ApplicationService
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.features.FeatureFlag
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.operations.IOperationRepo
Expand Down Expand Up @@ -235,6 +236,8 @@ internal class OneSignalImp(
userSwitcher = userSwitcher,
operationRepo = operationRepo,
configModel = configModel,
subscriptionModelStore = subscriptionModelStore,
identityVerificationService = services.getService<IdentityVerificationService>(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

please make this lazy

lock = loginLogoutLock,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.jwt.JwtRequirement
import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.LoginUserOperation

Expand Down Expand Up @@ -54,8 +55,18 @@ internal class LoginHelper(
}

val newOneSignalId = identityModelStore.model.onesignalId
// Under IV-required, the merge-anon-into-identified path can't dispatch — the
// anon user was never created server-side (no JWT) so the local-id reference
// would deadlock LoginUserOperation.canStartExecute. Skip the link entirely so
// the executor takes the createUser (upsert) path.
val existingOneSignalId =
if (currentExternalId == null) currentOneSignalId else null
if (configModel.useIdentityVerification == JwtRequirement.REQUIRED) {
null
} else if (currentExternalId == null) {
currentOneSignalId
} else {
null
}

return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package com.onesignal.user.internal

import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.operations.LoginUserOperation
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore

class LogoutHelper(
internal class LogoutHelper(
private val identityModelStore: IdentityModelStore,
private val userSwitcher: UserSwitcher,
private val operationRepo: IOperationRepo,
private val configModel: ConfigModel,
private val subscriptionModelStore: SubscriptionModelStore,
private val identityVerificationService: IdentityVerificationService,
private val lock: Any,
) {
internal data class LogoutEnqueueContext(
Expand All @@ -29,11 +33,26 @@ class LogoutHelper(
return null
}

// Outer gate: dispatch to IV extension only on new code paths. The extension's
// inner gate (ivBehaviorActive) keeps Phase 3 users on the legacy logout flow.
val handled =
identityVerificationService.newCodePathsRun &&
switchUserIv(
userSwitcher,
subscriptionModelStore,
configModel,
identityVerificationService.ivBehaviorActive,
)
if (handled) {
// IV-required: subscription is internally disabled and the user-switch
// suppressed backend op enqueue. Don't enqueue anonymous LoginUserOperation —
// the anonymous user cannot authenticate without a JWT.
return null
}

// Create new device-scoped user (clears external ID)
userSwitcher.createAndSwitchToNewUser()

// TODO: remove JWT Token for all future requests.

return LogoutEnqueueContext(configModel.appId, identityModelStore.model.onesignalId)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.onesignal.user.internal

import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore

/**
* IV-specific behavior for [LogoutHelper]. The base-class call site dispatches via
* `if (newCodePathsRun) switchUserIv(...) else legacyLogout()`; this extension
* internally short-circuits on `ivBehaviorActive` to keep Phase 3 users (new code path on,
* IV behavior off) on the legacy logout flow.
*/

/**
* Performs the IV-aware logout user-switch when [ivBehaviorActive] is true.
*
* Order matters and is intentional (mirrors reference branches #2599 and #2613):
* 1. Set `isDisabledInternally = true` on the CURRENT push subscription with the default
* NORMAL tag. This propagates through [com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener.getUpdateOperation],
* which reads the still-current OLD identity and enqueues an `UpdateSubscriptionOperation`
* carrying `(enabled = false, status = UNSUBSCRIBE)` — letting the backend know this device's
* push subscription is unsubscribing as the user logs out. The OLD user's JWT is still valid
* here, so the op dispatches successfully.
* 2. Switch to the new device-scoped (anonymous) user via
* [UserSwitcher.createAndSwitchToNewUser] with `suppressBackendOperation = true` so the
* subscription replacement does NOT propagate to listeners — the new anonymous user has no
* JWT and any create-subscription op for it would 401 indefinitely.
*
* Returns `true` when IV-specific handling was applied (caller skips legacy enqueue),
* or `false` when IV behavior is inactive (caller falls through to the legacy logout).
*/
internal fun switchUserIv(
userSwitcher: UserSwitcher,
subscriptionModelStore: SubscriptionModelStore,
configModel: ConfigModel,
ivBehaviorActive: Boolean,
): Boolean {
if (!ivBehaviorActive) return false

configModel.pushSubscriptionId?.let { pushSubId ->
subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true }
}
userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true)
return true
Comment on lines +37 to +43
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 isDisabledInternally = true setter in switchUserIv (line 36) is misplaced and breaks both bugs at once: (1) it fires before createAndSwitchToNewUser, so SubscriptionModelStoreListener enqueues an UpdateSubscriptionOperation against the OLD user (with their still-valid JWT) on every IV logout — directly contradicting the docstring's claim that the flag prevents backend ops. (2) UserSwitcher.createAndSwitchToNewUser builds a fresh SubscriptionModel that does not copy isDisabledInternally, then clear/replaceAlls the store, so the flag never lands on the new anonymous user's model — defeating the protection promised by the SubscriptionModel.isDisabledInternally KDoc. Fix is twofold: set the flag with setBooleanProperty(name, value, ModelChangeTags.NO_PROPOGATE) (or skip the OLD-model write) AND copy isDisabledInternally into the new SubscriptionModel inside UserSwitcher.createAndSwitchToNewUser.

Extended reasoning...

Two bugs from one misplaced line

LogoutHelperIvExtensions.kt:33-39 does, in order:

configModel.pushSubscriptionId?.let { pushSubId ->
    subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true }   // (A)
}
userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true)                // (B)

The isDisabledInternally setter (SubscriptionModel.kt:108-111) calls setBooleanProperty(::isDisabledInternally.name, value) — and Model.setBooleanProperty (Model.kt:234-244) defaults to tag = ModelChangeTags.NORMAL. So (A) fires a NORMAL-tagged change.

Bug 1 — fires an UpdateSubscriptionOperation against the OLD user on every IV logout

The change propagates Model.notifyChangedModelStore.onChangedModelStoreListener.onModelUpdated (ModelStoreListener.kt:44-56), which lets NORMAL through and calls getUpdateOperation. SubscriptionModelStoreListener.getUpdateOperation builds an UpdateSubscriptionOperation from _identityModelStore.model.onesignalId / .externalIdthe OLD user's identity, because (B) has not yet replaced the identity model. The new short-circuit at SubscriptionModelStoreListener.kt:64-69 makes this op carry (enabled = false, status = UNSUBSCRIBE).

This contradicts the file's own docstring, which states the flag exists "to prevent the model-store listener from generating create-subscription ops that would 401". On every IV logout it actually generates an unsubscribe op for the user being logged out — the OLD user's JWT is still in JwtTokenStore, so the op succeeds and persists at the backend. If the JWT was already invalid (a plausible logout trigger), the op 401s, OperationRepoIvExtensions calls jwtTokenStore.invalidateJwt(externalId) for the just-departed user, and the developer gets an onJwtInvalidated callback for a user they explicitly logged out.

Bug 2 — the flag never lands on the new anonymous user

Immediately after (A), step (B) calls UserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true). Inside that function (UserSwitcher.kt:60-88):

val newPushSubscription = SubscriptionModel().apply {
    id = currentPushSubscription?.id ?: idManager.createLocalId()
    type = SubscriptionType.PUSH
    optedIn = currentPushSubscription?.optedIn ?: true
    address = currentPushSubscription?.address ?: ""
    status = currentPushSubscription?.status ?: SubscriptionStatus.NO_PERMISSION
    sdk = oneSignalUtils.sdkVersion
    deviceOS = this@UserSwitcher.deviceOS ?: ""
    carrier = carrierName ?: ""
    appVersion = androidUtils.getAppVersion(appContextProvider()) ?: ""
}
// ...
subscriptionModelStore.clear(ModelChangeTags.NO_PROPOGATE)
// ...
subscriptionModelStore.replaceAll(subscriptions, ModelChangeTags.NO_PROPOGATE)

The new SubscriptionModel does NOT copy isDisabledInternally, so it defaults to false. The OLD model with isDisabledInternally = true is then discarded by clear + replaceAll. The KDoc on SubscriptionModel.isDisabledInternally claims the listener "honors this flag by short-circuiting to (enabled = false, status = UNSUBSCRIBE)" on the post-logout anonymous user — but the listener can never see a flag that doesn't exist on the new model. Subsequent property mutations on the new model (FCM token refresh, permission change, opt-in toggle) flow through getUpdateOperation un-short-circuited, producing normal UpdateSubscriptionOperations for an anonymous user that cannot authenticate.

Step-by-step proof

Starting state: user logged in with externalId = \"alice\", onesignalId = \"OS-A\", push sub PSUB-1 with isDisabledInternally = false. IV is required, alice's JWT is valid in JwtTokenStore.

  1. OneSignalImp.logout()LogoutHelper.switchUser() (LogoutHelper.kt:35).
  2. identityVerificationService.newCodePathsRun is true → switchUserIv(...) runs.
  3. ivBehaviorActive is true → execution proceeds past line 35.
  4. subscriptionModelStore.get(\"PSUB-1\").isDisabledInternally = true (line 36).
    • setBooleanProperty(\"isDisabledInternally\", true) with default tag NORMAL.
    • notifyChangedchangeNotifier.fire { onChanged(args, NORMAL) }.
    • ModelStore.onChanged re-fires onModelUpdated(args, NORMAL).
    • ModelStoreListener.onModelUpdated: tag == NORMAL, proceeds.
    • SubscriptionModelStoreListener.getUpdateOperation reads _identityModelStore.model → still (\"OS-A\", \"alice\"). getSubscriptionEnabledAndStatus short-circuits to (false, UNSUBSCRIBE).
    • UpdateSubscriptionOperation(appId, \"OS-A\", \"alice\", \"PSUB-1\", PUSH, enabled=false, address, UNSUBSCRIBE) is enqueued.
  5. userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) runs.
    • Builds newPushSubscription from currentPushSubscription — copies optedIn/address/status, but NOT isDisabledInternally.
    • subscriptionModelStore.clear(NO_PROPOGATE) removes the OLD model (the one we just set the flag on).
    • subscriptionModelStore.replaceAll([newPushSubscription], NO_PROPOGATE) installs the fresh model.
  6. New state: identity = anonymous (\"OS-B\", null), push sub model = fresh, isDisabledInternally = false.
  7. The UpdateSubscriptionOperation from step 4 dispatches with alice's still-valid JWT and unsubscribes alice's push subscription server-side.
  8. Later, FCM token refresh updates the new push sub's address → NORMAL-tagged → getUpdateOperation → no short-circuit (flag is false on new model) → enqueues UpdateSubscriptionOperation(\"OS-B\", null, ...) for the anonymous user. Under IV, hasValidJwtIfRequired filters it (externalId is null), so it stays queued indefinitely; without that filter it would 401.

How to fix

Two complementary changes:

  1. In LogoutHelperIvExtensions.switchUserIv: change the setter to suppress propagation, e.g. subscriptionModelStore.get(pushSubId)?.setBooleanProperty(SubscriptionModel::isDisabledInternally.name, true, ModelChangeTags.NO_PROPOGATE) — or simply skip the OLD-model write entirely, since the model is about to be discarded.
  2. In UserSwitcher.createAndSwitchToNewUser: copy isDisabledInternally = currentPushSubscription?.isDisabledInternally ?: false into the fresh SubscriptionModel so the flag actually persists into the post-logout anonymous user's model and the listener short-circuit fires for subsequent property changes.

Both verifiers (4 on bug_003, 3 on bug_004) confirmed the chain end-to-end; one verifier on bug_004 noted the practical 401 impact is mitigated by OperationRepoIvExtensions.hasValidJwtIfRequired (anonymous ops with null externalId are filtered before dispatch), reducing that half to a queue-accumulation + KDoc-mismatch concern. Bug_003 has no such mitigation: the unsubscribe op carries the OLD user's externalId and JWT and will be sent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@nan-li to look at this

}
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,11 @@ class CreateUserResponse(
* The subscriptions for the user.
*/
val subscriptions: List<SubscriptionObject>,
/**
* Read-your-write data for IAM fetch consistency, when the backend supplies it.
* Populated under Identity Verification so [com.onesignal.inAppMessages] can await
* the user record's propagation before fetching IAMs. `null` for non-IV apps and
* older backends that don't include `ryw_token` in the response.
*/
val rywData: com.onesignal.common.consistency.RywData? = null,
)
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,12 @@ object JSONConverter {
return@expandJSONArray null
}

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

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

fun convertToJSON(properties: PropertiesObject): JSONObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package com.onesignal.user.internal.jwt
* Listeners should call [JwtTokenStore.getJwt] for the current value — event delivery
* order is not guaranteed to match mutation order across concurrent writers.
*/
internal interface IJwtUpdateListener {
interface IJwtUpdateListener {
/** Fired when a JWT was added or refreshed (`putJwt`), or when stale entries are pruned. */
fun onJwtUpdated(externalId: String) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import org.json.JSONObject
* can still resolve their JWT at execution time. Storage is unconditional; *usage* of JWTs is
* gated on `IdentityVerificationService.ivBehaviorActive`.
*/
internal class JwtTokenStore(
class JwtTokenStore(
private val _prefs: IPreferencesService,
) : IEventNotifier<IJwtUpdateListener> {
private val tokens: MutableMap<String, String> = mutableMapOf()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.onesignal.user.internal.operations.impl.executors

import android.os.Build
import com.onesignal.common.AndroidUtils
import com.onesignal.common.consistency.enums.IamFetchRywTokenKey
import com.onesignal.common.consistency.models.IConsistencyManager
import com.onesignal.common.DeviceUtils
import com.onesignal.common.IDManager
import com.onesignal.common.NetworkUtils
Expand Down Expand Up @@ -51,6 +53,7 @@ internal class LoginUserOperationExecutor(
private val _languageContext: ILanguageContext,
private val _jwtTokenStore: JwtTokenStore,
private val _identityVerificationService: IdentityVerificationService,
private val _consistencyManager: IConsistencyManager,
) : IOperationExecutor {
override val operations: List<String>
get() = listOf(LOGIN_USER)
Expand Down Expand Up @@ -240,6 +243,16 @@ internal class LoginUserOperationExecutor(
backendSubscriptions.remove(backendSubscription)
}

// Forward the create-user RYW data to ConsistencyManager so InAppMessagesManager
// can fetch IAMs once the user record has propagated. Gated on newCodePathsRun:
// Phase 1 users don't await RYW (legacy IAM fetch path), so storing it would be
// a no-op anyway. Backend only sends rywData under IV.
if (_identityVerificationService.newCodePathsRun) {
response.rywData?.let { rywData ->
_consistencyManager.setRywData(backendOneSignalId, IamFetchRywTokenKey.USER, rywData)
}
}

val wasPossiblyAnUpsert = identities.isNotEmpty()
val followUpOperations =
if (wasPossiblyAnUpsert) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ internal class SubscriptionModelStoreListener(

companion object {
fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair<Boolean, SubscriptionStatus> {
// Internal-disabled subscription (e.g. the post-logout anonymous user under IV)
// must not generate backend ops; report as disabled+unsubscribe regardless of
// optedIn/status. See [SubscriptionModel.isDisabledInternally].
if (model.isDisabledInternally) {
return Pair(false, SubscriptionStatus.UNSUBSCRIBE)
}

val status: SubscriptionStatus
val enabled: Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ class SubscriptionModel : Model() {
setBooleanProperty(::optedIn.name, value)
}

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

var type: SubscriptionType
get() = getEnumProperty(::type.name)
set(value) {
Expand Down
Loading
Loading