diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml
index 3f07ef1a1..88b28052a 100644
--- a/OneSignalSDK/detekt/detekt-baseline-core.xml
+++ b/OneSignalSDK/detekt/detekt-baseline-core.xml
@@ -42,6 +42,7 @@
ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _configModelStore: ConfigModelStore
ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _sessionService: ISessionService
ConstructorParameterNaming:InstallIdService.kt$InstallIdService$private val _prefs: IPreferencesService
+ ConstructorParameterNaming:JwtTokenStore.kt$JwtTokenStore$private val _prefs: IPreferencesService
ConstructorParameterNaming:LanguageContext.kt$LanguageContext$private val _propertiesModelStore: PropertiesModelStore
ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _identityModelStore: IdentityModelStore
ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _propertiesModelStore: PropertiesModelStore
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt
index 9998fd213..908f55a0d 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt
@@ -45,6 +45,7 @@ import com.onesignal.location.ILocationManager
import com.onesignal.location.internal.MisconfiguredLocationManager
import com.onesignal.notifications.INotificationsManager
import com.onesignal.notifications.internal.MisconfiguredNotificationsManager
+import com.onesignal.user.internal.jwt.JwtTokenStore
internal class CoreModule : IModule {
override fun register(builder: ServiceBuilder) {
@@ -68,6 +69,8 @@ internal class CoreModule : IModule {
builder.register().provides()
builder.register().provides()
+ builder.register().provides()
+
// Operations
builder.register().provides()
builder.register()
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt
index 616fa1916..acc89a26c 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt
@@ -2,6 +2,7 @@ package com.onesignal.core.internal.config
import com.onesignal.common.modeling.Model
import com.onesignal.core.internal.http.OneSignalService.ONESIGNAL_API_BASE_URL
+import com.onesignal.user.internal.jwt.JwtRequirement
import org.json.JSONArray
import org.json.JSONObject
@@ -236,13 +237,18 @@ class ConfigModel : Model() {
setBooleanProperty(::enterprise.name, value)
}
- /**
- * Whether SMS auth hash should be used.
- */
- var useIdentityVerification: Boolean
- get() = getBooleanProperty(::useIdentityVerification.name) { false }
+ /** Mirrors backend `jwt_required`. Pre-HYDRATE callers see [JwtRequirement.UNKNOWN]. */
+ internal var useIdentityVerification: JwtRequirement
+ get() = JwtRequirement.fromBoolean(getOptBooleanProperty(::useIdentityVerification.name))
set(value) {
- setBooleanProperty(::useIdentityVerification.name, value)
+ setOptBooleanProperty(
+ ::useIdentityVerification.name,
+ when (value) {
+ JwtRequirement.UNKNOWN -> null
+ JwtRequirement.NOT_REQUIRED -> false
+ JwtRequirement.REQUIRED -> true
+ },
+ )
}
/**
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt
index e15b6faa0..26d4e1b86 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt
@@ -10,6 +10,7 @@ import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.jwt.JwtRequirement
import com.onesignal.user.internal.subscriptions.ISubscriptionManager
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
@@ -85,7 +86,9 @@ internal class ConfigModelStoreListener(
// these are only copied from the backend params when the backend has set them.
params.enterprise?.let { config.enterprise = it }
- params.useIdentityVerification?.let { config.useIdentityVerification = it }
+ params.useIdentityVerification?.let {
+ config.useIdentityVerification = JwtRequirement.fromBoolean(it)
+ }
params.firebaseAnalytics?.let { config.firebaseAnalytics = it }
params.restoreTTLFilter?.let { config.restoreTTLFilter = it }
params.clearGroupOnSummaryClick?.let { config.clearGroupOnSummaryClick = it }
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt
index 324421a71..a2a5b3315 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt
@@ -25,13 +25,17 @@ internal enum class FeatureFlag(
val activationMode: FeatureActivationMode
) {
// Threading mode is selected once per app startup to avoid mixed-mode behavior mid-session.
- //
// Remote key (lowercase) must match backend / Turbine flag id.
- //
SDK_BACKGROUND_THREADING(
"sdk_background_threading",
FeatureActivationMode.APP_STARTUP
),
+
+ /** JWT signing of SDK requests. IMMEDIATE so a kill-switch doesn't need a cold start. */
+ SDK_IDENTITY_VERIFICATION(
+ "sdk_identity_verification",
+ FeatureActivationMode.IMMEDIATE
+ ),
;
fun isEnabledIn(enabledKeys: Set): Boolean {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt
index e9b25c002..04918d90f 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt
@@ -8,6 +8,7 @@ import com.onesignal.core.internal.backend.impl.FeatureFlagsJsonParser
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.user.internal.jwt.IdentityVerificationGates
import kotlinx.serialization.json.JsonObject
internal interface IFeatureManager {
@@ -163,6 +164,13 @@ internal class FeatureManager(
enabled = enabled,
source = "FeatureManager:${feature.activationMode}"
)
+
+ FeatureFlag.SDK_IDENTITY_VERIFICATION ->
+ IdentityVerificationGates.update(
+ featureFlagOn = enabled,
+ jwtRequirement = configModelStore.model.useIdentityVerification,
+ source = "FeatureManager:${feature.activationMode}"
+ )
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt
index f4d4b92a5..84fab6944 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt
@@ -204,6 +204,9 @@ object PreferenceOneSignalKeys {
*/
const val PREFS_OS_LOCATION_SHARED = "OS_LOCATION_SHARED"
+ /** (String) JSON object mapping externalId -> JWT token. */
+ const val PREFS_OS_JWT_TOKENS = "PREFS_OS_JWT_TOKENS"
+
// Permissions
/**
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt
new file mode 100644
index 000000000..63b6a4075
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt
@@ -0,0 +1,10 @@
+package com.onesignal.user.internal.jwt
+
+/**
+ * Wake-up notification from [JwtTokenStore] when the JWT for [externalId] changes.
+ * Listeners must call [JwtTokenStore.getJwt] for the current value — event delivery
+ * order is not guaranteed to match mutation order across concurrent writers.
+ */
+internal interface IJwtUpdateListener {
+ fun onJwtUpdated(externalId: String)
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IdentityVerificationGates.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IdentityVerificationGates.kt
new file mode 100644
index 000000000..685777daf
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IdentityVerificationGates.kt
@@ -0,0 +1,52 @@
+package com.onesignal.user.internal.jwt
+
+import com.onesignal.debug.internal.logging.Logging
+
+/**
+ * Current Identity Verification gate state, pushed by `FeatureManager.applySideEffects`.
+ *
+ * Stored state is the tri-state [jwtRequirement] — UNKNOWN until the first HYDRATE so consumers
+ * that need to distinguish "we don't know yet" from "customer opted out" can. The Boolean
+ * derivations [ivBehaviorActive] and [newCodePathsRun] cover the common case where consumers
+ * just want yes/no; UNKNOWN reads as `false` for both, which is the safe default before HYDRATE.
+ *
+ * The two gates differ on purpose: [newCodePathsRun] also flips on when our SDK feature flag is
+ * on, honoring rollout state even when the customer hasn't opted in. [ivBehaviorActive] tracks
+ * customer config alone.
+ *
+ * Invariant `ivBehaviorActive == true ⇒ newCodePathsRun == true` is preserved at every
+ * observation because both are derived on read from the stored inputs; a reader can't observe an
+ * inconsistent state.
+ */
+internal object IdentityVerificationGates {
+ @Volatile
+ private var _featureFlagOn: Boolean = false
+
+ /** Customer config (`jwt_required`); [JwtRequirement.UNKNOWN] until the first HYDRATE. */
+ @Volatile
+ var jwtRequirement: JwtRequirement = JwtRequirement.UNKNOWN
+ private set
+
+ /** Whether IV-specific behavior (JWT attachment, auth error handling) applies. UNKNOWN reads as `false`. */
+ val ivBehaviorActive: Boolean
+ get() = jwtRequirement == JwtRequirement.REQUIRED
+
+ /** Whether new IV-related code paths should run. `featureFlag_IV_ON || jwt_required == REQUIRED`. */
+ val newCodePathsRun: Boolean
+ get() = _featureFlagOn || ivBehaviorActive
+
+ /** Idempotent. [source] is logged for traceability. */
+ fun update(
+ featureFlagOn: Boolean,
+ jwtRequirement: JwtRequirement,
+ source: String,
+ ) {
+ _featureFlagOn = featureFlagOn
+ this.jwtRequirement = jwtRequirement
+
+ Logging.debug(
+ "OneSignal: IdentityVerificationGates.update: newCodePathsRun=$newCodePathsRun, " +
+ "ivBehaviorActive=$ivBehaviorActive (source=$source)",
+ )
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtRequirement.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtRequirement.kt
new file mode 100644
index 000000000..3f21c4c0f
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtRequirement.kt
@@ -0,0 +1,31 @@
+package com.onesignal.user.internal.jwt
+
+/**
+ * Customer-side JWT requirement, mirrored from the backend `jwt_required` remote param.
+ * Explicit [UNKNOWN] so callers can distinguish pre-HYDRATE (no value yet) from
+ * [NOT_REQUIRED] (customer opted out).
+ *
+ * Represents only the customer-config side of Identity Verification; do not confuse
+ * with [com.onesignal.core.internal.features.FeatureFlag.SDK_IDENTITY_VERIFICATION] (our
+ * SDK-side rollout switch).
+ */
+internal enum class JwtRequirement {
+ /** Remote params have not been fetched yet. Treat as non-IV until known. */
+ UNKNOWN,
+
+ /** Customer config `jwt_required=false`. No JWT signing. */
+ NOT_REQUIRED,
+
+ /** Customer config `jwt_required=true`. IV-specific behavior active. */
+ REQUIRED,
+ ;
+
+ companion object {
+ fun fromBoolean(value: Boolean?): JwtRequirement =
+ when (value) {
+ null -> UNKNOWN
+ false -> NOT_REQUIRED
+ true -> REQUIRED
+ }
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt
new file mode 100644
index 000000000..102db5e59
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt
@@ -0,0 +1,119 @@
+package com.onesignal.user.internal.jwt
+
+import com.onesignal.common.events.EventProducer
+import com.onesignal.common.events.IEventNotifier
+import com.onesignal.core.internal.preferences.IPreferencesService
+import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
+import com.onesignal.core.internal.preferences.PreferenceStores
+import com.onesignal.debug.internal.logging.Logging
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * Persistent store mapping externalId -> JWT. Multi-user so ops queued under a previous user
+ * can still resolve their JWT at execution time. Storage is unconditional; *usage* of JWTs is
+ * gated on [IdentityVerificationGates.ivBehaviorActive].
+ */
+internal class JwtTokenStore(
+ private val _prefs: IPreferencesService,
+) : IEventNotifier {
+ private val tokens: MutableMap = mutableMapOf()
+ private var isLoaded: Boolean = false
+ private val updates = EventProducer()
+
+ override val hasSubscribers: Boolean
+ get() = updates.hasSubscribers
+
+ override fun subscribe(handler: IJwtUpdateListener) = updates.subscribe(handler)
+
+ override fun unsubscribe(handler: IJwtUpdateListener) = updates.unsubscribe(handler)
+
+ fun getJwt(externalId: String): String? {
+ synchronized(tokens) {
+ ensureLoaded()
+ return tokens[externalId]
+ }
+ }
+
+ /** Null [jwt] is a no-op; call [invalidateJwt] to remove a token. */
+ fun putJwt(
+ externalId: String,
+ jwt: String?,
+ ) {
+ if (jwt == null) return
+ val changed: Boolean
+ synchronized(tokens) {
+ ensureLoaded()
+ changed = tokens[externalId] != jwt
+ tokens[externalId] = jwt
+ if (changed) {
+ persist()
+ }
+ }
+ if (changed) {
+ updates.fire { it.onJwtUpdated(externalId) }
+ }
+ }
+
+ /** Removes the JWT for [externalId] and notifies subscribers. */
+ fun invalidateJwt(externalId: String) {
+ val existed: Boolean
+ synchronized(tokens) {
+ ensureLoaded()
+ existed = tokens.remove(externalId) != null
+ if (existed) {
+ persist()
+ }
+ }
+ if (existed) {
+ updates.fire { it.onJwtUpdated(externalId) }
+ }
+ }
+
+ /** Drops JWTs whose externalId isn't in [activeIds]. Call on cold start to bound growth. */
+ fun pruneToExternalIds(activeIds: Set) {
+ val removed: Set
+ synchronized(tokens) {
+ ensureLoaded()
+ val toRemove = tokens.keys - activeIds
+ removed = toRemove.toSet()
+ if (removed.isNotEmpty()) {
+ tokens.keys.removeAll(removed)
+ persist()
+ }
+ }
+ for (externalId in removed) {
+ updates.fire { it.onJwtUpdated(externalId) }
+ }
+ }
+
+ /** Caller must hold `synchronized(tokens)`. */
+ private fun ensureLoaded() {
+ if (isLoaded) return
+ val json =
+ _prefs.getString(
+ PreferenceStores.ONESIGNAL,
+ PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
+ )
+ if (json != null) {
+ try {
+ val obj = JSONObject(json)
+ for (key in obj.keys()) {
+ tokens[key] = obj.getString(key)
+ }
+ } catch (e: JSONException) {
+ Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh: ${e.message}")
+ }
+ }
+ isLoaded = true
+ }
+
+ /** Caller must hold `synchronized(tokens)`. */
+ private fun persist() {
+ _prefs.saveString(
+ PreferenceStores.ONESIGNAL,
+ PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
+ JSONObject(tokens.toMap()).toString(),
+ )
+ }
+}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt
index 54cf41b3a..358e2ed42 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt
@@ -15,4 +15,9 @@ class FeatureFlagTests : FunSpec({
test("SDK_BACKGROUND_THREADING uses the expected remote key") {
FeatureFlag.SDK_BACKGROUND_THREADING.key shouldBe "sdk_background_threading"
}
+
+ test("SDK_IDENTITY_VERIFICATION uses the expected remote key and IMMEDIATE activation") {
+ FeatureFlag.SDK_IDENTITY_VERIFICATION.key shouldBe "sdk_identity_verification"
+ FeatureFlag.SDK_IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt
index bc6695d41..9fc1d8301 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt
@@ -4,6 +4,8 @@ import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.common.threading.ThreadingMode
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
+import com.onesignal.user.internal.jwt.IdentityVerificationGates
+import com.onesignal.user.internal.jwt.JwtRequirement
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
@@ -15,11 +17,13 @@ import kotlinx.serialization.json.jsonPrimitive
class FeatureManagerTests : FunSpec({
beforeEach {
ThreadingMode.useBackgroundThreading = false
+ IdentityVerificationGates.update(false, JwtRequirement.UNKNOWN, "test-reset")
}
fun stubConfigModel(model: ConfigModel) {
every { model.sdkRemoteFeatureFlags } returns emptyList()
every { model.sdkRemoteFeatureFlagMetadata } returns null
+ every { model.useIdentityVerification } returns JwtRequirement.UNKNOWN
}
test("initial state enables BACKGROUND_THREADING when key is present in sdk remote flags") {
@@ -188,4 +192,110 @@ class FeatureManagerTests : FunSpec({
manager.enabledFeatureKeys() shouldBe emptyList()
}
+
+ test("initial state: IDENTITY_VERIFICATION flag off + jwt_required=null → gates both false") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+
+ val manager = FeatureManager(configModelStore)
+
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe false
+ IdentityVerificationGates.newCodePathsRun shouldBe false
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("initial state: IDENTITY_VERIFICATION flag on → newCodePathsRun=true, ivBehaviorActive=false") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ every { initialModel.sdkRemoteFeatureFlags } returns listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key)
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+
+ val manager = FeatureManager(configModelStore)
+
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("ERROR STATE: flag off + jwt_required=true → both gates true (customer config wins)") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ every { initialModel.useIdentityVerification } returns JwtRequirement.REQUIRED
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+
+ val manager = FeatureManager(configModelStore)
+
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe false
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+ }
+
+ test("initial state: flag on + jwt_required=true → full IV (both gates true)") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ every { initialModel.sdkRemoteFeatureFlags } returns listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key)
+ every { initialModel.useIdentityVerification } returns JwtRequirement.REQUIRED
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+
+ val manager = FeatureManager(configModelStore)
+
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+ }
+
+ test("HYDRATE updates gates when useIdentityVerification arrives as true") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+ val manager = FeatureManager(configModelStore)
+
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+
+ val updatedModel = mockk()
+ stubConfigModel(updatedModel)
+ every { updatedModel.useIdentityVerification } returns JwtRequirement.REQUIRED
+ // Match production: store's model is swapped before onModelReplaced fires.
+ every { configModelStore.model } returns updatedModel
+
+ manager.onModelReplaced(updatedModel, ModelChangeTags.HYDRATE)
+
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+ }
+
+ test("IDENTITY_VERIFICATION is IMMEDIATE: mid-session flag flip flows through to the gates") {
+ val initialModel = mockk()
+ stubConfigModel(initialModel)
+ val configModelStore = mockk()
+ every { configModelStore.model } returns initialModel
+ every { configModelStore.subscribe(any()) } just runs
+ val manager = FeatureManager(configModelStore)
+
+ // Mid-session model replacement enables the flag remotely.
+ val updatedModel = mockk()
+ stubConfigModel(updatedModel)
+ every { updatedModel.sdkRemoteFeatureFlags } returns listOf(FeatureFlag.SDK_IDENTITY_VERIFICATION.key)
+ every { updatedModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED
+ every { configModelStore.model } returns updatedModel
+
+ manager.onModelReplaced(updatedModel, ModelChangeTags.HYDRATE)
+
+ // Feature flag flips in-memory because IDENTITY_VERIFICATION is IMMEDIATE.
+ manager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) shouldBe true
+ // newCodePathsRun reflects the flipped flag; ivBehaviorActive still false (jwt_required=false).
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/IdentityVerificationGatesTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/IdentityVerificationGatesTests.kt
new file mode 100644
index 000000000..d185346ac
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/IdentityVerificationGatesTests.kt
@@ -0,0 +1,97 @@
+package com.onesignal.user.internal.jwt
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+
+class IdentityVerificationGatesTests : FunSpec({
+ // Singleton state leaks across tests; reset before each.
+ beforeEach {
+ IdentityVerificationGates.update(false, JwtRequirement.UNKNOWN, "test-reset")
+ }
+
+ test("defaults to newCodePathsRun=false and ivBehaviorActive=false") {
+ IdentityVerificationGates.newCodePathsRun shouldBe false
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("featureFlagOn=false, jwtRequirement=UNKNOWN: both gates are false (safe default)") {
+ IdentityVerificationGates.update(
+ featureFlagOn = false,
+ jwtRequirement = JwtRequirement.UNKNOWN,
+ source = "test",
+ )
+ IdentityVerificationGates.newCodePathsRun shouldBe false
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("featureFlagOn=false, jwtRequirement=NOT_REQUIRED: both gates are false") {
+ IdentityVerificationGates.update(
+ featureFlagOn = false,
+ jwtRequirement = JwtRequirement.NOT_REQUIRED,
+ source = "test",
+ )
+ IdentityVerificationGates.newCodePathsRun shouldBe false
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("ERROR STATE — featureFlagOn=false, jwtRequirement=REQUIRED: both gates true (customer config wins)") {
+ IdentityVerificationGates.update(
+ featureFlagOn = false,
+ jwtRequirement = JwtRequirement.REQUIRED,
+ source = "test",
+ )
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+ }
+
+ test("featureFlagOn=true, jwtRequirement=UNKNOWN: newCodePathsRun true, ivBehaviorActive false") {
+ IdentityVerificationGates.update(
+ featureFlagOn = true,
+ jwtRequirement = JwtRequirement.UNKNOWN,
+ source = "test",
+ )
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("featureFlagOn=true, jwtRequirement=NOT_REQUIRED: newCodePathsRun true, ivBehaviorActive false (Phase 3)") {
+ IdentityVerificationGates.update(
+ featureFlagOn = true,
+ jwtRequirement = JwtRequirement.NOT_REQUIRED,
+ source = "test",
+ )
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+
+ test("featureFlagOn=true, jwtRequirement=REQUIRED: both gates true (full IV)") {
+ IdentityVerificationGates.update(
+ featureFlagOn = true,
+ jwtRequirement = JwtRequirement.REQUIRED,
+ source = "test",
+ )
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+ }
+
+ test("updating to the same values is a no-op but still reflects in reads") {
+ IdentityVerificationGates.update(true, JwtRequirement.REQUIRED, "first")
+ IdentityVerificationGates.update(true, JwtRequirement.REQUIRED, "second")
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+ }
+
+ test("transition: non-IV → IV-active → off") {
+ IdentityVerificationGates.update(false, JwtRequirement.NOT_REQUIRED, "phase-1-non-iv")
+ IdentityVerificationGates.newCodePathsRun shouldBe false
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+
+ IdentityVerificationGates.update(true, JwtRequirement.REQUIRED, "phase-2-iv-on")
+ IdentityVerificationGates.newCodePathsRun shouldBe true
+ IdentityVerificationGates.ivBehaviorActive shouldBe true
+
+ IdentityVerificationGates.update(false, JwtRequirement.NOT_REQUIRED, "kill-switch")
+ IdentityVerificationGates.newCodePathsRun shouldBe false
+ IdentityVerificationGates.ivBehaviorActive shouldBe false
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/JwtTokenStoreTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/JwtTokenStoreTests.kt
new file mode 100644
index 000000000..664e50234
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/jwt/JwtTokenStoreTests.kt
@@ -0,0 +1,231 @@
+package com.onesignal.user.internal.jwt
+
+import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
+import com.onesignal.core.internal.preferences.PreferenceStores
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.mocks.MockPreferencesService
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import org.json.JSONObject
+
+class JwtTokenStoreTests : FunSpec({
+ beforeEach {
+ // Silence logging to avoid android.util.Log.w not-mocked failures in Logging.warn
+ Logging.logLevel = LogLevel.NONE
+ }
+
+ test("getJwt returns null for an externalId never stored") {
+ val store = JwtTokenStore(MockPreferencesService())
+
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("putJwt stores a token retrievable by externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+
+ store.putJwt("alice", "token-a")
+
+ store.getJwt("alice") shouldBe "token-a"
+ }
+
+ test("putJwt replaces an existing token for the same externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a1")
+
+ store.putJwt("alice", "token-a2")
+
+ store.getJwt("alice") shouldBe "token-a2"
+ }
+
+ test("putJwt with null is a no-op (invalidate is the explicit path)") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+
+ store.putJwt("alice", null)
+
+ store.getJwt("alice") shouldBe "token-a"
+ }
+
+ test("invalidateJwt removes the token for externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+
+ store.invalidateJwt("alice")
+
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("invalidateJwt on an absent externalId is a no-op (no crash)") {
+ val store = JwtTokenStore(MockPreferencesService())
+
+ store.invalidateJwt("alice")
+
+ store.getJwt("alice") shouldBe null
+ }
+
+ test("putJwt persists to preferences and can be recovered by a fresh store instance") {
+ val prefs = MockPreferencesService()
+ val first = JwtTokenStore(prefs)
+ first.putJwt("alice", "token-a")
+ first.putJwt("bob", "token-b")
+
+ val second = JwtTokenStore(prefs)
+
+ second.getJwt("alice") shouldBe "token-a"
+ second.getJwt("bob") shouldBe "token-b"
+ }
+
+ test("invalidateJwt persists so next launch does not see the token") {
+ val prefs = MockPreferencesService()
+ val first = JwtTokenStore(prefs)
+ first.putJwt("alice", "token-a")
+ first.invalidateJwt("alice")
+
+ val second = JwtTokenStore(prefs)
+
+ second.getJwt("alice") shouldBe null
+ }
+
+ test("pruneToExternalIds removes tokens whose externalId is not in the active set") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ store.putJwt("bob", "token-b")
+ store.putJwt("chris", "token-c")
+
+ store.pruneToExternalIds(setOf("alice", "chris"))
+
+ store.getJwt("alice") shouldBe "token-a"
+ store.getJwt("bob") shouldBe null
+ store.getJwt("chris") shouldBe "token-c"
+ }
+
+ test("subscribers are notified when a new JWT is put") {
+ val store = JwtTokenStore(MockPreferencesService())
+ val calls = mutableListOf()
+ store.subscribe(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.putJwt("alice", "token-a")
+
+ calls shouldBe listOf("alice")
+ }
+
+ test("subscribers are NOT notified when putJwt does not change the stored token") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ val calls = mutableListOf()
+ store.subscribe(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.putJwt("alice", "token-a")
+
+ calls.isEmpty() shouldBe true
+ }
+
+ test("subscribers are notified on invalidation") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ val calls = mutableListOf()
+ store.subscribe(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.invalidateJwt("alice")
+
+ calls shouldBe listOf("alice")
+ }
+
+ test("subscribers are NOT notified when invalidating a non-existent token") {
+ val store = JwtTokenStore(MockPreferencesService())
+ val calls = mutableListOf()
+ store.subscribe(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.invalidateJwt("alice")
+
+ calls.isEmpty() shouldBe true
+ }
+
+ test("pruneToExternalIds fires for each removed externalId") {
+ val store = JwtTokenStore(MockPreferencesService())
+ store.putJwt("alice", "token-a")
+ store.putJwt("bob", "token-b")
+ store.putJwt("chris", "token-c")
+ val calls = mutableListOf()
+ store.subscribe(
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ },
+ )
+
+ store.pruneToExternalIds(setOf("alice"))
+
+ // Order is not deterministic across JVMs; check set semantics.
+ calls.toSet() shouldBe setOf("bob", "chris")
+ }
+
+ test("unsubscribed listener is not notified") {
+ val store = JwtTokenStore(MockPreferencesService())
+ val calls = mutableListOf()
+ val listener =
+ object : IJwtUpdateListener {
+ override fun onJwtUpdated(externalId: String) {
+ calls.add(externalId)
+ }
+ }
+ store.subscribe(listener)
+ store.unsubscribe(listener)
+
+ store.putJwt("alice", "token-a")
+
+ calls.isEmpty() shouldBe true
+ }
+
+ test("persisted JSON is the expected shape") {
+ val prefs = MockPreferencesService()
+ val store = JwtTokenStore(prefs)
+
+ store.putJwt("alice", "token-a")
+ store.putJwt("bob", "token-b")
+
+ val raw = prefs.getString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS)
+ val obj = JSONObject(requireNotNull(raw))
+ obj.getString("alice") shouldBe "token-a"
+ obj.getString("bob") shouldBe "token-b"
+ }
+
+ test("malformed persisted JSON starts fresh without crashing") {
+ val prefs =
+ MockPreferencesService(
+ mapOf(PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS to "{not valid json"),
+ )
+ val store = JwtTokenStore(prefs)
+
+ store.getJwt("alice") shouldBe null
+ // Can still store new tokens after a malformed load
+ store.putJwt("alice", "token-a")
+ store.getJwt("alice") shouldBe "token-a"
+ }
+})
diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt
index 188cc66cb..300917777 100644
--- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt
+++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/listeners/DeviceRegistrationListenerTests.kt
@@ -252,7 +252,7 @@ class DeviceRegistrationListenerTests : FunSpec({
permission = true,
pushModel = uninitializedPushModel(),
pushTokenResponse =
- PushTokenResponse(NEW_TOKEN, SubscriptionStatus.SUBSCRIBED),
+ PushTokenResponse(NEW_TOKEN, SubscriptionStatus.SUBSCRIBED),
)
// Permission flips off between gate evaluation and the IO callback.
every { harness.notificationsManager.permission } returns true andThen false