Skip to content

Commit 44bc2f4

Browse files
committed
feat(iv): foundation: gates singleton, JWT token store, feature flag entry
First of set of PRs against the IV integration branch. Lands only the foundation primitives; no consumer wiring or public API yet. - Add `FeatureFlag.IDENTITY_VERIFICATION` (IMMEDIATE) so a Turbine kill-switch takes effect without a cold start. - Add `IdentityVerificationGates` singleton (mirrors `ThreadingMode`) holding `newCodePathsRun` (featureFlag || jwt_required) and `ivBehaviorActive` (jwt_required alone). `FeatureManager.applySideEffects` pushes both values in on every refresh, reading from the passed `model` parameter to stay in sync with the HYDRATE snapshot. - Add `JwtTokenStore` (SharedPreferences-backed externalId -> JWT) with an `EventProducer<IJwtUpdateListener>` for PR 5's IAM retry to subscribe to. - Change `ConfigModel.useIdentityVerification` from `Boolean` to `Boolean?`; `null` means pre-HYDRATE so PR 2's `getNextOps` deferral can distinguish it from an explicit `false`. - Add `PREFS_OS_JWT_TOKENS` key. - Register `JwtTokenStore` in DI. `IdentityVerificationService` is deferred to PR 2 where its HYDRATE handler has real work. Tests cover the gate matrix (including the ERROR STATE row where jwt_required=true overrides a false feature flag), JWT store put/get/ invalidate/prune + listener broadcast, and FeatureManager propagation to the gates on init and on HYDRATE.
1 parent 2f615fc commit 44bc2f4

12 files changed

Lines changed: 639 additions & 11 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import com.onesignal.location.ILocationManager
4545
import com.onesignal.location.internal.MisconfiguredLocationManager
4646
import com.onesignal.notifications.INotificationsManager
4747
import com.onesignal.notifications.internal.MisconfiguredNotificationsManager
48+
import com.onesignal.user.internal.jwt.JwtTokenStore
4849

4950
internal class CoreModule : IModule {
5051
override fun register(builder: ServiceBuilder) {
@@ -68,6 +69,8 @@ internal class CoreModule : IModule {
6869
builder.register<ConfigModelStoreListener>().provides<IStartableService>()
6970
builder.register<FeatureFlagsRefreshService>().provides<IStartableService>()
7071

72+
builder.register<JwtTokenStore>().provides<JwtTokenStore>()
73+
7174
// Operations
7275
builder.register<OperationModelStore>().provides<OperationModelStore>()
7376
builder.register<OperationRepo>()

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,11 @@ class ConfigModel : Model() {
236236
setBooleanProperty(::enterprise.name, value)
237237
}
238238

239-
/**
240-
* Whether SMS auth hash should be used.
241-
*/
242-
var useIdentityVerification: Boolean
243-
get() = getBooleanProperty(::useIdentityVerification.name) { false }
239+
/** Mirrors backend `jwt_required`. `null` = pre-HYDRATE (distinguish from `false`). */
240+
var useIdentityVerification: Boolean?
241+
get() = getOptBooleanProperty(::useIdentityVerification.name)
244242
set(value) {
245-
setBooleanProperty(::useIdentityVerification.name, value)
243+
setOptBooleanProperty(::useIdentityVerification.name, value)
246244
}
247245

248246
/**

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@ internal enum class FeatureFlag(
2424
val key: String,
2525
val activationMode: FeatureActivationMode
2626
) {
27-
// Threading mode is selected once per app startup to avoid mixed-mode behavior mid-session.
28-
//
2927
// Remote key (lowercase) must match backend / Turbine flag id.
30-
//
3128
SDK_BACKGROUND_THREADING(
3229
"sdk_background_threading",
3330
FeatureActivationMode.APP_STARTUP
3431
),
32+
33+
/** JWT signing of SDK requests. IMMEDIATE so a kill-switch doesn't need a cold start. */
34+
IDENTITY_VERIFICATION(
35+
"identity_verification",
36+
FeatureActivationMode.IMMEDIATE
37+
),
3538
;
3639

3740
fun isEnabledIn(enabledKeys: Set<String>): Boolean {

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.onesignal.core.internal.backend.impl.FeatureFlagsJsonParser
88
import com.onesignal.core.internal.config.ConfigModel
99
import com.onesignal.core.internal.config.ConfigModelStore
1010
import com.onesignal.debug.internal.logging.Logging
11+
import com.onesignal.user.internal.jwt.IdentityVerificationGates
1112
import kotlinx.serialization.json.JsonObject
1213

1314
internal interface IFeatureManager {
@@ -103,14 +104,14 @@ internal class FeatureManager(
103104
when (feature.activationMode) {
104105
FeatureActivationMode.IMMEDIATE -> {
105106
nextStates[feature] = desiredState
106-
applySideEffects(feature, desiredState)
107+
applySideEffects(feature, desiredState, model)
107108
}
108109

109110
FeatureActivationMode.APP_STARTUP -> {
110111
val hasBeenInitialized = nextStates.containsKey(feature)
111112
if (applyNextRunOnlyFeatures || !hasBeenInitialized) {
112113
nextStates[feature] = desiredState
113-
applySideEffects(feature, desiredState)
114+
applySideEffects(feature, desiredState, model)
114115
} else {
115116
val currentState = nextStates[feature] ?: false
116117
if (currentState != desiredState) {
@@ -137,13 +138,21 @@ internal class FeatureManager(
137138
private fun applySideEffects(
138139
feature: FeatureFlag,
139140
enabled: Boolean,
141+
model: ConfigModel,
140142
) {
141143
when (feature) {
142144
FeatureFlag.SDK_BACKGROUND_THREADING ->
143145
ThreadingMode.updateUseBackgroundThreading(
144146
enabled = enabled,
145147
source = "FeatureManager:${feature.activationMode}"
146148
)
149+
150+
FeatureFlag.IDENTITY_VERIFICATION ->
151+
IdentityVerificationGates.update(
152+
featureFlagOn = enabled,
153+
jwtRequired = model.useIdentityVerification,
154+
source = "FeatureManager:${feature.activationMode}"
155+
)
147156
}
148157
}
149158

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/preferences/IPreferencesService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ object PreferenceOneSignalKeys {
204204
*/
205205
const val PREFS_OS_LOCATION_SHARED = "OS_LOCATION_SHARED"
206206

207+
/** (String) JSON object mapping externalId -> JWT token. */
208+
const val PREFS_OS_JWT_TOKENS = "PREFS_OS_JWT_TOKENS"
209+
207210
// Permissions
208211

209212
/**
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.onesignal.user.internal.jwt
2+
3+
/** Notified by [JwtTokenStore] on put/invalidate. Null [jwt] means invalidated. */
4+
internal interface IJwtUpdateListener {
5+
fun onJwtUpdated(externalId: String, jwt: String?)
6+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.onesignal.user.internal.jwt
2+
3+
import com.onesignal.debug.internal.logging.Logging
4+
5+
/**
6+
* Current Identity Verification gate state, pushed by `FeatureManager.applySideEffects`.
7+
*
8+
* The two gates differ on purpose: [newCodePathsRun] also flips on when customer config
9+
* (`jwt_required`) is true — honoring customer setup even if our feature flag is off.
10+
* [ivBehaviorActive] tracks customer config alone.
11+
*/
12+
internal object IdentityVerificationGates {
13+
/** Whether new IV-related code paths should run. `featureFlag_IV_ON || jwt_required == true`. */
14+
@Volatile
15+
var newCodePathsRun: Boolean = false
16+
private set
17+
18+
/** Whether IV-specific behavior (JWT attachment, auth error handling) applies. `jwt_required == true`. */
19+
@Volatile
20+
var ivBehaviorActive: Boolean = false
21+
private set
22+
23+
/** Idempotent. [source] is logged for traceability when gates change. */
24+
fun update(
25+
featureFlagOn: Boolean,
26+
jwtRequired: Boolean?,
27+
source: String,
28+
) {
29+
val previousNewCode = newCodePathsRun
30+
val previousIvActive = ivBehaviorActive
31+
32+
newCodePathsRun = featureFlagOn || (jwtRequired == true)
33+
ivBehaviorActive = jwtRequired == true
34+
35+
if (previousNewCode != newCodePathsRun || previousIvActive != ivBehaviorActive) {
36+
Logging.info(
37+
"OneSignal: IdentityVerificationGates updated: " +
38+
"newCodePathsRun=$newCodePathsRun, ivBehaviorActive=$ivBehaviorActive " +
39+
"(source=$source, featureFlagOn=$featureFlagOn, jwtRequired=$jwtRequired)",
40+
)
41+
} else {
42+
Logging.debug(
43+
"OneSignal: IdentityVerificationGates unchanged " +
44+
"(source=$source, newCodePathsRun=$newCodePathsRun, ivBehaviorActive=$ivBehaviorActive)",
45+
)
46+
}
47+
}
48+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.onesignal.user.internal.jwt
2+
3+
import com.onesignal.common.events.EventProducer
4+
import com.onesignal.common.events.IEventNotifier
5+
import com.onesignal.core.internal.preferences.IPreferencesService
6+
import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
7+
import com.onesignal.core.internal.preferences.PreferenceStores
8+
import com.onesignal.debug.internal.logging.Logging
9+
import org.json.JSONException
10+
import org.json.JSONObject
11+
12+
/**
13+
* Persistent store mapping externalId -> JWT. Multi-user so ops queued under a previous user
14+
* can still resolve their JWT at execution time. Storage is unconditional; *usage* of JWTs is
15+
* gated on [IdentityVerificationGates.ivBehaviorActive].
16+
*/
17+
internal class JwtTokenStore(
18+
private val _prefs: IPreferencesService,
19+
) : IEventNotifier<IJwtUpdateListener> {
20+
private val tokens: MutableMap<String, String> = mutableMapOf()
21+
private var isLoaded: Boolean = false
22+
private val updates = EventProducer<IJwtUpdateListener>()
23+
24+
override val hasSubscribers: Boolean
25+
get() = updates.hasSubscribers
26+
27+
override fun subscribe(handler: IJwtUpdateListener) = updates.subscribe(handler)
28+
29+
override fun unsubscribe(handler: IJwtUpdateListener) = updates.unsubscribe(handler)
30+
31+
fun getJwt(externalId: String): String? {
32+
synchronized(tokens) {
33+
ensureLoaded()
34+
return tokens[externalId]
35+
}
36+
}
37+
38+
/** Null [jwt] is a no-op; call [invalidateJwt] to remove a token. */
39+
fun putJwt(
40+
externalId: String,
41+
jwt: String?,
42+
) {
43+
if (jwt == null) return
44+
val changed: Boolean
45+
synchronized(tokens) {
46+
ensureLoaded()
47+
changed = tokens[externalId] != jwt
48+
tokens[externalId] = jwt
49+
if (changed) {
50+
persist()
51+
}
52+
}
53+
if (changed) {
54+
updates.fire { it.onJwtUpdated(externalId, jwt) }
55+
}
56+
}
57+
58+
/** Removes the JWT for [externalId] and broadcasts with `jwt = null`. */
59+
fun invalidateJwt(externalId: String) {
60+
val existed: Boolean
61+
synchronized(tokens) {
62+
ensureLoaded()
63+
existed = tokens.remove(externalId) != null
64+
if (existed) {
65+
persist()
66+
}
67+
}
68+
if (existed) {
69+
updates.fire { it.onJwtUpdated(externalId, null) }
70+
}
71+
}
72+
73+
/** Drops JWTs whose externalId isn't in [activeIds]. Call on cold start to bound growth. */
74+
fun pruneToExternalIds(activeIds: Set<String>) {
75+
val removed: Set<String>
76+
synchronized(tokens) {
77+
ensureLoaded()
78+
val toRemove = tokens.keys - activeIds
79+
removed = toRemove.toSet()
80+
if (removed.isNotEmpty()) {
81+
tokens.keys.removeAll(removed)
82+
persist()
83+
}
84+
}
85+
for (externalId in removed) {
86+
updates.fire { it.onJwtUpdated(externalId, null) }
87+
}
88+
}
89+
90+
/** Caller must hold `synchronized(tokens)`. */
91+
private fun ensureLoaded() {
92+
if (isLoaded) return
93+
val json =
94+
_prefs.getString(
95+
PreferenceStores.ONESIGNAL,
96+
PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
97+
)
98+
if (json != null) {
99+
try {
100+
val obj = JSONObject(json)
101+
for (key in obj.keys()) {
102+
tokens[key] = obj.getString(key)
103+
}
104+
} catch (e: JSONException) {
105+
Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh: ${e.message}")
106+
}
107+
}
108+
isLoaded = true
109+
}
110+
111+
/** Caller must hold `synchronized(tokens)`. */
112+
private fun persist() {
113+
_prefs.saveString(
114+
PreferenceStores.ONESIGNAL,
115+
PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
116+
JSONObject(tokens.toMap()).toString(),
117+
)
118+
}
119+
}

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@ class FeatureFlagTests : FunSpec({
1515
test("SDK_BACKGROUND_THREADING uses the expected remote key") {
1616
FeatureFlag.SDK_BACKGROUND_THREADING.key shouldBe "sdk_background_threading"
1717
}
18+
19+
test("IDENTITY_VERIFICATION uses the expected remote key and IMMEDIATE activation") {
20+
FeatureFlag.IDENTITY_VERIFICATION.key shouldBe "identity_verification"
21+
FeatureFlag.IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE
22+
}
1823
})

0 commit comments

Comments
 (0)