Skip to content
Merged
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
60 changes: 42 additions & 18 deletions OneSignalSDK/detekt/detekt-baseline-core.xml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,34 @@ interface IOneSignal {
*/
fun logout()

/**
* Update the JWT bearer token associated with [externalId]. Use this when your backend
* has issued a new JWT for an already-logged-in user (e.g. in response to a previous
* [IUserJwtInvalidatedListener.onUserJwtInvalidated] callback). Stores the JWT and
* wakes the operation queue so any deferred ops can dispatch with the fresh token.
*
* @param externalId The external ID the JWT belongs to.
* @param token The new JWT bearer token issued by your backend.
*/
fun updateUserJwt(
externalId: String,
token: String,
)

/**
* Subscribe a listener for JWT-invalidated events. Fires on a background thread when
* the SDK detects that the stored JWT for a user is no longer valid (typically after
* a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
* their backend and supplying it via [updateUserJwt].
*
* Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
* event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
*/
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)

/** Unsubscribe a listener previously registered via [addUserJwtInvalidatedListener]. */
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)

// Suspend versions of property accessors and methods to avoid blocking threads

/**
Expand Down Expand Up @@ -226,4 +254,16 @@ interface IOneSignal {
* Logout the current user (suspend version).
*/
suspend fun logoutSuspend()

/**
* Update the JWT bearer token associated with [externalId] (suspend version). Suspends
* until SDK initialization is complete, then stores the JWT and wakes the operation queue.
*
* @param externalId The external ID the JWT belongs to.
* @param token The new JWT bearer token issued by your backend.
*/
suspend fun updateUserJwtSuspend(
externalId: String,
token: String,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.onesignal

/**
* Implement this interface and provide an instance to
* [IOneSignal.addUserJwtInvalidatedListener] to be notified when the SDK has
* detected that the JWT for a user is no longer valid (typically a 401 from
* the OneSignal backend on a request signed with that JWT).
*
* Threading: delivered on a background dispatcher
* (`OneSignalDispatchers.launchOnDefault`). Implementations should not assume a
* specific thread and should re-dispatch to the UI thread if needed.
*
* Pure pub/sub: only listeners subscribed at the time of the invalidation
* receive the event. Subscribe early (e.g. in `Application.onCreate`) to avoid
* missing cold-start 401s.
*/
fun interface IUserJwtInvalidatedListener {
/**
* Called when the JWT is invalidated for [UserJwtInvalidatedEvent.externalId].
* Apps should use this signal to fetch a fresh JWT from their backend and
* supply it via [IOneSignal.updateUserJwt].
*
* @param event Describes which user's JWT was invalidated.
*/
fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent)
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,39 @@ object OneSignal {
@JvmStatic
fun logout() = oneSignal.logout()

/**
* Update the JWT bearer token associated with [externalId]. Use this when your backend
* has issued a new JWT for an already-logged-in user (e.g. in response to a previous
* [IUserJwtInvalidatedListener.onUserJwtInvalidated] callback). Stores the JWT and
* wakes the operation queue so any deferred ops can dispatch with the fresh token.
*
* @param externalId The external ID the JWT belongs to.
* @param token The new JWT bearer token issued by your backend.
*/
@JvmStatic
fun updateUserJwt(
externalId: String,
token: String,
) = oneSignal.updateUserJwt(externalId, token)

/**
* Subscribe a listener for JWT-invalidated events. Fires on a background thread when
* the SDK detects that the stored JWT for a user is no longer valid (typically after
* a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
* their backend and supplying it via [updateUserJwt].
*
* Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
* event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
*/
@JvmStatic
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
oneSignal.addUserJwtInvalidatedListener(listener)

/** Unsubscribe a listener previously registered via [addUserJwtInvalidatedListener]. */
@JvmStatic
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =
oneSignal.removeUserJwtInvalidatedListener(listener)

private val oneSignal: IOneSignal by lazy {
OneSignalImp()
}
Expand Down Expand Up @@ -405,6 +438,21 @@ object OneSignal {
oneSignal.logoutSuspend()
}

/**
* Update the JWT bearer token associated with [externalId] without blocking the calling
* thread. Suspend-safe version of [updateUserJwt].
*
* @param externalId The external ID the JWT belongs to.
* @param token The new JWT bearer token issued by your backend.
*/
@JvmStatic
suspend fun updateUserJwtSuspend(
externalId: String,
token: String,
) {
oneSignal.updateUserJwtSuspend(externalId, token)
}

/**
* Used to retrieve services from the SDK when constructor dependency injection is not an
* option.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.onesignal

/**
* The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated].
* Delivery occurs on a background thread.
*/
class UserJwtInvalidatedEvent(
val externalId: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.onesignal.core.internal.background.impl.BackgroundManager
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.config.impl.ConfigModelStoreListener
import com.onesignal.core.internal.config.impl.FeatureFlagsRefreshService
import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.database.IDatabaseProvider
import com.onesignal.core.internal.database.impl.DatabaseProvider
import com.onesignal.core.internal.device.IDeviceService
Expand Down Expand Up @@ -45,6 +46,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) {
Expand All @@ -68,6 +70,11 @@ internal class CoreModule : IModule {
builder.register<ConfigModelStoreListener>().provides<IStartableService>()
builder.register<FeatureFlagsRefreshService>().provides<IStartableService>()

builder.register<JwtTokenStore>().provides<JwtTokenStore>()
builder.register<IdentityVerificationService>()
.provides<IdentityVerificationService>()
.provides<IStartableService>()

// Operations
builder.register<OperationModelStore>().provides<OperationModelStore>()
builder.register<OperationRepo>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ internal class ParamsBackendService(
return ParamsObject(
googleProjectNumber = responseJson.safeString("android_sender_id"),
enterprise = responseJson.safeBool("enterp"),
// TODO: New
useIdentityVerification = responseJson.safeBool("require_ident_auth"),
useIdentityVerification = responseJson.safeBool("jwt_required"),
Comment thread
nan-li marked this conversation as resolved.
notificationChannels = responseJson.optJSONArray("chnl_lst"),
firebaseAnalytics = responseJson.safeBool("fba"),
restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
},
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,10 +83,10 @@ internal class ConfigModelStoreListener(
config.fcmParams.projectId = params.fcmParams.projectId
config.fcmParams.appId = params.fcmParams.appId
config.fcmParams.apiKey = params.fcmParams.apiKey
config.useIdentityVerification = JwtRequirement.fromBoolean(params.useIdentityVerification ?: false)

// 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.firebaseAnalytics?.let { config.firebaseAnalytics = it }
params.restoreTTLFilter?.let { config.restoreTTLFilter = it }
params.clearGroupOnSummaryClick?.let { config.clearGroupOnSummaryClick = it }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.features.FeatureFlag
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.user.internal.jwt.JwtRequirement

/**
* Single source of truth for Identity Verification gating, and for forwarding HYDRATE events to
* the [com.onesignal.core.internal.operations.IOperationRepo] post-HYDRATE choreography.
*
* Gate state is derived on read from the injected [IFeatureManager] (rollout flag) and
* [ConfigModelStore] (customer `jwt_required`); nothing is duplicated here. UNKNOWN
* (pre-HYDRATE) reads as `false` for both gates, which is the safe default.
*
* Invariant `ivBehaviorActive == true ⇒ newCodePathsRun == true` holds because both are derived
* from the same `useIdentityVerification` field.
*
* Consumers (e.g. OperationRepo) wire post-HYDRATE behavior via [setOnJwtConfigHydratedHandler];
* the handler fires once per HYDRATE with `ivRequired = useIdentityVerification == REQUIRED`.
*/
class IdentityVerificationService(
private val featureManager: IFeatureManager,
private val configModelStore: ConfigModelStore,
) : IStartableService, ISingletonModelStoreChangeHandler<ConfigModel> {
/** Whether IV-specific behavior (JWT attachment, auth error handling) applies. UNKNOWN reads as `false`. */
val ivBehaviorActive: Boolean
get() = configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED

/** Whether new IV-related code paths should run. `featureFlag_IV_ON || jwt_required == REQUIRED`. */
val newCodePathsRun: Boolean
get() = featureManager.isEnabled(FeatureFlag.SDK_IDENTITY_VERIFICATION) || ivBehaviorActive

private val handlerLock = Any()
private var onJwtConfigHydrated: ((ivRequired: Boolean) -> Unit)? = null

/**
* Register a handler invoked once per HYDRATE of the config model. Used by OperationRepo to
* release pre-HYDRATE deferral and (when IV is required) purge anonymous queued ops.
* Pass `null` to clear.
*/
fun setOnJwtConfigHydratedHandler(handler: ((ivRequired: Boolean) -> Unit)?) {
synchronized(handlerLock) {
onJwtConfigHydrated = handler
}
}

override fun start() {
configModelStore.subscribe(this)
}

override fun onModelReplaced(
model: ConfigModel,
tag: String,
) {
if (tag != ModelChangeTags.HYDRATE) return
// Snapshot the handler under the lock, then invoke outside — never hold the lock
// across user-supplied code.
val handler = synchronized(handlerLock) { onJwtConfigHydrated }
handler?.invoke(model.useIdentityVerification == JwtRequirement.REQUIRED)
}

override fun onModelUpdated(
args: ModelChangedArgs,
tag: String,
) {
// Remote params arrive as full-model replacements (HYDRATE); individual property
// updates are not expected for useIdentityVerification.
}
}
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,18 +20,22 @@ 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
) {
// 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<String>): Boolean {
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 Expand Up @@ -163,6 +163,10 @@ internal class FeatureManager(
enabled = enabled,
source = "FeatureManager:${feature.activationMode}"
)

// SDK_IDENTITY_VERIFICATION has no side effect: IdentityVerificationService
// reads featureStates directly via isEnabled() at gate-check time.
FeatureFlag.SDK_IDENTITY_VERIFICATION -> {}
}
}

Expand Down
Loading
Loading