From 65013c3f9d983e7bb1afedfeaf6de4ddb79c9728 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 26 Mar 2026 22:52:47 -0700 Subject: [PATCH 01/29] add public API to interface --- .../src/main/java/com/onesignal/IOneSignal.kt | 12 ++++++++++++ .../onesignal/IUserJwtInvalidatedListener.kt | 15 +++++++++++++++ .../src/main/java/com/onesignal/OneSignal.kt | 18 ++++++++++++++++++ .../com/onesignal/UserJwtInvalidatedEvent.kt | 10 ++++++++++ 4 files changed, 55 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt index e20ddfc2ac..0f48ec1c7c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -226,4 +226,16 @@ interface IOneSignal { * Logout the current user (suspend version). */ suspend fun logoutSuspend() + + /** + * Update the JWT token for a user + */ + fun updateUserJwt( + externalId: String, + token: String, + ) + + fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) + + fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt new file mode 100644 index 0000000000..82cc6e1d7b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt @@ -0,0 +1,15 @@ +package com.onesignal + +/** + * Implement this interface and provide an instance to [OneSignal.addUserJwtInvalidatedListener] + * in order to receive control when the JWT for the current user is invalidated. + * + */ +interface IUserJwtInvalidatedListener { + /** + * Called when the JWT is invalidated + * + * @param event The user JWT that expired. + */ + fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt index 708bbe08f8..b6acf51a8b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -343,6 +343,24 @@ object OneSignal { @JvmStatic fun logout() = oneSignal.logout() + @JvmStatic + fun updateUserJwt( + externalId: String, + token: String, + ) { + oneSignal.updateUserJwt(externalId, token) + } + + @JvmStatic + fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + oneSignal.addUserJwtInvalidatedListener(listener) + } + + @JvmStatic + fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + oneSignal.removeUserJwtInvalidatedListener(listener) + } + private val oneSignal: IOneSignal by lazy { OneSignalImp() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt new file mode 100644 index 0000000000..9c7ddcb87b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt @@ -0,0 +1,10 @@ +package com.onesignal + +/** + * The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated], it provides access + * to the external ID whose JWT has just been invalidated. + * + */ +class UserJwtInvalidatedEvent( + val externalId: String, +) From b6f3e4e384769fcce7943e7e4351820219191263 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 16:16:29 -0700 Subject: [PATCH 02/29] Add JwtTokenStore, Operation.externalId, ConfigModel.useIdentityVerification nullable, OptionalHeaders.jwt Foundational models and infrastructure for identity verification: - Create JwtTokenStore: persistent Map backed by SharedPreferences, supporting multi-user JWT storage with getJwt/putJwt/invalidateJwt/pruneToExternalIds - Add var externalId to Operation base class so OperationRepo can stamp and gate operations per-user; remove redundant externalId from LoginUserOperation and TrackCustomEventOperation (same Model data-map key, no migration needed) - Change ConfigModel.useIdentityVerification from Boolean to Boolean? (null = unknown, false = off, true = on) to eliminate race between operation processing and remote params - Add jwt field to OptionalHeaders for passing Bearer tokens through HTTP layer - Add PREFS_OS_JWT_TOKENS key to PreferenceOneSignalKeys Made-with: Cursor --- .../core/internal/config/ConfigModel.kt | 11 +- .../internal/http/impl/OptionalHeaders.kt | 5 + .../core/internal/operations/Operation.kt | 12 +++ .../preferences/IPreferencesService.kt | 7 ++ .../user/internal/identity/JwtTokenStore.kt | 100 ++++++++++++++++++ .../internal/operations/LoginUserOperation.kt | 9 -- .../operations/TrackCustomEventOperation.kt | 9 -- 7 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt 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 a88739e05e..86ac5f56bf 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 @@ -237,12 +237,15 @@ class ConfigModel : Model() { } /** - * Whether SMS auth hash should be used. + * Whether identity verification (JWT) is required for this application. + * - `null` = unknown (remote params haven't arrived yet; all operations are held) + * - `false` = explicitly disabled (SDK behaves as today, no JWT gating) + * - `true` = enabled (operations require a valid JWT, anonymous users are blocked) */ - var useIdentityVerification: Boolean - get() = getBooleanProperty(::useIdentityVerification.name) { false } + var useIdentityVerification: Boolean? + get() = getOptBooleanProperty(::useIdentityVerification.name) set(value) { - setBooleanProperty(::useIdentityVerification.name, value) + setOptBooleanProperty(::useIdentityVerification.name, value) } /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt index f566fd04fc..8a0f3e7c95 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt @@ -17,4 +17,9 @@ data class OptionalHeaders( * Used to track delay between session start and request */ val sessionDuration: Long? = null, + /** + * JWT bearer token for identity verification. When non-null, sent as + * `Authorization: Bearer ` on the request. + */ + val jwt: String? = null, ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt index 76f51994ab..64a9b5c80c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt @@ -16,6 +16,18 @@ abstract class Operation(name: String) : Model() { setStringProperty(::name.name, value) } + /** + * The external ID of the user this operation belongs to. Used by [IOperationRepo] to look up + * the correct JWT when identity verification is enabled, and to gate anonymous operations. + * Stamped automatically by [IOperationRepo] at enqueue time from the current identity model + * when not already set by the concrete operation's constructor. + */ + var externalId: String? + get() = getOptStringProperty(::externalId.name) + set(value) { + setOptStringProperty(::externalId.name, value) + } + init { this.name = name } 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 f4d4b92a5d..0c9f47c517 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 @@ -272,6 +272,13 @@ object PreferenceOneSignalKeys { */ const val PREFS_OS_IAM_LAST_DISMISSED_TIME = "PREFS_OS_IAM_LAST_DISMISSED_TIME" + // Identity Verification + + /** + * (String) JSON map of externalId -> JWT token for identity verification. + */ + const val PREFS_OS_JWT_TOKENS = "PREFS_OS_JWT_TOKENS" + // Models /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt new file mode 100644 index 0000000000..e4975b29bb --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt @@ -0,0 +1,100 @@ +package com.onesignal.user.internal.identity + +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import org.json.JSONObject + +/** + * Persistent store mapping externalId -> JWT token. Supports multiple users simultaneously + * so that queued operations for a previous user can still resolve their JWT at execution time. + * + * Storage is unconditional (callers store JWTs regardless of the identity-verification flag). + * Only *usage* of JWTs (Authorization header, gating, alias resolution) is gated on + * [com.onesignal.core.internal.config.ConfigModel.useIdentityVerification]. + */ +class JwtTokenStore( + private val _prefs: IPreferencesService, +) { + private val tokens: MutableMap = mutableMapOf() + private var isLoaded = false + + /** Not thread-safe; callers must hold `synchronized(tokens)`. */ + private fun ensureLoaded() { + if (isLoaded) return + val json = + _prefs.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS, + ) + if (json != null) { + val obj = JSONObject(json) + for (key in obj.keys()) { + tokens[key] = obj.getString(key) + } + } + isLoaded = true + } + + /** Not thread-safe; callers must hold `synchronized(tokens)`. */ + private fun persist() { + _prefs.saveString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS, + JSONObject(tokens.toMap()).toString(), + ) + } + + /** + * Returns the JWT for the given [externalId], or null if none is stored. + */ + fun getJwt(externalId: String): String? { + synchronized(tokens) { + ensureLoaded() + return tokens[externalId] + } + } + + /** + * Stores (or replaces) the JWT for [externalId]. Passing a null [jwt] is a no-op; + * use [invalidateJwt] to remove a token. + */ + fun putJwt( + externalId: String, + jwt: String?, + ) { + if (jwt == null) return + synchronized(tokens) { + ensureLoaded() + tokens[externalId] = jwt + persist() + } + } + + /** + * Removes the JWT for [externalId], marking it as invalid. Operations for this user + * will be held until a new JWT is provided via [putJwt]. + */ + fun invalidateJwt(externalId: String) { + synchronized(tokens) { + ensureLoaded() + if (tokens.remove(externalId) != null) { + persist() + } + } + } + + /** + * Removes all stored JWTs whose externalId is NOT in [activeIds]. + * Called on cold start after loading persisted operations to prevent unbounded growth. + */ + fun pruneToExternalIds(activeIds: Set) { + synchronized(tokens) { + ensureLoaded() + val removed = tokens.keys.retainAll(activeIds) + if (removed) { + persist() + } + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt index b283cc3da0..9164ab39ca 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt @@ -32,15 +32,6 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { setStringProperty(::onesignalId.name, value) } - /** - * The optional external ID of this newly logged-in user. Must be unique for the [appId]. - */ - var externalId: String? - get() = getOptStringProperty(::externalId.name) - private set(value) { - setOptStringProperty(::externalId.name, value) - } - /** * The user ID of an existing user the [externalId] will be attempted to be associated to first. * When null (or non-null but unsuccessful), a new user will be upserted. This ID *may* be locally generated diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt index b510a4fd3f..04956e1877 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt @@ -30,15 +30,6 @@ class TrackCustomEventOperation() : Operation(CustomEventOperationExecutor.CUSTO setStringProperty(::onesignalId.name, value) } - /** - * The optional external ID of current logged-in user. Must be unique for the [appId]. - */ - var externalId: String? - get() = getOptStringProperty(::externalId.name) - private set(value) { - setOptStringProperty(::externalId.name, value) - } - /** * The timestamp when the custom event was created. */ From 372d924a01a9730b6ee747fbfbbf3216c05b95c6 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 16:58:11 -0700 Subject: [PATCH 03/29] Add jwt parameter to backend service interfaces/impls, add Authorization Bearer header to HttpClient Identity verification: plumb JWT through the HTTP and backend layer. - HttpClient: set Authorization: Bearer header when OptionalHeaders.jwt is non-null - IIdentityBackendService + impl: add jwt param to setAlias, deleteAlias - ISubscriptionBackendService + impl: add jwt param to createSubscription, updateSubscription, deleteSubscription, transferSubscription, getIdentityFromSubscription - IUserBackendService + impl: add jwt param to createUser, updateUser, getUser - All jwt params default to null so existing callers are unaffected --- .../core/internal/http/impl/HttpClient.kt | 4 ++++ .../internal/backend/IIdentityBackendService.kt | 2 ++ .../backend/ISubscriptionBackendService.kt | 5 +++++ .../user/internal/backend/IUserBackendService.kt | 3 +++ .../backend/impl/IdentityBackendService.kt | 7 +++++-- .../backend/impl/SubscriptionBackendService.kt | 16 +++++++++++----- .../internal/backend/impl/UserBackendService.kt | 10 +++++++--- .../customEvents/ICustomEventBackendService.kt | 1 + .../impl/CustomEventBackendService.kt | 4 +++- 9 files changed, 41 insertions(+), 11 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index d1ea2036c2..b01a118a87 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -195,6 +195,10 @@ internal class HttpClient( con.setRequestProperty("OneSignal-Session-Duration", headers.sessionDuration.toString()) } + if (headers?.jwt != null) { + con.setRequestProperty("Authorization", "Bearer ${headers.jwt}") + } + // Network request is made from getResponseCode() httpResponse = con.responseCode diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt index 4278d8002b..b59dd71917 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt @@ -18,6 +18,7 @@ interface IIdentityBackendService { aliasLabel: String, aliasValue: String, identities: Map, + jwt: String? = null, ): Map /** @@ -35,6 +36,7 @@ interface IIdentityBackendService { aliasLabel: String, aliasValue: String, aliasLabelToDelete: String, + jwt: String? = null, ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt index 7bcf23fdb2..e6e65bff1f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt @@ -22,6 +22,7 @@ interface ISubscriptionBackendService { aliasLabel: String, aliasValue: String, subscription: SubscriptionObject, + jwt: String? = null, ): Pair? /** @@ -35,6 +36,7 @@ interface ISubscriptionBackendService { appId: String, subscriptionId: String, subscription: SubscriptionObject, + jwt: String? = null, ): RywData? /** @@ -46,6 +48,7 @@ interface ISubscriptionBackendService { suspend fun deleteSubscription( appId: String, subscriptionId: String, + jwt: String? = null, ) /** @@ -61,6 +64,7 @@ interface ISubscriptionBackendService { subscriptionId: String, aliasLabel: String, aliasValue: String, + jwt: String? = null, ) /** @@ -74,5 +78,6 @@ interface ISubscriptionBackendService { suspend fun getIdentityFromSubscription( appId: String, subscriptionId: String, + jwt: String? = null, ): Map } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt index 4cec114b5a..b849fc4c42 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt @@ -24,6 +24,7 @@ interface IUserBackendService { identities: Map, subscriptions: List, properties: Map, + jwt: String? = null, ): CreateUserResponse // TODO: Change to send only the push subscription, optimally @@ -48,6 +49,7 @@ interface IUserBackendService { properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, + jwt: String? = null, ): RywData? /** @@ -65,6 +67,7 @@ interface IUserBackendService { appId: String, aliasLabel: String, aliasValue: String, + jwt: String? = null, ): CreateUserResponse } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt index adfff7bdc9..614b8a3bf3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/IdentityBackendService.kt @@ -4,6 +4,7 @@ import com.onesignal.common.exceptions.BackendException import com.onesignal.common.putMap import com.onesignal.common.toMap import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders import com.onesignal.user.internal.backend.IIdentityBackendService import org.json.JSONObject @@ -15,12 +16,13 @@ internal class IdentityBackendService( aliasLabel: String, aliasValue: String, identities: Map, + jwt: String?, ): Map { val requestJSONObject = JSONObject() .put("identity", JSONObject().putMap(identities)) - val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue/identity", requestJSONObject) + val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue/identity", requestJSONObject, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -36,8 +38,9 @@ internal class IdentityBackendService( aliasLabel: String, aliasValue: String, aliasLabelToDelete: String, + jwt: String?, ) { - val response = _httpClient.delete("apps/$appId/users/by/$aliasLabel/$aliasValue/identity/$aliasLabelToDelete") + val response = _httpClient.delete("apps/$appId/users/by/$aliasLabel/$aliasValue/identity/$aliasLabelToDelete", jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt index a2266d4d36..1003dd84c5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt @@ -7,6 +7,7 @@ import com.onesignal.common.safeLong import com.onesignal.common.safeString import com.onesignal.common.toMap import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders import com.onesignal.user.internal.backend.ISubscriptionBackendService import com.onesignal.user.internal.backend.SubscriptionObject import org.json.JSONObject @@ -19,11 +20,12 @@ internal class SubscriptionBackendService( aliasLabel: String, aliasValue: String, subscription: SubscriptionObject, + jwt: String?, ): Pair? { val jsonSubscription = JSONConverter.convertToJSON(subscription) val requestJSON = JSONObject().put("subscription", jsonSubscription) - val response = _httpClient.post("apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions", requestJSON) + val response = _httpClient.post("apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions", requestJSON, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -50,12 +52,13 @@ internal class SubscriptionBackendService( appId: String, subscriptionId: String, subscription: SubscriptionObject, + jwt: String?, ): RywData? { val requestJSON = JSONObject() .put("subscription", JSONConverter.convertToJSON(subscription)) - val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId", requestJSON) + val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId", requestJSON, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -76,8 +79,9 @@ internal class SubscriptionBackendService( override suspend fun deleteSubscription( appId: String, subscriptionId: String, + jwt: String?, ) { - val response = _httpClient.delete("apps/$appId/subscriptions/$subscriptionId") + val response = _httpClient.delete("apps/$appId/subscriptions/$subscriptionId", jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -89,12 +93,13 @@ internal class SubscriptionBackendService( subscriptionId: String, aliasLabel: String, aliasValue: String, + jwt: String?, ) { val requestJSON = JSONObject() .put("identity", JSONObject().put(aliasLabel, aliasValue)) - val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId/owner", requestJSON) + val response = _httpClient.patch("apps/$appId/subscriptions/$subscriptionId/owner", requestJSON, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -104,8 +109,9 @@ internal class SubscriptionBackendService( override suspend fun getIdentityFromSubscription( appId: String, subscriptionId: String, + jwt: String?, ): Map { - val response = _httpClient.get("apps/$appId/subscriptions/$subscriptionId/user/identity") + val response = _httpClient.get("apps/$appId/subscriptions/$subscriptionId/user/identity", jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt index 1a1514018f..8a5c58d691 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt @@ -6,6 +6,7 @@ import com.onesignal.common.putMap import com.onesignal.common.safeLong import com.onesignal.common.safeString import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders import com.onesignal.user.internal.backend.CreateUserResponse import com.onesignal.user.internal.backend.IUserBackendService import com.onesignal.user.internal.backend.PropertiesDeltasObject @@ -21,6 +22,7 @@ internal class UserBackendService( identities: Map, subscriptions: List, properties: Map, + jwt: String?, ): CreateUserResponse { val requestJSON = JSONObject() @@ -39,7 +41,7 @@ internal class UserBackendService( requestJSON.put("refresh_device_metadata", true) - val response = _httpClient.post("apps/$appId/users", requestJSON) + val response = _httpClient.post("apps/$appId/users", requestJSON, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -55,6 +57,7 @@ internal class UserBackendService( properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, + jwt: String?, ): RywData? { val jsonObject = JSONObject() @@ -68,7 +71,7 @@ internal class UserBackendService( jsonObject.put("deltas", JSONConverter.convertToJSON(propertyiesDelta)) } - val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue", jsonObject) + val response = _httpClient.patch("apps/$appId/users/by/$aliasLabel/$aliasValue", jsonObject, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) @@ -90,8 +93,9 @@ internal class UserBackendService( appId: String, aliasLabel: String, aliasValue: String, + jwt: String?, ): CreateUserResponse { - val response = _httpClient.get("apps/$appId/users/by/$aliasLabel/$aliasValue") + val response = _httpClient.get("apps/$appId/users/by/$aliasLabel/$aliasValue", jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt index 92474635ab..8c624f1f76 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt @@ -20,5 +20,6 @@ interface ICustomEventBackendService { eventName: String, eventProperties: String?, metadata: CustomEventMetadata, + jwt: String? = null, ): ExecutionResponse } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt index 096fa67456..eccd67b650 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal.customEvents.impl import com.onesignal.common.DateUtils import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.user.internal.customEvents.ICustomEventBackendService @@ -21,6 +22,7 @@ internal class CustomEventBackendService( eventName: String, eventProperties: String?, metadata: CustomEventMetadata, + jwt: String?, ): ExecutionResponse { val body = JSONObject() body.put("name", eventName) @@ -42,7 +44,7 @@ internal class CustomEventBackendService( body.put("payload", payload) val jsonObject = JSONObject().put("events", JSONArray().put(body)) - val response = httpClient.post("apps/$appId/custom_events", jsonObject) + val response = httpClient.post("apps/$appId/custom_events", jsonObject, jwt?.let { OptionalHeaders(jwt = it) }) if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) From 12a918bab8bc40b17c04250bd6067b3b66d7ab41 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 17:09:50 -0700 Subject: [PATCH 04/29] Add JWT gating, centralized externalId stamping, and FAIL_UNAUTHORIZED handling to OperationRepo Identity verification: OperationRepo becomes JWT-aware. - Add JwtTokenStore and IdentityModelStore as constructor dependencies - Centralized externalId stamping: internalEnqueue() auto-sets op.externalId from the current identity model for new operations (not loaded from persistence, not already set by the operation's constructor) - IV gating in getNextOps(): when IV=null (unknown), hold ALL operations; when IV=true, skip operations without a valid JWT; when IV=false, proceed normally - FAIL_UNAUTHORIZED: invalidate the per-user JWT in JwtTokenStore and re-queue operations to front (held by JWT gating until a new JWT is provided) - Cold-start cleanup: prune JwtTokenStore to externalIds from pending operations plus the current identity model's externalId - New removeOperationsWithoutExternalId() method on IOperationRepo for IdentityVerificationService to purge anonymous operations when IV is enabled --- .../internal/operations/IOperationRepo.kt | 7 +++ .../internal/operations/impl/OperationRepo.kt | 63 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt index d2dceea5c3..6bc70bffc1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt @@ -42,6 +42,13 @@ interface IOperationRepo { suspend fun awaitInitialized() fun forceExecuteOperations() + + /** + * Remove all queued operations that have no externalId (anonymous operations). + * Used by IdentityVerificationService when identity verification is enabled to + * purge operations that cannot be executed without an authenticated user. + */ + fun removeOperationsWithoutExternalId() } // Extension function so the syntax containsInstanceOf() can be used over diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 9b39566d17..fc15c740de 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -11,6 +11,8 @@ import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.impl.states.NewRecordsState import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -28,6 +30,8 @@ internal class OperationRepo( private val _configModelStore: ConfigModelStore, private val _time: ITime, private val _newRecordState: NewRecordsState, + private val _jwtTokenStore: JwtTokenStore, + private val _identityModelStore: IdentityModelStore, ) : IOperationRepo, IStartableService { internal class OperationQueueItem( val operation: Operation, @@ -154,6 +158,13 @@ internal class OperationRepo( addToStore: Boolean, index: Int? = null, ) { + // Stamp externalId on new operations from the current identity model. + // Operations loaded from persistence (addToStore=false) already have their externalId. + // Operations that set externalId in their constructor (e.g. LoginUserOperation) are skipped. + if (addToStore && queueItem.operation.externalId == null) { + queueItem.operation.externalId = _identityModelStore.model.externalId + } + synchronized(queue) { val hasExisting = queue.any { it.operation.id == queueItem.operation.id } if (hasExisting) { @@ -268,7 +279,20 @@ internal class OperationRepo( ops.forEach { _operationModelStore.remove(it.operation.id) } ops.forEach { it.waiter?.wake(true) } } - ExecutionResult.FAIL_UNAUTHORIZED, // TODO: Need to provide callback for app to reset JWT. For now, fail with no retry. + ExecutionResult.FAIL_UNAUTHORIZED -> { + val externalId = startingOp.operation.externalId + if (externalId != null) { + _jwtTokenStore.invalidateJwt(externalId) + Logging.warn("Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. Operations re-queued.") + synchronized(queue) { + ops.reversed().forEach { queue.add(0, it) } + } + } else { + Logging.warn("Operation execution failed with 401 Unauthorized for anonymous user. Operations dropped.") + ops.forEach { _operationModelStore.remove(it.operation.id) } + ops.forEach { it.waiter?.wake(false) } + } + } ExecutionResult.FAIL_NORETRY, ExecutionResult.FAIL_CONFLICT, -> { @@ -372,12 +396,16 @@ internal class OperationRepo( } internal fun getNextOps(bucketFilter: Int): List? { + val iv = _configModelStore.model.useIdentityVerification + if (iv == null) return null + return synchronized(queue) { val startingOp = queue.firstOrNull { it.operation.canStartExecute && _newRecordState.canAccess(it.operation.applyToRecordId) && - it.bucket <= bucketFilter + it.bucket <= bucketFilter && + hasValidJwtIfRequired(iv, it.operation) } if (startingOp != null) { @@ -389,6 +417,15 @@ internal class OperationRepo( } } + private fun hasValidJwtIfRequired( + iv: Boolean, + op: Operation, + ): Boolean { + if (!iv) return true + val externalId = op.externalId ?: return false + return _jwtTokenStore.getJwt(externalId) != null + } + /** * Given a starting operation, find and remove from the queue all other operations that * can be executed along with the starting operation. The full list of operations, with @@ -450,6 +487,28 @@ internal class OperationRepo( index = 0, ) } + + val activeExternalIds = + synchronized(queue) { + queue.mapNotNull { it.operation.externalId }.toMutableSet() + } + _identityModelStore.model.externalId?.let { activeExternalIds.add(it) } + _jwtTokenStore.pruneToExternalIds(activeExternalIds) + initialized.complete(Unit) } + + override fun removeOperationsWithoutExternalId() { + synchronized(queue) { + val toRemove = queue.filter { it.operation.externalId == null } + toRemove.forEach { + queue.remove(it) + _operationModelStore.remove(it.operation.id) + it.waiter?.wake(false) + } + if (toRemove.isNotEmpty()) { + Logging.debug("OperationRepo: removed ${toRemove.size} anonymous operations (no externalId)") + } + } + } } From 2295fe79f14cab5c6fdcd21e0237d87702a1a188 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 17:45:18 -0700 Subject: [PATCH 05/29] Update all operation executors to resolve JWT and alias based on identity verification - Add resolveIdentityAlias() helper to dynamically choose external_id vs onesignal_id for API paths - Each executor now looks up JWT from JwtTokenStore using operation's externalId and passes it to backend calls - LoginUserFromSubscriptionOperationExecutor returns FAIL_NORETRY when IV is enabled (v4 migration safety net) Made-with: Cursor --- .../backend/IIdentityBackendService.kt | 20 +++++++++++ .../executors/CustomEventOperationExecutor.kt | 4 +++ .../executors/IdentityOperationExecutor.kt | 30 +++++++++++++--- ...inUserFromSubscriptionOperationExecutor.kt | 7 ++++ .../executors/LoginUserOperationExecutor.kt | 5 ++- .../executors/RefreshUserOperationExecutor.kt | 15 ++++++-- .../SubscriptionOperationExecutor.kt | 35 +++++++++++++++---- .../executors/UpdateUserOperationExecutor.kt | 18 ++++++++-- 8 files changed, 119 insertions(+), 15 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt index b59dd71917..a09f40ca68 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IIdentityBackendService.kt @@ -1,6 +1,7 @@ package com.onesignal.user.internal.backend import com.onesignal.common.exceptions.BackendException +import com.onesignal.debug.internal.logging.Logging interface IIdentityBackendService { /** @@ -50,4 +51,23 @@ object IdentityConstants { * The alias label for the internal onesignal ID alias. */ const val ONESIGNAL_ID = "onesignal_id" + + /** + * Resolves which alias (external_id vs onesignal_id) should be used in backend API paths. + * When identity verification is enabled and the operation has an externalId, routes through + * external_id; otherwise falls back to onesignal_id. + */ + fun resolveAlias( + useIdentityVerification: Boolean?, + externalId: String?, + onesignalId: String, + ): Pair { + if (useIdentityVerification == true) { + if (externalId != null) { + return EXTERNAL_ID to externalId + } + Logging.error("Identity verification is enabled but externalId is null. Falling back to onesignal_id.") + } + return ONESIGNAL_ID to onesignalId + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt index 2e1046e6c6..14166713df 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt @@ -13,12 +13,14 @@ import com.onesignal.core.internal.operations.IOperationExecutor import com.onesignal.core.internal.operations.Operation import com.onesignal.user.internal.customEvents.ICustomEventBackendService import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.TrackCustomEventOperation internal class CustomEventOperationExecutor( private val customEventBackendService: ICustomEventBackendService, private val applicationService: IApplicationService, private val deviceService: IDeviceService, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(CUSTOM_EVENT) @@ -40,6 +42,7 @@ internal class CustomEventOperationExecutor( try { when (operation) { is TrackCustomEventOperation -> { + val jwt = operation.externalId?.let { _jwtTokenStore.getJwt(it) } customEventBackendService.sendCustomEvent( operation.appId, operation.onesignalId, @@ -48,6 +51,7 @@ internal class CustomEventOperationExecutor( operation.eventName, operation.eventProperties, eventMetadataJson, + jwt, ) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt index 104fe9569f..f12831c3f9 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal.operations.impl.executors import com.onesignal.common.NetworkUtils import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -12,6 +13,7 @@ import com.onesignal.user.internal.backend.IIdentityBackendService import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.DeleteAliasOperation import com.onesignal.user.internal.operations.SetAliasOperation import com.onesignal.user.internal.operations.impl.states.NewRecordsState @@ -21,6 +23,8 @@ internal class IdentityOperationExecutor( private val _identityModelStore: IdentityModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, + private val _configModelStore: ConfigModelStore, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(SET_ALIAS, DELETE_ALIAS) @@ -44,12 +48,21 @@ internal class IdentityOperationExecutor( val lastOperation = operations.last() if (lastOperation is SetAliasOperation) { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + lastOperation.externalId, + lastOperation.onesignalId, + ) + val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + try { _identityBackend.setAlias( lastOperation.appId, - IdentityConstants.ONESIGNAL_ID, - lastOperation.onesignalId, + aliasLabel, + aliasValue, mapOf(lastOperation.label to lastOperation.value), + jwt, ) // ensure the now created alias is in the model as long as the user is still current. @@ -87,12 +100,21 @@ internal class IdentityOperationExecutor( } } } else if (lastOperation is DeleteAliasOperation) { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + lastOperation.externalId, + lastOperation.onesignalId, + ) + val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + try { _identityBackend.deleteAlias( lastOperation.appId, - IdentityConstants.ONESIGNAL_ID, - lastOperation.onesignalId, + aliasLabel, + aliasValue, lastOperation.label, + jwt, ) // ensure the now deleted alias is not in the model as long as the user is still current. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt index 84093eeccb..cf63ab2e20 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserFromSubscriptionOperationExecutor.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal.operations.impl.executors import com.onesignal.common.NetworkUtils import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -20,6 +21,7 @@ internal class LoginUserFromSubscriptionOperationExecutor( private val _subscriptionBackend: ISubscriptionBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, + private val _configModelStore: ConfigModelStore, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER_FROM_SUBSCRIPTION_USER) @@ -27,6 +29,11 @@ internal class LoginUserFromSubscriptionOperationExecutor( override suspend fun execute(operations: List): ExecutionResponse { Logging.debug("LoginUserFromSubscriptionOperationExecutor(operation: $operations)") + if (_configModelStore.model.useIdentityVerification == true) { + Logging.warn("LoginUserFromSubscriptionOperation is not supported when identity verification is enabled. Dropping.") + return ExecutionResponse(ExecutionResult.FAIL_NORETRY) + } + if (operations.size > 1) { throw Exception("Only supports one operation! Attempted operations:\n$operations") } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt index 46968b3e71..c80adff4ae 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt @@ -24,6 +24,7 @@ import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.SubscriptionObject import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.CreateSubscriptionOperation import com.onesignal.user.internal.operations.DeleteSubscriptionOperation import com.onesignal.user.internal.operations.LoginUserOperation @@ -47,6 +48,7 @@ internal class LoginUserOperationExecutor( private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER) @@ -168,7 +170,8 @@ internal class LoginUserOperationExecutor( try { val subscriptionList = subscriptions.toList() - val response = _userBackend.createUser(createUserOperation.appId, identities, subscriptionList.map { it.second }, properties) + val jwt = createUserOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + val response = _userBackend.createUser(createUserOperation.appId, identities, subscriptionList.map { it.second }, properties, jwt) val idTranslations = mutableMapOf() // Add the "local-to-backend" ID translation to the IdentifierTranslator for any operations that were // *not* executed but still reference the locally-generated IDs. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt index d7bfa0f671..02e10bbc22 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt @@ -17,6 +17,7 @@ import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.RefreshUserOperation import com.onesignal.user.internal.operations.impl.states.NewRecordsState import com.onesignal.user.internal.properties.PropertiesModel @@ -34,6 +35,7 @@ internal class RefreshUserOperationExecutor( private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(REFRESH_USER) @@ -54,12 +56,21 @@ internal class RefreshUserOperationExecutor( } private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + op.externalId, + op.onesignalId, + ) + val jwt = op.externalId?.let { _jwtTokenStore.getJwt(it) } + try { val response = _userBackend.getUser( op.appId, - IdentityConstants.ONESIGNAL_ID, - op.onesignalId, + aliasLabel, + aliasValue, + jwt, ) if (op.onesignalId != _identityModelStore.model.onesignalId) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 81ab0bb687..97d78ec4d7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -26,6 +26,7 @@ import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.SubscriptionObject import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.CreateSubscriptionOperation import com.onesignal.user.internal.operations.DeleteSubscriptionOperation import com.onesignal.user.internal.operations.TransferSubscriptionOperation @@ -44,6 +45,7 @@ internal class SubscriptionOperationExecutor( private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(CREATE_SUBSCRIPTION, UPDATE_SUBSCRIPTION, DELETE_SUBSCRIPTION, TRANSFER_SUBSCRIPTION) @@ -107,12 +109,21 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + createOperation.externalId, + createOperation.onesignalId, + ) + val jwt = createOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + val result = _subscriptionBackend.createSubscription( createOperation.appId, - IdentityConstants.ONESIGNAL_ID, - createOperation.onesignalId, + aliasLabel, + aliasValue, subscription, + jwt, ) ?: return ExecutionResponse(ExecutionResult.SUCCESS) val backendSubscriptionId = result.first @@ -190,7 +201,8 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) - val rywData = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription) + val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + val rywData = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription, jwt) if (rywData != null) { _consistencyManager.setRywData(startingOperation.onesignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywData) @@ -239,12 +251,21 @@ internal class SubscriptionOperationExecutor( // TODO: whenever the end-user changes users, we need to add the read-your-write token here, currently no code to handle the re-fetch IAMs private suspend fun transferSubscription(startingOperation: TransferSubscriptionOperation): ExecutionResponse { + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + startingOperation.externalId, + startingOperation.onesignalId, + ) + val jwt = startingOperation.externalId?.let { _jwtTokenStore.getJwt(it) } + try { _subscriptionBackend.transferSubscription( startingOperation.appId, startingOperation.subscriptionId, - IdentityConstants.ONESIGNAL_ID, - startingOperation.onesignalId, + aliasLabel, + aliasValue, + jwt, ) } catch (ex: BackendException) { val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) @@ -275,8 +296,10 @@ internal class SubscriptionOperationExecutor( } private suspend fun deleteSubscription(op: DeleteSubscriptionOperation): ExecutionResponse { + val jwt = op.externalId?.let { _jwtTokenStore.getJwt(it) } + try { - _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId) + _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId, jwt) // remove the subscription model as a HYDRATE in case for some reason it still exists. _subscriptionModelStore.remove(op.subscriptionId, ModelChangeTags.HYDRATE) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt index e529035ec1..090b3f3904 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt @@ -6,6 +6,7 @@ import com.onesignal.common.consistency.enums.IamFetchRywTokenKey import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -19,6 +20,7 @@ import com.onesignal.user.internal.backend.PropertiesObject import com.onesignal.user.internal.backend.PurchaseObject import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.DeleteTagOperation import com.onesignal.user.internal.operations.SetPropertyOperation import com.onesignal.user.internal.operations.SetTagOperation @@ -35,6 +37,8 @@ internal class UpdateUserOperationExecutor( private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, + private val _configModelStore: ConfigModelStore, + private val _jwtTokenStore: JwtTokenStore, ) : IOperationExecutor { override val operations: List get() = listOf(SET_TAG, DELETE_TAG, SET_PROPERTY, TRACK_SESSION_START, TRACK_SESSION_END, TRACK_PURCHASE) @@ -137,15 +141,25 @@ internal class UpdateUserOperationExecutor( } if (appId != null && onesignalId != null) { + val firstOp = operations.first() + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + firstOp.externalId, + onesignalId, + ) + val jwt = firstOp.externalId?.let { _jwtTokenStore.getJwt(it) } + try { val rywData = _userBackend.updateUser( appId, - IdentityConstants.ONESIGNAL_ID, - onesignalId, + aliasLabel, + aliasValue, propertiesObject, refreshDeviceMetadata, deltasObject, + jwt, ) if (rywData != null) { From 1f6a60b29e159aec682c15c7d0f2a403a85efb2a Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 17:59:42 -0700 Subject: [PATCH 06/29] Add JWT to In-App Messages backend calls, guard anonymous IAM fetch - Add jwt param to IInAppBackendService.listInAppMessages and pass through OptionalHeaders - Skip IAM fetch when identity verification is enabled and user is anonymous - Look up JWT from JwtTokenStore for authenticated IAM requests Made-with: Cursor --- .../inAppMessages/internal/InAppMessagesManager.kt | 12 +++++++++++- .../internal/backend/IInAppBackendService.kt | 1 + .../internal/backend/impl/InAppBackendService.kt | 9 +++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index b4994a0aeb..09a37c6491 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -48,6 +48,7 @@ import com.onesignal.user.IUserManager import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionChangedHandler import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -76,6 +77,7 @@ internal class InAppMessagesManager( private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, ) : IInAppMessagesManager, IStartableService, ISubscriptionChangedHandler, @@ -299,6 +301,12 @@ internal class InAppMessagesManager( return } + val externalId = _identityModelStore.model.externalId + if (_configModelStore.model.useIdentityVerification == true && externalId == null) { + Logging.debug("InAppMessagesManager.fetchMessages: Skipping IAM fetch for anonymous user while identity verification is enabled.") + return + } + fetchIAMMutex.withLock { val now = _time.currentTimeMillis if (lastTimeFetchedIAMs != null && (now - lastTimeFetchedIAMs!!) < _configModelStore.model.fetchIAMMinInterval) { @@ -308,9 +316,11 @@ internal class InAppMessagesManager( lastTimeFetchedIAMs = now } + val jwt = externalId?.let { _jwtTokenStore.getJwt(it) } + // lambda so that it is updated on each potential retry val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime } - val newMessages = _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider) + val newMessages = _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider, jwt) if (newMessages != null) { this.messages = newMessages as MutableList diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt index 6755b6eb5a..e98761f235 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt @@ -24,6 +24,7 @@ internal interface IInAppBackendService { subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? /** diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index 9bbd738d55..fa046a6c70 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -29,12 +29,13 @@ internal class InAppBackendService( subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String?, ): List? { val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS delay(rywDelay) // Delay by the specified amount val baseUrl = "apps/$appId/subscriptions/$subscriptionId/iams" - return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider) + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } override suspend fun getIAMData( @@ -209,6 +210,7 @@ internal class InAppBackendService( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? { var attempts = 0 var retryLimit: Int = 0 // retry limit is remote defined & set dynamically below @@ -220,6 +222,7 @@ internal class InAppBackendService( rywToken = rywData.rywToken, sessionDuration = sessionDurationProvider(), retryCount = retryCount, + jwt = jwt, ) val response = _httpClient.get(baseUrl, values) @@ -244,18 +247,20 @@ internal class InAppBackendService( } while (attempts <= retryLimit) // Final attempt without the RYW token if retries fail - return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider) + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) } private suspend fun fetchInAppMessagesWithoutRywToken( url: String, sessionDurationProvider: () -> Long, + jwt: String? = null, ): List? { val response = _httpClient.get( url, OptionalHeaders( sessionDuration = sessionDurationProvider(), + jwt = jwt, ), ) From 38f250b57eac7748c3bf1925894282d888fe9f65 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 19:54:49 -0700 Subject: [PATCH 07/29] Use alias-based IAM fetch endpoint: /users/by/:alias_label/:alias_id/subscriptions/:subscription_id/iams Made-with: Cursor --- .../inAppMessages/internal/InAppMessagesManager.kt | 8 +++++++- .../internal/backend/IInAppBackendService.kt | 2 ++ .../internal/backend/impl/InAppBackendService.kt | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index 09a37c6491..80e4ba4f93 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -316,11 +316,17 @@ internal class InAppMessagesManager( lastTimeFetchedIAMs = now } + val (aliasLabel, aliasValue) = + IdentityConstants.resolveAlias( + _configModelStore.model.useIdentityVerification, + externalId, + _identityModelStore.model.onesignalId, + ) val jwt = externalId?.let { _jwtTokenStore.getJwt(it) } // lambda so that it is updated on each potential retry val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime } - val newMessages = _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider, jwt) + val newMessages = _backend.listInAppMessages(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) if (newMessages != null) { this.messages = newMessages as MutableList diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt index e98761f235..7044d6db3b 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt @@ -21,6 +21,8 @@ internal interface IInAppBackendService { */ suspend fun listInAppMessages( appId: String, + aliasLabel: String, + aliasValue: String, subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index fa046a6c70..77a77b5f5f 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -26,6 +26,8 @@ internal class InAppBackendService( override suspend fun listInAppMessages( appId: String, + aliasLabel: String, + aliasValue: String, subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, @@ -34,7 +36,7 @@ internal class InAppBackendService( val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS delay(rywDelay) // Delay by the specified amount - val baseUrl = "apps/$appId/subscriptions/$subscriptionId/iams" + val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } From da67b046a564c0c7b9cd3661e68a5783d7fa238e Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:10:45 -0700 Subject: [PATCH 08/29] Wire JWT storage and identity verification guards into login, logout, and subscription listeners - LoginHelper: store JWT unconditionally on login, handle same-externalId re-login (store + forceExecute), set existingOneSignalId to null when IV=ON - LogoutHelper: IV=ON branch opts out push before user switch so backend is notified, then creates local-only anonymous user with suppressBackendOperation - UserManager: add jwtInvalidatedNotifier EventProducer for JWT callbacks - OneSignalImp: wire updateUserJwt, addUserJwtInvalidatedListener, removeUserJwtInvalidatedListener; pass JwtTokenStore/SubscriptionModelStore to LoginHelper/LogoutHelper - SubscriptionModelStoreListener, IdentityModelStoreListener, PropertiesModelStoreListener: suppress ops for anonymous users when IV=ON Made-with: Cursor --- .../com/onesignal/internal/OneSignalImp.kt | 23 ++++++++++++++ .../onesignal/user/internal/LoginHelper.kt | 16 ++++++++-- .../onesignal/user/internal/LogoutHelper.kt | 30 +++++++++++-------- .../onesignal/user/internal/UserManager.kt | 2 ++ .../listeners/IdentityModelStoreListener.kt | 12 ++++++-- .../listeners/PropertiesModelStoreListener.kt | 8 +++++ .../SubscriptionModelStoreListener.kt | 16 ++++++++-- 7 files changed, 87 insertions(+), 20 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 5cd9cb9177..6229bc94bd 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -2,6 +2,7 @@ package com.onesignal.internal import android.content.Context import com.onesignal.IOneSignal +import com.onesignal.IUserJwtInvalidatedListener import com.onesignal.common.AndroidUtils import com.onesignal.common.DeviceUtils import com.onesignal.common.OneSignalUtils @@ -35,8 +36,10 @@ import com.onesignal.user.IUserManager import com.onesignal.user.UserModule import com.onesignal.user.internal.LoginHelper import com.onesignal.user.internal.LogoutHelper +import com.onesignal.user.internal.UserManager import com.onesignal.user.internal.UserSwitcher import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.resolveAppId import com.onesignal.user.internal.subscriptions.SubscriptionModelStore @@ -142,6 +145,7 @@ internal class OneSignalImp( private val propertiesModelStore: PropertiesModelStore by lazy { services.getService() } private val subscriptionModelStore: SubscriptionModelStore by lazy { services.getService() } private val preferencesService: IPreferencesService by lazy { services.getService() } + private val jwtTokenStore: JwtTokenStore by lazy { services.getService() } private val listOfModules = listOf( "com.onesignal.notifications.NotificationsModule", @@ -220,6 +224,7 @@ internal class OneSignalImp( userSwitcher = userSwitcher, operationRepo = operationRepo, configModel = configModel, + jwtTokenStore = jwtTokenStore, lock = loginLogoutLock, ) } @@ -230,6 +235,7 @@ internal class OneSignalImp( userSwitcher = userSwitcher, operationRepo = operationRepo, configModel = configModel, + subscriptionModelStore = subscriptionModelStore, lock = loginLogoutLock, ) } @@ -409,6 +415,23 @@ internal class OneSignalImp( } } + override fun updateUserJwt( + externalId: String, + token: String, + ) { + Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId)") + jwtTokenStore.putJwt(externalId, token) + operationRepo.forceExecuteOperations() + } + + override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + (services.getService() as UserManager).jwtInvalidatedNotifier.subscribe(listener) + } + + override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + (services.getService() as UserManager).jwtInvalidatedNotifier.unsubscribe(listener) + } + override fun hasService(c: Class): Boolean = services.hasService(c) override fun getService(c: Class): T = services.getService(c) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt index 15939441ba..cc9bb14daf 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LoginHelper.kt @@ -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.identity.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation class LoginHelper( @@ -11,6 +12,7 @@ class LoginHelper( private val userSwitcher: UserSwitcher, private val operationRepo: IOperationRepo, private val configModel: ConfigModel, + private val jwtTokenStore: JwtTokenStore, private val lock: Any, ) { suspend fun login( @@ -26,10 +28,13 @@ class LoginHelper( currentIdentityOneSignalId = identityModelStore.model.onesignalId if (currentIdentityExternalId == externalId) { + jwtTokenStore.putJwt(externalId, jwtBearerToken) + operationRepo.forceExecuteOperations() return } - // TODO: Set JWT Token for all future requests. + jwtTokenStore.putJwt(externalId, jwtBearerToken) + userSwitcher.createAndSwitchToNewUser { identityModel, _ -> identityModel.externalId = externalId } @@ -37,13 +42,20 @@ class LoginHelper( newIdentityOneSignalId = identityModelStore.model.onesignalId } + val existingOneSignalId = + if (configModel.useIdentityVerification == true) { + null + } else { + if (currentIdentityExternalId == null) currentIdentityOneSignalId else null + } + val result = operationRepo.enqueueAndWait( LoginUserOperation( configModel.appId, newIdentityOneSignalId, externalId, - if (currentIdentityExternalId == null) currentIdentityOneSignalId else null, + existingOneSignalId, ), ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt index 8d9015c612..59028bc039 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt @@ -4,12 +4,14 @@ import com.onesignal.core.internal.config.ConfigModel 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( private val identityModelStore: IdentityModelStore, private val userSwitcher: UserSwitcher, private val operationRepo: IOperationRepo, private val configModel: ConfigModel, + private val subscriptionModelStore: SubscriptionModelStore, private val lock: Any, ) { fun logout() { @@ -18,20 +20,24 @@ class LogoutHelper( return } - // Create new device-scoped user (clears external ID) - userSwitcher.createAndSwitchToNewUser() + if (configModel.useIdentityVerification == true) { + configModel.pushSubscriptionId?.let { pushSubId -> + subscriptionModelStore.get(pushSubId) + ?.setBooleanProperty("optedIn", false) + } - // Enqueue login operation for the new device-scoped user (no external ID) - operationRepo.enqueue( - LoginUserOperation( - configModel.appId, - identityModelStore.model.onesignalId, - null, - // No external ID for device-scoped user - ), - ) + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + } else { + userSwitcher.createAndSwitchToNewUser() - // TODO: remove JWT Token for all future requests. + operationRepo.enqueue( + LoginUserOperation( + configModel.appId, + identityModelStore.model.onesignalId, + null, + ), + ) + } } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 328cb9da7d..d827c557a1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal import com.onesignal.common.IDManager import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils +import com.onesignal.IUserJwtInvalidatedListener import com.onesignal.common.events.EventProducer import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs @@ -43,6 +44,7 @@ internal open class UserManager( get() = _subscriptionManager.subscriptions val changeHandlersNotifier = EventProducer() + val jwtInvalidatedNotifier = EventProducer() override val pushSubscription: IPushSubscription get() = _subscriptionManager.subscriptions.push diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt index 90a565a5a2..b34d7069b7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/IdentityModelStoreListener.kt @@ -10,10 +10,14 @@ import com.onesignal.user.internal.operations.DeleteAliasOperation import com.onesignal.user.internal.operations.SetAliasOperation internal class IdentityModelStoreListener( - store: IdentityModelStore, + private val _identityModelStore: IdentityModelStore, opRepo: IOperationRepo, private val _configModelStore: ConfigModelStore, -) : SingletonModelStoreListener(store, opRepo) { +) : SingletonModelStoreListener(_identityModelStore, opRepo) { + private fun shouldSuppressForAnonymousUser(): Boolean = + _configModelStore.model.useIdentityVerification == true && + _identityModelStore.model.externalId == null + override fun getReplaceOperation(model: IdentityModel): Operation? { // when the identity model is replaced, nothing to do on the backend. Already handled via login process. return null @@ -25,7 +29,9 @@ internal class IdentityModelStoreListener( property: String, oldValue: Any?, newValue: Any?, - ): Operation { + ): Operation? { + if (shouldSuppressForAnonymousUser()) return null + return if (newValue != null && newValue is String) { SetAliasOperation(_configModelStore.model.appId, model.onesignalId, property, newValue) } else { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt index d020c5cc66..8ca4d7326a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/PropertiesModelStoreListener.kt @@ -4,6 +4,7 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.operations.Operation import com.onesignal.core.internal.operations.listeners.SingletonModelStoreListener +import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.operations.DeleteTagOperation import com.onesignal.user.internal.operations.SetPropertyOperation import com.onesignal.user.internal.operations.SetTagOperation @@ -14,7 +15,12 @@ internal class PropertiesModelStoreListener( store: PropertiesModelStore, opRepo: IOperationRepo, private val _configModelStore: ConfigModelStore, + private val _identityModelStore: IdentityModelStore, ) : SingletonModelStoreListener(store, opRepo) { + private fun shouldSuppressForAnonymousUser(): Boolean = + _configModelStore.model.useIdentityVerification == true && + _identityModelStore.model.externalId == null + override fun getReplaceOperation(model: PropertiesModel): Operation? { // when the property model is replaced, nothing to do on the backend. Already handled via login process. return null @@ -27,6 +33,8 @@ internal class PropertiesModelStoreListener( oldValue: Any?, newValue: Any?, ): Operation? { + if (shouldSuppressForAnonymousUser()) return null + // for any of the property changes, we do not need to fire an operation. if (path.startsWith(PropertiesModel::locationTimestamp.name) || path.startsWith(PropertiesModel::locationBackground.name) || diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt index f0002940e9..874e0b75a4 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt @@ -18,7 +18,13 @@ internal class SubscriptionModelStoreListener( private val _identityModelStore: IdentityModelStore, private val _configModelStore: ConfigModelStore, ) : ModelStoreListener(store, opRepo) { - override fun getAddOperation(model: SubscriptionModel): Operation { + private fun shouldSuppressForAnonymousUser(): Boolean = + _configModelStore.model.useIdentityVerification == true && + _identityModelStore.model.externalId == null + + override fun getAddOperation(model: SubscriptionModel): Operation? { + if (shouldSuppressForAnonymousUser()) return null + val enabledAndStatus = getSubscriptionEnabledAndStatus(model) return CreateSubscriptionOperation( _configModelStore.model.appId, @@ -31,7 +37,9 @@ internal class SubscriptionModelStoreListener( ) } - override fun getRemoveOperation(model: SubscriptionModel): Operation { + override fun getRemoveOperation(model: SubscriptionModel): Operation? { + if (shouldSuppressForAnonymousUser()) return null + return DeleteSubscriptionOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, model.id) } @@ -41,7 +49,9 @@ internal class SubscriptionModelStoreListener( property: String, oldValue: Any?, newValue: Any?, - ): Operation { + ): Operation? { + if (shouldSuppressForAnonymousUser()) return null + val enabledAndStatus = getSubscriptionEnabledAndStatus(model) return UpdateSubscriptionOperation( _configModelStore.model.appId, From 8ab58a116016cb2ebab4d751c20c4b6dc01350f9 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:10:52 -0700 Subject: [PATCH 09/29] Add IdentityVerificationService, register JwtTokenStore in DI - New IdentityVerificationService: listens for config HYDRATE events, purges anonymous operations when IV=true, wakes OperationRepo when IV resolves from null, fires UserJwtInvalidatedEvent for beta migration (externalId present but no JWT) - CoreModule: register JwtTokenStore and IdentityVerificationService - ParamsBackendService: remove leftover TODO comments Made-with: Cursor --- .../java/com/onesignal/core/CoreModule.kt | 6 ++ .../backend/impl/ParamsBackendService.kt | 2 - .../impl/IdentityVerificationService.kt | 67 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt 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 9d34231d63..3a15b23bb5 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 @@ -10,6 +10,7 @@ import com.onesignal.core.internal.background.IBackgroundManager 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.IdentityVerificationService import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.DatabaseProvider import com.onesignal.core.internal.device.IDeviceService @@ -35,6 +36,7 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.debug.internal.crash.OneSignalCrashUploaderWrapper import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager @@ -63,6 +65,10 @@ internal class CoreModule : IModule { builder.register().provides() builder.register().provides() + // Identity Verification + 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/backend/impl/ParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt index ec0af86055..a98fbd8e70 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -84,7 +84,6 @@ internal class ParamsBackendService( return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), - // TODO: New useIdentityVerification = responseJson.safeBool("require_ident_auth"), notificationChannels = responseJson.optJSONArray("chnl_lst"), firebaseAnalytics = responseJson.safeBool("fba"), @@ -95,7 +94,6 @@ internal class ParamsBackendService( unsubscribeWhenNotificationsDisabled = responseJson.safeBool("unsubscribe_on_notifications_disabled"), locationShared = responseJson.safeBool("location_shared"), requiresUserPrivacyConsent = responseJson.safeBool("requires_user_privacy_consent"), - // TODO: New opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), features = features, influenceParams = influenceParams ?: InfluenceParamsObject(), diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt new file mode 100644 index 0000000000..b0c163f9cf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt @@ -0,0 +1,67 @@ +package com.onesignal.core.internal.config.impl + +import com.onesignal.UserJwtInvalidatedEvent +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.operations.IOperationRepo +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.IUserManager +import com.onesignal.user.internal.UserManager +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore + +/** + * Reacts to the identity-verification remote param arriving via config HYDRATE. + * + * - When IV transitions from unknown (null) to true: purges anonymous operations. + * - When IV transitions from unknown (null) to any value: wakes the operation queue. + * - On beta migration: if IV=true and the current user has an externalId but no JWT, + * fires [UserJwtInvalidatedEvent] so the developer provides a fresh token. + */ +internal class IdentityVerificationService( + private val _configModelStore: ConfigModelStore, + private val _operationRepo: IOperationRepo, + private val _identityModelStore: IdentityModelStore, + private val _jwtTokenStore: JwtTokenStore, + private val _userManager: IUserManager, +) : IStartableService, ISingletonModelStoreChangeHandler { + override fun start() { + _configModelStore.subscribe(this) + } + + override fun onModelReplaced( + model: ConfigModel, + tag: String, + ) { + if (tag != ModelChangeTags.HYDRATE) return + + val useIV = model.useIdentityVerification + + if (useIV == true) { + Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations") + _operationRepo.removeOperationsWithoutExternalId() + + val externalId = _identityModelStore.model.externalId + if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) { + Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, firing invalidated event") + (_userManager as UserManager).jwtInvalidatedNotifier.fireOnMain { + it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + } + } + } + + _operationRepo.forceExecuteOperations() + } + + override fun onModelUpdated( + args: ModelChangedArgs, + tag: String, + ) { + // Individual property updates are not expected for remote params; + // ConfigModelStoreListener replaces the entire model on HYDRATE. + } +} From 2c87765b482a8dc50bf69d1e26759a51ef803a8b Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:48:45 -0700 Subject: [PATCH 10/29] demo app: add JWT to buttons (login, updateJWT) Register for JWT invalidation events, log a warning and show a toast when triggered. --- .../data/repository/OneSignalRepository.kt | 11 +++- .../sdktest/ui/components/Dialogs.kt | 56 ++++++++++++++++--- .../onesignal/sdktest/ui/main/MainScreen.kt | 21 ++++++- .../sdktest/ui/main/MainViewModel.kt | 24 +++++++- .../com/onesignal/sdktest/ui/main/Sections.kt | 8 ++- 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt index 70696e54fd..18ce42cf04 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt @@ -19,12 +19,17 @@ class OneSignalRepository { } // User operations - suspend fun loginUser(externalUserId: String) = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging in user with externalUserId: $externalUserId") - OneSignal.login(externalUserId) + suspend fun loginUser(externalUserId: String, jwtToken: String? = null) = withContext(Dispatchers.IO) { + Log.d(TAG, "Logging in user with externalUserId: $externalUserId, jwt: ${if (jwtToken != null) "provided" else "none"}") + OneSignal.login(externalUserId, jwtToken) Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}") } + suspend fun updateUserJwt(externalUserId: String, jwtToken: String) = withContext(Dispatchers.IO) { + Log.d(TAG, "Updating JWT for externalUserId: $externalUserId") + OneSignal.updateUserJwt(externalUserId, jwtToken) + } + suspend fun logoutUser() = withContext(Dispatchers.IO) { Log.d(TAG, "Logging out user") OneSignal.logout() diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt index 8045664984..f4dcb99ba2 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt @@ -341,18 +341,60 @@ fun MultiSelectRemoveDialog( } /** - * Dialog for login/switch user. + * Dialog for login/switch user with optional JWT token. */ @Composable fun LoginDialog( onDismiss: () -> Unit, - onConfirm: (String) -> Unit + onConfirm: (String, String?) -> Unit ) { - SingleInputDialog( - title = "Login User", - label = "External User Id", - onDismiss = onDismiss, - onConfirm = onConfirm + var externalId by remember { mutableStateOf("") } + var jwtToken by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), + properties = DialogProperties(usePlatformDefaultWidth = false), + title = { + Text("Login User", style = MaterialTheme.typography.titleMedium) + }, + text = { + Column { + OutlinedTextField( + value = externalId, + onValueChange = { externalId = it }, + label = { Text("External User Id") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = jwtToken, + onValueChange = { jwtToken = it }, + label = { Text("JWT Token (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(externalId, jwtToken.ifBlank { null }) }, + enabled = externalId.isNotBlank() + ) { + Text("Login") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) ) } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt index a9c02609b2..b1ba42d50b 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt @@ -80,6 +80,7 @@ fun MainScreen(viewModel: MainViewModel) { // Dialog states var showLoginDialog by remember { mutableStateOf(false) } + var showUpdateJwtDialog by remember { mutableStateOf(false) } var showAddAliasDialog by remember { mutableStateOf(false) } var showAddMultipleAliasDialog by remember { mutableStateOf(false) } var showAddEmailDialog by remember { mutableStateOf(false) } @@ -160,7 +161,8 @@ fun MainScreen(viewModel: MainViewModel) { UserSection( externalUserId = externalUserId, onLoginClick = { showLoginDialog = true }, - onLogoutClick = { viewModel.logoutUser() } + onLogoutClick = { viewModel.logoutUser() }, + onUpdateJwtClick = { showUpdateJwtDialog = true } ) // === PUSH SECTION === @@ -284,12 +286,25 @@ fun MainScreen(viewModel: MainViewModel) { if (showLoginDialog) { LoginDialog( onDismiss = { showLoginDialog = false }, - onConfirm = { userId -> - viewModel.loginUser(userId) + onConfirm = { userId, jwt -> + viewModel.loginUser(userId, jwt) showLoginDialog = false } ) } + + if (showUpdateJwtDialog) { + PairInputDialog( + title = "Update User JWT", + keyLabel = "External User Id", + valueLabel = "JWT Token", + onDismiss = { showUpdateJwtDialog = false }, + onConfirm = { externalId, token -> + viewModel.updateUserJwt(externalId, token) + showUpdateJwtDialog = false + } + ) + } if (showAddAliasDialog) { PairInputDialog( diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index e65af736d2..ea7a3f7cd1 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -5,7 +5,9 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.onesignal.IUserJwtInvalidatedListener import com.onesignal.OneSignal +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.notifications.IPermissionObserver import com.onesignal.sdktest.data.model.NotificationType import com.onesignal.sdktest.data.repository.OneSignalRepository @@ -19,7 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver { +class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver, IUserJwtInvalidatedListener { private val repository = OneSignalRepository() @@ -99,6 +101,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I OneSignal.User.pushSubscription.addObserver(this) OneSignal.Notifications.addPermissionObserver(this) OneSignal.User.addObserver(this) + OneSignal.addUserJwtInvalidatedListener(this) android.util.Log.d("MainViewModel", "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") LogManager.debug("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") } @@ -217,10 +220,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private fun refreshTriggers() { _triggers.value = triggersList.toList() } // User operations - fun loginUser(externalUserId: String) { + fun loginUser(externalUserId: String, jwtToken: String? = null) { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { - repository.loginUser(externalUserId) + repository.loginUser(externalUserId, jwtToken) withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) _externalUserId.value = externalUserId @@ -240,6 +243,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + fun updateUserJwt(externalUserId: String, jwtToken: String) { + viewModelScope.launch(Dispatchers.IO) { + repository.updateUserJwt(externalUserId, jwtToken) + withContext(Dispatchers.Main) { + showToast("Updated JWT for: $externalUserId") + } + } + } + fun logoutUser() { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { @@ -619,8 +631,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _pushEnabled.postValue(state.current.optedIn) } + override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { + LogManager.warn("JWT invalidated for externalId: ${event.externalId}") + showToast("JWT invalidated for: ${event.externalId}") + } + override fun onCleared() { super.onCleared() OneSignal.User.pushSubscription.removeObserver(this) + OneSignal.removeUserJwtInvalidatedListener(this) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt index f672d322c1..552399484c 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt @@ -138,7 +138,8 @@ fun AppSection( fun UserSection( externalUserId: String?, onLoginClick: () -> Unit, - onLogoutClick: () -> Unit + onLogoutClick: () -> Unit, + onUpdateJwtClick: () -> Unit ) { val isLoggedIn = !externalUserId.isNullOrEmpty() @@ -200,6 +201,11 @@ fun UserSection( onClick = onLogoutClick ) } + + OutlineButton( + text = "UPDATE USER JWT", + onClick = onUpdateJwtClick + ) } // === PUSH SECTION === From 28a657f2cdf52ad3c8387f85a613b2978776ea21 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:56:37 -0700 Subject: [PATCH 11/29] update remote params identity verification key to "jwt_required" --- .../core/internal/backend/impl/ParamsBackendService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt index a98fbd8e70..f81ec9c39d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -84,7 +84,7 @@ internal class ParamsBackendService( return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), - useIdentityVerification = responseJson.safeBool("require_ident_auth"), + useIdentityVerification = responseJson.safeBool("jwt_required"), notificationChannels = responseJson.optJSONArray("chnl_lst"), firebaseAnalytics = responseJson.safeBool("fba"), restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"), From d7b4b89300c0030bc6750bf7d19f8a6493372405 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:59:18 -0700 Subject: [PATCH 12/29] Fix: set all HTTP headers before writing request body Optional headers (ETag, RYW-Token, Retry-Count, Session-Duration, Authorization) were set after the body write, which opens the connection. This caused IllegalStateException on POST requests with a JWT. Move all setRequestProperty calls before outputStream write to prevent the error. Made-with: Cursor --- .../core/internal/http/impl/HttpClient.kt | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index b01a118a87..f7c01b843e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -159,18 +159,6 @@ internal class HttpClient( con.doOutput = true } - logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties) - - if (jsonBody != null) { - val strJsonBody = JSONUtils.toUnescapedEUIDString(jsonBody) - val sendBytes = strJsonBody.toByteArray(charset("UTF-8")) - con.setFixedLengthStreamingMode(sendBytes.size) - val outputStream = con.outputStream - outputStream.write(sendBytes) - } - - // H E A D E R S - if (headers?.cacheKey != null) { val eTag = _prefs.getString( @@ -199,6 +187,16 @@ internal class HttpClient( con.setRequestProperty("Authorization", "Bearer ${headers.jwt}") } + logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties) + + if (jsonBody != null) { + val strJsonBody = JSONUtils.toUnescapedEUIDString(jsonBody) + val sendBytes = strJsonBody.toByteArray(charset("UTF-8")) + con.setFixedLengthStreamingMode(sendBytes.size) + val outputStream = con.outputStream + outputStream.write(sendBytes) + } + // Network request is made from getResponseCode() httpResponse = con.responseCode From 684db4cc1e3ea5f74f6192468b82d76885711a2b Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 01:28:18 -0700 Subject: [PATCH 13/29] demo app: use Identity verification toggle to make requests Cache the JWT token on login and updateUserJwt calls. When Identity Verification is enabled, include the Authorization Bearer header in the demo app's fetch user request. --- .../sdktest/data/network/OneSignalService.kt | 16 +++++--- .../data/repository/OneSignalRepository.kt | 6 +-- .../onesignal/sdktest/ui/main/MainScreen.kt | 3 ++ .../sdktest/ui/main/MainViewModel.kt | 41 ++++++++++++++++--- .../com/onesignal/sdktest/ui/main/Sections.kt | 9 ++++ .../sdktest/util/SharedPreferenceUtil.kt | 18 ++++++++ 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt index 8982aefc85..09a65333df 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt @@ -167,12 +167,13 @@ object OneSignalService { * Fetch user data from OneSignal API. * Note: This endpoint does not require authentication. * - * @param onesignalId The OneSignal user ID + * @param aliasLabel The alias type to look up by (e.g. "onesignal_id" or "external_id") + * @param aliasValue The alias value * @return UserData object containing aliases, tags, emails, and SMS numbers, or null on error */ - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - if (onesignalId.isEmpty()) { - LogManager.w(TAG, "Cannot fetch user - onesignalId is empty") + suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { + if (aliasValue.isEmpty()) { + LogManager.w(TAG, "Cannot fetch user - aliasValue is empty") return@withContext null } @@ -180,9 +181,9 @@ object OneSignalService { LogManager.w(TAG, "Cannot fetch user - appId not set") return@withContext null } - + try { - val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/onesignal_id/$onesignalId" + val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/$aliasLabel/$aliasValue" LogManager.d(TAG, "Fetching user data from: $url") val connection = (URL(url).openConnection() as HttpURLConnection).apply { @@ -190,6 +191,9 @@ object OneSignalService { connectTimeout = 30000 readTimeout = 30000 setRequestProperty("Accept", "application/json") + if (jwt != null) { + setRequestProperty("Authorization", "Bearer $jwt") + } requestMethod = "GET" } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt index 18ce42cf04..774b03fd97 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt @@ -241,8 +241,8 @@ class OneSignalRepository { } // Fetch user data from API - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching user data for: $onesignalId") - OneSignalService.fetchUser(onesignalId) + suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { + Log.d(TAG, "Fetching user data by $aliasLabel: $aliasValue") + OneSignalService.fetchUser(aliasLabel, aliasValue, jwt) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt index b1ba42d50b..45dbc39a72 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt @@ -69,6 +69,7 @@ fun MainScreen(viewModel: MainViewModel) { val consentRequired by viewModel.consentRequired.observeAsState(false) val privacyConsentGiven by viewModel.privacyConsentGiven.observeAsState(false) val externalUserId by viewModel.externalUserId.observeAsState() + val useIdentityVerification by viewModel.useIdentityVerification.observeAsState(false) val aliases by viewModel.aliases.observeAsState(emptyList()) val emails by viewModel.emails.observeAsState(emptyList()) val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList()) @@ -160,6 +161,8 @@ fun MainScreen(viewModel: MainViewModel) { // === USER SECTION === UserSection( externalUserId = externalUserId, + useIdentityVerification = useIdentityVerification, + onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, onLoginClick = { showLoginDialog = true }, onLogoutClick = { viewModel.logoutUser() }, onUpdateJwtClick = { showUpdateJwtDialog = true } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index ea7a3f7cd1..d5f08ca78d 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -76,6 +76,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private val _locationShared = MutableLiveData() val locationShared: LiveData = _locationShared + // Identity Verification toggle (demo app only, controls alias used for API calls) + private val _useIdentityVerification = MutableLiveData() + val useIdentityVerification: LiveData = _useIdentityVerification + // Toast messages private val _toastMessage = MutableLiveData() val toastMessage: LiveData = _toastMessage @@ -130,6 +134,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _privacyConsentGiven.value = repository.getPrivacyConsent() _inAppMessagesPaused.value = repository.isInAppMessagesPaused() _locationShared.value = repository.isLocationShared() + _useIdentityVerification.value = SharedPreferenceUtil.getCachedIdentityVerification(context) val externalId = OneSignal.User.externalId _externalUserId.value = if (externalId.isEmpty()) null else externalId @@ -148,16 +153,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } fun fetchUserDataFromApi() { - val onesignalId = OneSignal.User.onesignalId - if (onesignalId.isNullOrEmpty()) { - _isLoading.value = false - return + val useIV = _useIdentityVerification.value == true + val aliasLabel: String + val aliasValue: String + + if (useIV) { + val externalId = _externalUserId.value + if (externalId.isNullOrEmpty()) { + _isLoading.value = false + return + } + aliasLabel = "external_id" + aliasValue = externalId + } else { + val onesignalId = OneSignal.User.onesignalId + if (onesignalId.isNullOrEmpty()) { + _isLoading.value = false + return + } + aliasLabel = "onesignal_id" + aliasValue = onesignalId } + val jwt = if (useIV) SharedPreferenceUtil.getCachedJwtToken(getApplication()) else null + _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { try { - val userData = repository.fetchUser(onesignalId) + val userData = repository.fetchUser(aliasLabel, aliasValue, jwt) withContext(Dispatchers.Main) { if (userData != null) { aliasesList.clear() @@ -226,6 +249,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.loginUser(externalUserId, jwtToken) withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) _externalUserId.value = externalUserId showToast("Logged in as: $externalUserId") aliasesList.clear() @@ -247,6 +271,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I viewModelScope.launch(Dispatchers.IO) { repository.updateUserJwt(externalUserId, jwtToken) withContext(Dispatchers.Main) { + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) showToast("Updated JWT for: $externalUserId") } } @@ -274,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + fun setUseIdentityVerification(enabled: Boolean) { + SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled) + _useIdentityVerification.value = enabled + showToast(if (enabled) "Identity verification enabled" else "Identity verification disabled") + } + // Consent required fun setConsentRequired(required: Boolean) { repository.setConsentRequired(required) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt index 552399484c..7cc769d838 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt @@ -137,6 +137,8 @@ fun AppSection( @Composable fun UserSection( externalUserId: String?, + useIdentityVerification: Boolean, + onUseIdentityVerificationChange: (Boolean) -> Unit, onLoginClick: () -> Unit, onLogoutClick: () -> Unit, onUpdateJwtClick: () -> Unit @@ -144,6 +146,13 @@ fun UserSection( val isLoggedIn = !externalUserId.isNullOrEmpty() SectionCard(title = "User") { + ToggleRow( + label = "Identity Verification", + description = "Use external_id for API calls", + checked = useIdentityVerification, + onCheckedChange = onUseIdentityVerificationChange + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) // Status Row( modifier = Modifier diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt index f3b93dfb00..1cef40b592 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt @@ -12,6 +12,8 @@ object SharedPreferenceUtil { private const val LOCATION_SHARED_PREF = "LOCATION_SHARED_PREF" private const val IN_APP_MESSAGING_PAUSED_PREF = "IN_APP_MESSAGING_PAUSED_PREF" private const val CONSENT_REQUIRED_PREF = "CONSENT_REQUIRED_PREF" + private const val IDENTITY_VERIFICATION_PREF = "IDENTITY_VERIFICATION_PREF" + private const val JWT_TOKEN_PREF = "JWT_TOKEN_PREF" private fun getSharedPreference(context: Context): SharedPreferences { return context.getSharedPreferences(APP_SHARED_PREFS, Context.MODE_PRIVATE) @@ -69,4 +71,20 @@ object SharedPreferenceUtil { fun cacheConsentRequired(context: Context, required: Boolean) { getSharedPreference(context).edit().putBoolean(CONSENT_REQUIRED_PREF, required).apply() } + + fun getCachedIdentityVerification(context: Context): Boolean { + return getSharedPreference(context).getBoolean(IDENTITY_VERIFICATION_PREF, false) + } + + fun cacheIdentityVerification(context: Context, enabled: Boolean) { + getSharedPreference(context).edit().putBoolean(IDENTITY_VERIFICATION_PREF, enabled).apply() + } + + fun getCachedJwtToken(context: Context): String? { + return getSharedPreference(context).getString(JWT_TOKEN_PREF, null) + } + + fun cacheJwtToken(context: Context, token: String?) { + getSharedPreference(context).edit().putString(JWT_TOKEN_PREF, token).apply() + } } From c0bde450fb077955e42bb4f55a9e53156f5a32ce Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 01:46:55 -0700 Subject: [PATCH 14/29] Add isDisabledInternally to SubscriptionModel for IV logout When Identity Verification is ON and logout is called, the SDK now sets isDisabledInternally=true instead of optedIn=false. This preserves the real opt-in preference while telling the backend the subscription is disabled. On the next login, UserSwitcher creates a fresh SubscriptionModel that defaults isDisabledInternally=false, restoring the real state automatically. Made-with: Cursor --- .../com/onesignal/user/internal/LogoutHelper.kt | 2 +- .../listeners/SubscriptionModelStoreListener.kt | 4 ++++ .../internal/subscriptions/SubscriptionModel.kt | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt index 59028bc039..ebe105c585 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelper.kt @@ -23,7 +23,7 @@ class LogoutHelper( if (configModel.useIdentityVerification == true) { configModel.pushSubscriptionId?.let { pushSubId -> subscriptionModelStore.get(pushSubId) - ?.setBooleanProperty("optedIn", false) + ?.let { it.isDisabledInternally = true } } userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt index 874e0b75a4..4be3aa2e31 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/listeners/SubscriptionModelStoreListener.kt @@ -66,6 +66,10 @@ internal class SubscriptionModelStoreListener( companion object { fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair { + if (model.isDisabledInternally) { + return Pair(false, SubscriptionStatus.UNSUBSCRIBE) + } + val status: SubscriptionStatus val enabled: Boolean diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt index c7bde3aae8..a4622d3aee 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModel.kt @@ -92,6 +92,20 @@ class SubscriptionModel : Model() { setBooleanProperty(::optedIn.name, value) } + /** + * Set to true by the SDK when logout is called with Identity Verification enabled. + * The real [optedIn] and [status] remain unchanged to preserve the user's preference. + * When a subscription update is built, this flag causes enabled=false and + * status=UNSUBSCRIBE to be sent to the backend instead of the real values. + * On the next login, [UserSwitcher.createAndSwitchToNewUser] creates a fresh model + * that does not carry this flag (defaults to false), 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) { From ad2b83a9a562a1208375a32db6fddfaa756be161 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:05:30 -0700 Subject: [PATCH 15/29] Encapsulate JWT invalidation listener management in UserManager Add addJwtInvalidatedListener, removeJwtInvalidatedListener, and fireJwtInvalidated methods to UserManager. Callers no longer need to cast IUserManager to UserManager to access the notifier directly. Register UserManager as a concrete DI service so IdentityVerificationService can depend on it directly. Made-with: Cursor --- .../config/impl/IdentityVerificationService.kt | 8 ++------ .../java/com/onesignal/internal/OneSignalImp.kt | 4 ++-- .../main/java/com/onesignal/user/UserModule.kt | 2 +- .../com/onesignal/user/internal/UserManager.kt | 17 ++++++++++++++++- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt index b0c163f9cf..8529850d99 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt @@ -1,6 +1,5 @@ package com.onesignal.core.internal.config.impl -import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs @@ -9,7 +8,6 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.startup.IStartableService import com.onesignal.debug.internal.logging.Logging -import com.onesignal.user.IUserManager import com.onesignal.user.internal.UserManager import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.identity.JwtTokenStore @@ -27,7 +25,7 @@ internal class IdentityVerificationService( private val _operationRepo: IOperationRepo, private val _identityModelStore: IdentityModelStore, private val _jwtTokenStore: JwtTokenStore, - private val _userManager: IUserManager, + private val _userManager: UserManager, ) : IStartableService, ISingletonModelStoreChangeHandler { override fun start() { _configModelStore.subscribe(this) @@ -48,9 +46,7 @@ internal class IdentityVerificationService( val externalId = _identityModelStore.model.externalId if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) { Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, firing invalidated event") - (_userManager as UserManager).jwtInvalidatedNotifier.fireOnMain { - it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) - } + _userManager.fireJwtInvalidated(externalId) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 6229bc94bd..875d32042d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -425,11 +425,11 @@ internal class OneSignalImp( } override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - (services.getService() as UserManager).jwtInvalidatedNotifier.subscribe(listener) + services.getService().addJwtInvalidatedListener(listener) } override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - (services.getService() as UserManager).jwtInvalidatedNotifier.unsubscribe(listener) + services.getService().removeJwtInvalidatedListener(listener) } override fun hasService(c: Class): Boolean = services.hasService(c) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt index be55228756..0b92fb85bc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt @@ -75,7 +75,7 @@ internal class UserModule : IModule { builder.register().provides() builder.register().provides() builder.register().provides() - builder.register().provides() + builder.register().provides().provides() builder.register().provides() builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index d827c557a1..5c08962a82 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -4,6 +4,7 @@ import com.onesignal.common.IDManager import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.events.EventProducer import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs @@ -44,7 +45,21 @@ internal open class UserManager( get() = _subscriptionManager.subscriptions val changeHandlersNotifier = EventProducer() - val jwtInvalidatedNotifier = EventProducer() + private val jwtInvalidatedNotifier = EventProducer() + + fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedNotifier.subscribe(listener) + } + + fun removeJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedNotifier.unsubscribe(listener) + } + + fun fireJwtInvalidated(externalId: String) { + jwtInvalidatedNotifier.fireOnMain { + it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + } + } override val pushSubscription: IPushSubscription get() = _subscriptionManager.subscriptions.push From 5c37446e74f204cf759ce3c2b3d92f0bcf6e3f78 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:17:51 -0700 Subject: [PATCH 16/29] debug: dump full operation queue in OperationRepo log Made-with: Cursor --- .../onesignal/core/internal/operations/impl/OperationRepo.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index fc15c740de..93fa92ab48 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -199,7 +199,8 @@ internal class OperationRepo( } val ops = getNextOps(executeBucket) - Logging.debug("processQueueForever:ops:\n$ops") + val queueSnapshot = synchronized(queue) { queue.toList() } + Logging.debug("processQueueForever:ops:\n$ops\nqueue(${queueSnapshot.size}):\n$queueSnapshot") if (ops != null) { executeOperations(ops) From ccd95cb314f2cb4ee06f3318b08123f1c8dcf9e2 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:25:24 -0700 Subject: [PATCH 17/29] Fix spotless import ordering in CoreModule and UserManager Made-with: Cursor --- .../core/src/main/java/com/onesignal/core/CoreModule.kt | 2 +- .../src/main/java/com/onesignal/user/internal/UserManager.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 3a15b23bb5..260a830c81 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 @@ -36,7 +36,6 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time -import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.debug.internal.crash.OneSignalCrashUploaderWrapper import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager @@ -44,6 +43,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.identity.JwtTokenStore internal class CoreModule : IModule { override fun register(builder: ServiceBuilder) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 5c08962a82..4ec95c0820 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -1,10 +1,10 @@ package com.onesignal.user.internal +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.IDManager import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils -import com.onesignal.IUserJwtInvalidatedListener -import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.events.EventProducer import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs From 457b7458cd3d02f7639dade102b97c339e45055d Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:29:45 -0700 Subject: [PATCH 18/29] Update detekt baselines for identity verification changes Add KDoc to public JWT API functions, update detekt baseline --- OneSignalSDK/detekt/detekt-baseline-core.xml | 48 +++++++++++++------ .../detekt-baseline-in-app-messages.xml | 21 ++++---- .../detekt/detekt-baseline-notifications.xml | 18 +++---- OneSignalSDK/detekt/detekt-baseline-otel.xml | 10 ++++ .../src/main/java/com/onesignal/IOneSignal.kt | 17 ++++++- .../src/main/java/com/onesignal/OneSignal.kt | 18 +++++++ 6 files changed, 96 insertions(+), 36 deletions(-) create mode 100644 OneSignalSDK/detekt/detekt-baseline-otel.xml diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 0530b2d0eb..b5361b0bb7 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -22,6 +22,7 @@ ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _paramsBackendService: IParamsBackendService ConstructorParameterNaming:ConfigModelStoreListener.kt$ConfigModelStoreListener$private val _subscriptionManager: ISubscriptionManager + ConstructorParameterNaming:CustomEventOperationExecutor.kt$CustomEventOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:DatabaseCursor.kt$DatabaseCursor$private val _cursor: Cursor ConstructorParameterNaming:DatabaseProvider.kt$DatabaseProvider$private val _application: IApplicationService ConstructorParameterNaming:DeviceService.kt$DeviceService$private val _applicationService: IApplicationService @@ -33,16 +34,26 @@ ConstructorParameterNaming:HttpConnectionFactory.kt$HttpConnectionFactory$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:IdentityBackendService.kt$IdentityBackendService$private val _httpClient: IHttpClient ConstructorParameterNaming:IdentityModelStoreListener.kt$IdentityModelStoreListener$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:IdentityModelStoreListener.kt$IdentityModelStoreListener$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _buildUserService: IRebuildUserService + ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityBackend: IIdentityBackendService ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _newRecordState: NewRecordsState + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _jwtTokenStore: JwtTokenStore + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _operationRepo: IOperationRepo + ConstructorParameterNaming:IdentityVerificationService.kt$IdentityVerificationService$private val _userManager: UserManager ConstructorParameterNaming:InfluenceDataRepository.kt$InfluenceDataRepository$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _applicationService: IApplicationService 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 _configModelStore: ConfigModelStore ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService @@ -51,6 +62,7 @@ ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _deviceService: IDeviceService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityOperationExecutor: IdentityOperationExecutor + ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _languageContext: ILanguageContext ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _subscriptionsModelStore: SubscriptionModelStore @@ -62,6 +74,8 @@ ConstructorParameterNaming:NewRecordsState.kt$NewRecordsState$private val _time: ITime ConstructorParameterNaming:OSDatabase.kt$OSDatabase$private val _outcomeTableProvider: OutcomeTableProvider ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _newRecordState: NewRecordsState ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _operationModelStore: OperationModelStore ConstructorParameterNaming:OperationRepo.kt$OperationRepo$private val _time: ITime @@ -81,6 +95,7 @@ ConstructorParameterNaming:PreferencesService.kt$PreferencesService$private val _applicationService: IApplicationService ConstructorParameterNaming:PreferencesService.kt$PreferencesService$private val _time: ITime ConstructorParameterNaming:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:PropertiesModelStoreListener.kt$PropertiesModelStoreListener$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:RebuildUserService.kt$RebuildUserService$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:RebuildUserService.kt$RebuildUserService$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:RebuildUserService.kt$RebuildUserService$private val _propertiesModelStore: PropertiesModelStore @@ -93,6 +108,7 @@ ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _buildUserService: IRebuildUserService ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _newRecordState: NewRecordsState ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private val _subscriptionsModelStore: SubscriptionModelStore @@ -123,6 +139,7 @@ ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _consistencyManager: IConsistencyManager ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _deviceService: IDeviceService + ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _newRecordState: NewRecordsState ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService ConstructorParameterNaming:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private val _subscriptionModelStore: SubscriptionModelStore @@ -132,8 +149,10 @@ ConstructorParameterNaming:TrackGooglePurchase.kt$TrackGooglePurchase$private val _operationRepo: IOperationRepo ConstructorParameterNaming:TrackGooglePurchase.kt$TrackGooglePurchase$private val _prefs: IPreferencesService ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _buildUserService: IRebuildUserService + ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _configModelStore: ConfigModelStore ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _consistencyManager: IConsistencyManager ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _identityModelStore: IdentityModelStore + ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _newRecordState: NewRecordsState ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$private val _userBackend: IUserBackendService @@ -158,10 +177,6 @@ ForbiddenComment:HttpClient.kt$HttpClient$// TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT? ForbiddenComment:IPreferencesService.kt$PreferenceOneSignalKeys$* (String) The serialized IAMs TODO: This isn't currently used, determine if actually needed for cold start IAM fetch delay ForbiddenComment:IUserBackendService.kt$IUserBackendService$// TODO: Change to send only the push subscription, optimally - ForbiddenComment:LoginHelper.kt$LoginHelper$// TODO: Set JWT Token for all future requests. - ForbiddenComment:LogoutHelper.kt$LogoutHelper$// TODO: remove JWT Token for all future requests. - ForbiddenComment:OperationRepo.kt$OperationRepo$// TODO: Need to provide callback for app to reset JWT. For now, fail with no retry. - ForbiddenComment:ParamsBackendService.kt$ParamsBackendService$// TODO: New ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO after we remove IAM from being an activity window we may be able to remove this handler ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO improve this method ForbiddenComment:PermissionsViewModel.kt$PermissionsViewModel.Companion$// TODO this will be removed once the handler is deleted @@ -192,15 +207,16 @@ LongMethod:TrackGooglePurchase.kt$TrackGooglePurchase$private fun queryBoughtItems() LongMethod:TrackGooglePurchase.kt$TrackGooglePurchase$private fun sendPurchases( skusToAdd: ArrayList<String>, newPurchaseTokens: ArrayList<String>, ) LongMethod:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse - LongParameterList:ICustomEventBackendService.kt$ICustomEventBackendService$( appId: String, onesignalId: String, externalId: String?, timestamp: Long, eventName: String, eventProperties: String?, metadata: CustomEventMetadata, ) + LongParameterList:ICustomEventBackendService.kt$ICustomEventBackendService$( appId: String, onesignalId: String, externalId: String?, timestamp: Long, eventName: String, eventProperties: String?, metadata: CustomEventMetadata, jwt: String? = null, ) LongParameterList:IDatabase.kt$IDatabase$( table: String, columns: Array<String>? = null, whereClause: String? = null, whereArgs: Array<String>? = null, groupBy: String? = null, having: String? = null, orderBy: String? = null, limit: String? = null, action: (ICursor) -> Unit, ) LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, ) - LongParameterList:IParamsBackendService.kt$ParamsObject$( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, var notificationChannels: JSONArray? = null, var firebaseAnalytics: Boolean? = null, var restoreTTLFilter: Boolean? = null, var clearGroupOnSummaryClick: Boolean? = null, var receiveReceiptEnabled: Boolean? = null, var disableGMSMissingPrompt: Boolean? = null, var unsubscribeWhenNotificationsDisabled: Boolean? = null, var locationShared: Boolean? = null, var requiresUserPrivacyConsent: Boolean? = null, var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, ) - LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, ) - LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, ) + LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, jwt: String? = null, ) + LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, ) LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, ) + LongParameterList:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _jwtTokenStore: JwtTokenStore, ) LongParameterList:SubscriptionObject.kt$SubscriptionObject$( val id: String? = null, val type: SubscriptionObjectType? = null, val token: String? = null, val enabled: Boolean? = null, val notificationTypes: Int? = null, val sdk: String? = null, val deviceModel: String? = null, val deviceOS: String? = null, val rooted: Boolean? = null, val netType: Int? = null, val carrier: String? = null, val appVersion: String? = null, ) - LongParameterList:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$( private val _subscriptionBackend: ISubscriptionBackendService, private val _deviceService: IDeviceService, private val _applicationService: IApplicationService, private val _subscriptionModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, ) + LongParameterList:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$( private val _subscriptionBackend: ISubscriptionBackendService, private val _deviceService: IDeviceService, private val _applicationService: IApplicationService, private val _subscriptionModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, private val _jwtTokenStore: JwtTokenStore, ) + LongParameterList:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, private val _configModelStore: ConfigModelStore, private val _jwtTokenStore: JwtTokenStore, ) LongParameterList:UserSwitcher.kt$UserSwitcher$( private val preferencesService: IPreferencesService, private val operationRepo: IOperationRepo, private val services: ServiceProvider, private val idManager: IDManager = IDManager, private val identityModelStore: IdentityModelStore, private val propertiesModelStore: PropertiesModelStore, private val subscriptionModelStore: SubscriptionModelStore, private val configModel: ConfigModel, private val oneSignalUtils: OneSignalUtils = OneSignalUtils, private val carrierName: String? = null, private val deviceOS: String? = null, private val androidUtils: AndroidUtils = AndroidUtils, private val appContextProvider: () -> Context, ) LoopWithTooManyJumpStatements:ModelStore.kt$ModelStore$for (index in jsonArray.length() - 1 downTo 0) { val newModel = create(jsonArray.getJSONObject(index)) ?: continue /* * NOTE: Migration fix for bug introduced in 5.1.12 * The following check is intended for the operation model store. * When the call to this method moved out of the operation model store's initializer, * duplicate operations could be cached. * See https://github.com/OneSignal/OneSignal-Android-SDK/pull/2099 */ val hasExisting = models.any { it.id == newModel.id } if (hasExisting) { Logging.debug("ModelStore<$name>: load - operation.id: ${newModel.id} already exists in the store.") continue } models.add(0, newModel) // listen for changes to this model newModel.subscribe(this) } MagicNumber:ApplicationService.kt$ApplicationService$50 @@ -265,6 +281,7 @@ NestedBlockDepth:InfluenceManager.kt$InfluenceManager$private fun attemptSessionUpgrade( entryAction: AppEntryAction, directId: String? = null, ) NestedBlockDepth:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean NestedBlockDepth:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + NestedBlockDepth:OperationRepo.kt$OperationRepo$internal suspend fun executeOperations(ops: List<OperationQueueItem>) NestedBlockDepth:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$override fun resolve(provider: IServiceProvider): Any? NestedBlockDepth:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean @@ -281,7 +298,6 @@ RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean - ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model? ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse @@ -294,8 +310,10 @@ ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int? ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long? ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T? + ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean + ReturnCount:OperationRepo.kt$OperationRepo$private fun hasValidJwtIfRequired( iv: Boolean, op: Operation, ): Boolean ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendUniqueOutcomeEvent( name: String, sessionInfluences: List<Influence>, ): OutcomeEvent? ReturnCount:PermissionsViewModel.kt$PermissionsViewModel$private fun shouldShowSettings( permission: String, shouldShowRationaleAfter: Boolean, ): Boolean @@ -320,6 +338,7 @@ SwallowedException:JSONUtils.kt$JSONUtils$t: Throwable SwallowedException:PermissionsActivity.kt$PermissionsActivity$e: ClassNotFoundException SwallowedException:PreferencesService.kt$PreferencesService$ex: Exception + SwallowedException:PreferencesService.kt$PreferencesService$t: Throwable SwallowedException:SyncJobService.kt$SyncJobService$e: Exception SwallowedException:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$t: Throwable ThrowsCount:OneSignalImp.kt$OneSignalImp$private suspend fun waitUntilInitInternal(operationName: String? = null) @@ -334,6 +353,7 @@ TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$ex: Exception + TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$t: Throwable TooGenericExceptionCaught:SyncJobService.kt$SyncJobService$e: Exception TooGenericExceptionCaught:ThreadUtils.kt$e: Exception TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable @@ -412,10 +432,6 @@ UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResponse UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResult UndocumentedPublicClass:IOutcomeEvent.kt$IOutcomeEvent - UndocumentedPublicClass:IParamsBackendService.kt$FCMParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$IParamsBackendService - UndocumentedPublicClass:IParamsBackendService.kt$InfluenceParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$ParamsObject UndocumentedPublicClass:IPreferencesService.kt$PreferenceOneSignalKeys UndocumentedPublicClass:IPreferencesService.kt$PreferencePlayerPurchasesKeys UndocumentedPublicClass:IPreferencesService.kt$PreferenceStores @@ -604,11 +620,13 @@ UnusedPrivateMember:AndroidUtils.kt$AndroidUtils$var requestPermission: String? = null UnusedPrivateMember:ApplicationService.kt$ApplicationService$val listenerKey = "decorViewReady:$runnable" UnusedPrivateMember:JSONUtils.kt$JSONUtils$`object`: Any - UnusedPrivateMember:LoginHelper.kt$LoginHelper$jwtBearerToken: String? = null UnusedPrivateMember:OSDatabase.kt$OSDatabase.Companion$private const val FLOAT_TYPE = " FLOAT" UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'login'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'logout'") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'login'") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'logout'") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") diff --git a/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml b/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml index da9439705a..fe085e6d85 100644 --- a/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml +++ b/OneSignalSDK/detekt/detekt-baseline-in-app-messages.xml @@ -1,9 +1,9 @@ - + - + ComplexCondition:InAppMessagesManager.kt$InAppMessagesManager$!message.isTriggerChanged && isMessageDisplayed && (isTriggerOnMessage || isNewTriggerAdded && isOnlyDynamicTriggers) - ComplexMethod:TriggerController.kt$TriggerController$private fun evaluateTrigger(trigger: Trigger): Boolean + ComplexMethod:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData) ConstructorParameterNaming:DynamicTriggerController.kt$DynamicTriggerController$private val _session: ISessionService ConstructorParameterNaming:DynamicTriggerController.kt$DynamicTriggerController$private val _state: InAppStateService ConstructorParameterNaming:DynamicTriggerController.kt$DynamicTriggerController$private val _time: ITime @@ -39,6 +39,7 @@ ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _displayer: IInAppDisplayer ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _influenceManager: IInfluenceManager + ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _languageContext: ILanguageContext ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _lifecycle: IInAppLifecycleService ConstructorParameterNaming:InAppMessagesManager.kt$InAppMessagesManager$private val _outcomeEventsController: IOutcomeEventsController @@ -65,10 +66,12 @@ ForbiddenComment:InAppMessagesManager.kt$InAppMessagesManager$// TODO until we don't fix the activity going forward or back dismissing the IAM, we need to auto dismiss ForbiddenComment:InAppMessagesManager.kt$InAppMessagesManager$// TODO: Add more action payload preview logs here in future LongMethod:DynamicTriggerController.kt$DynamicTriggerController$fun dynamicTriggerShouldFire(trigger: Trigger): Boolean + LongMethod:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData) LongMethod:InAppRepository.kt$InAppRepository$override suspend fun cleanCachedInAppMessages() + LongParameterList:IInAppBackendService.kt$IInAppBackendService$( appId: String, aliasLabel: String, aliasValue: String, subscriptionId: String, rywData: RywData, sessionDurationProvider: () -> Long, jwt: String? = null, ) LongParameterList:IInAppBackendService.kt$IInAppBackendService$( appId: String, subscriptionId: String, variantId: String?, messageId: String, clickId: String?, isFirstClick: Boolean, ) LongParameterList:InAppDisplayer.kt$InAppDisplayer$( private val _applicationService: IApplicationService, private val _lifecycle: IInAppLifecycleService, private val _promptFactory: IInAppMessagePromptFactory, private val _backend: IInAppBackendService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, private val _time: ITime, ) - LongParameterList:InAppMessagesManager.kt$InAppMessagesManager$( private val _applicationService: IApplicationService, private val _sessionService: ISessionService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _userManager: IUserManager, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _outcomeEventsController: IOutcomeEventsController, private val _state: InAppStateService, private val _prefs: IInAppPreferencesController, private val _repository: IInAppRepository, private val _backend: IInAppBackendService, private val _triggerController: ITriggerController, private val _triggerModelStore: TriggerModelStore, private val _displayer: IInAppDisplayer, private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, ) + LongParameterList:InAppMessagesManager.kt$InAppMessagesManager$( private val _applicationService: IApplicationService, private val _sessionService: ISessionService, private val _influenceManager: IInfluenceManager, private val _configModelStore: ConfigModelStore, private val _userManager: IUserManager, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _outcomeEventsController: IOutcomeEventsController, private val _state: InAppStateService, private val _prefs: IInAppPreferencesController, private val _repository: IInAppRepository, private val _backend: IInAppBackendService, private val _triggerController: ITriggerController, private val _triggerModelStore: TriggerModelStore, private val _displayer: IInAppDisplayer, private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, private val _jwtTokenStore: JwtTokenStore, ) LongParameterList:OneSignalAnimate.kt$OneSignalAnimate$( view: View, deltaFromY: Float, deltaToY: Float, duration: Int, interpolator: Interpolator?, animCallback: Animation.AnimationListener?, ) MagicNumber:DraggableRelativeLayout.kt$DraggableRelativeLayout$3 MagicNumber:DraggableRelativeLayout.kt$DraggableRelativeLayout$3000 @@ -85,12 +88,12 @@ MagicNumber:InAppMessageView.kt$InAppMessageView$5 MagicNumber:InAppMessageView.kt$InAppMessageView$8 MagicNumber:InAppMessageView.kt$InAppMessageView$8.0 - MagicNumber:InAppMessageView.kt$InAppMessageView.<no name provided>$5 + MagicNumber:InAppMessageView.kt$InAppMessageView.<no name provided>$5 MagicNumber:InAppMessagesManager.kt$InAppMessagesManager$1000 MagicNumber:InAppRepository.kt$InAppRepository$1000L MagicNumber:OneSignalAnimate.kt$OneSignalAnimate$0.5f MagicNumber:WebViewManager.kt$WebViewManager$3 - NestedBlockDepth:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean + NestedBlockDepth:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean PrintStackTrace:InAppMessage.kt$InAppMessage$e PrintStackTrace:InAppMessage.kt$InAppMessage$exception PrintStackTrace:InAppMessageClickResult.kt$InAppMessageClickResult$e @@ -100,17 +103,17 @@ PrintStackTrace:InAppMessageTag.kt$InAppMessageTag$e PrintStackTrace:Trigger.kt$Trigger$exception PrintStackTrace:WebViewManager.kt$WebViewManager.OSJavaScriptInterface$e - ReturnCount:DraggableRelativeLayout.kt$DraggableRelativeLayout.<no name provided>$override fun clampViewPositionVertical( child: View, top: Int, dy: Int, ): Int + ReturnCount:DraggableRelativeLayout.kt$DraggableRelativeLayout.<no name provided>$override fun clampViewPositionVertical( child: View, top: Int, dy: Int, ): Int ReturnCount:DynamicTriggerController.kt$DynamicTriggerController$fun dynamicTriggerShouldFire(trigger: Trigger): Boolean ReturnCount:InAppBackendService.kt$InAppBackendService$override suspend fun getIAMData( appId: String, messageId: String, variantId: String?, ): GetIAMDataResponse - ReturnCount:InAppBackendService.kt$InAppBackendService$private suspend fun attemptFetchWithRetries( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, ): List<InAppMessage>? + ReturnCount:InAppBackendService.kt$InAppBackendService$private suspend fun attemptFetchWithRetries( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, jwt: String? = null, ): List<InAppMessage>? ReturnCount:InAppHydrator.kt$InAppHydrator$fun hydrateIAMMessageContent(jsonObject: JSONObject): InAppMessageContent? ReturnCount:InAppMessage.kt$InAppMessage$private fun parseEndTimeJson(json: JSONObject): Date? ReturnCount:InAppMessagePreviewHandler.kt$InAppMessagePreviewHandler$private fun inAppPreviewPushUUID(payload: JSONObject): String? ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$override fun onMessageWasDisplayed(message: InAppMessage) ReturnCount:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData) ReturnCount:TriggerController.kt$TriggerController$override fun evaluateMessageTriggers(message: InAppMessage): Boolean - ReturnCount:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean + ReturnCount:TriggerController.kt$TriggerController$override fun isTriggerOnMessage( message: InAppMessage, triggersKeys: Collection<String>, ): Boolean ReturnCount:TriggerController.kt$TriggerController$override fun messageHasOnlyDynamicTriggers(message: InAppMessage): Boolean ReturnCount:TriggerController.kt$TriggerController$private fun evaluateTrigger(trigger: Trigger): Boolean ReturnCount:TriggerController.kt$TriggerController$private fun triggerMatchesFlex( triggerValue: Any?, deviceValue: Any, operator: Trigger.OSTriggerOperator, ): Boolean diff --git a/OneSignalSDK/detekt/detekt-baseline-notifications.xml b/OneSignalSDK/detekt/detekt-baseline-notifications.xml index 8ee6e8c589..104b3ef175 100644 --- a/OneSignalSDK/detekt/detekt-baseline-notifications.xml +++ b/OneSignalSDK/detekt/detekt-baseline-notifications.xml @@ -137,28 +137,20 @@ LongParameterList:INotificationGenerationWorkManager.kt$INotificationGenerationWorkManager$( context: Context, osNotificationId: String, androidNotificationId: Int, jsonPayload: JSONObject?, timestamp: Long, isRestoring: Boolean, isHighPriority: Boolean, ) LongParameterList:INotificationRepository.kt$INotificationRepository$( id: String, groupId: String?, collapseKey: String?, shouldDismissIdenticals: Boolean, isOpened: Boolean, androidId: Int, title: String?, body: String?, expireTime: Long, jsonPayload: String, ) LongParameterList:NotificationLifecycleService.kt$NotificationLifecycleService$( private val _applicationService: IApplicationService, private val _time: ITime, private val _configModelStore: ConfigModelStore, private val _influenceManager: IInfluenceManager, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _backend: INotificationBackendService, private val _receiveReceiptWorkManager: IReceiveReceiptWorkManager, private val _analyticsTracker: IAnalyticsTracker, ) - LoopWithTooManyJumpStatements:NotificationLifecycleService.kt$NotificationLifecycleService$for (i in 0 until data.length()) { val notificationId = NotificationFormatHelper.getOSNotificationIdFromJson(data[i] as JSONObject?) ?: continue if (postedOpenedNotifIds.contains(notificationId)) { continue } postedOpenedNotifIds.add(notificationId) suspendifyWithErrorHandling( useIO = true, // or false for CPU operations block = { _backend.updateNotificationAsOpened( appId, notificationId, subscriptionId, deviceType, ) }, onError = { ex -> if (ex is BackendException) { Logging.error("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") } else { Logging.error("Unexpected error in notification opened confirmation", ex) } }, ) } + LoopWithTooManyJumpStatements:NotificationLifecycleService.kt$NotificationLifecycleService$for (i in 0 until data.length()) { val notificationId = NotificationFormatHelper.getOSNotificationIdFromJson(data[i] as JSONObject?) ?: continue if (postedOpenedNotifIds.contains(notificationId)) { continue } postedOpenedNotifIds.add(notificationId) suspendifyWithErrorHandling( useIO = true, // or false for CPU operations block = { _backend.updateNotificationAsOpened( appId, notificationId, subscriptionId, deviceType, ) }, onError = { ex -> if (ex is BackendException) { Logging.info("Notification opened confirmation failed with statusCode: ${ex.statusCode} response: ${ex.response}") } else { Logging.info("Unexpected error in notification opened confirmation", ex) } }, ) } MagicNumber:FirebaseAnalyticsTracker.kt$FirebaseAnalyticsTracker$1000 MagicNumber:FirebaseAnalyticsTracker.kt$FirebaseAnalyticsTracker$30 MagicNumber:FirebaseAnalyticsTracker.kt$FirebaseAnalyticsTracker$60 MagicNumber:Notification.kt$Notification$1000 MagicNumber:NotificationBundleProcessor.kt$NotificationBundleProcessor$1000L - MagicNumber:NotificationBundleProcessor.kt$NotificationBundleProcessor$9 MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$16 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$3 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$5 MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$6 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$7 - MagicNumber:NotificationChannelManager.kt$NotificationChannelManager$9 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$1000L MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$16 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$2000 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$3 - MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$4 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$5000 MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$6 - MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$7 - MagicNumber:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$9 MagicNumber:NotificationDisplayer.kt$NotificationDisplayer$16 MagicNumber:NotificationDisplayer.kt$NotificationDisplayer$3 MagicNumber:NotificationDisplayer.kt$NotificationDisplayer$5000 @@ -166,6 +158,9 @@ MagicNumber:NotificationGenerationProcessor.kt$NotificationGenerationProcessor$1000L MagicNumber:NotificationGenerationWorkManager.kt$NotificationGenerationWorkManager.NotificationGenerationWorker$1000L MagicNumber:NotificationHelper.kt$NotificationHelper$10 + MagicNumber:NotificationPriorityMapper.kt$NotificationPriorityMapper$3 + MagicNumber:NotificationPriorityMapper.kt$NotificationPriorityMapper$5 + MagicNumber:NotificationPriorityMapper.kt$NotificationPriorityMapper$7 MagicNumber:NotificationQueryHelper.kt$NotificationQueryHelper$1000L MagicNumber:NotificationQueryHelper.kt$NotificationQueryHelper$604800L MagicNumber:NotificationRepository.kt$NotificationRepository$1000L @@ -194,8 +189,6 @@ ReturnCount:GenerateNotificationOpenIntent.kt$GenerateNotificationOpenIntent$private fun getIntentAppOpen(): Intent? ReturnCount:NotificationChannelManager.kt$NotificationChannelManager$override fun createNotificationChannel(notificationJob: NotificationGenerationJob): String ReturnCount:NotificationChannelManager.kt$NotificationChannelManager$override fun processChannelList(list: JSONArray?) - ReturnCount:NotificationChannelManager.kt$NotificationChannelManager$private fun priorityToImportance(priority: Int): Int - ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun convertOSToAndroidPriority(priority: Int): Int ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun getAccentColor(fcmJson: JSONObject): BigInteger? ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun getBitmapFromAssetsOrResourceName(bitmapStr: String): Bitmap? ReturnCount:NotificationDisplayBuilder.kt$NotificationDisplayBuilder$private fun getResourceIcon(iconName: String?): Int @@ -213,6 +206,8 @@ ReturnCount:NotificationHelper.kt$NotificationHelper$fun getNotificationIdFromFCMJson(fcmJson: JSONObject?): String? ReturnCount:NotificationLifecycleService.kt$NotificationLifecycleService$private fun shouldInitDirectSessionFromNotificationOpen(context: Activity): Boolean ReturnCount:NotificationPermissionController.kt$NotificationPermissionController$override suspend fun prompt(fallbackToSettings: Boolean): Boolean + ReturnCount:NotificationPriorityMapper.kt$NotificationPriorityMapper$fun toAndroidImportance(osPriority: Int): Int + ReturnCount:NotificationPriorityMapper.kt$NotificationPriorityMapper$fun toAndroidPriority(osPriority: Int): Int ReturnCount:NotificationRestoreProcessor.kt$NotificationRestoreProcessor$private fun getVisibleNotifications(): List<Int>? ReturnCount:NotificationRestoreWorkManager.kt$NotificationRestoreWorkManager.NotificationRestoreWorker$override suspend fun doWork(): Result ReturnCount:NotificationSummaryManager.kt$NotificationSummaryManager$private suspend fun internalUpdateSummaryNotificationAfterChildRemoved( group: String, dismissed: Boolean, ) @@ -247,6 +242,7 @@ TooGenericExceptionCaught:NotificationGenerationProcessor.kt$NotificationGenerationProcessor$t: Throwable TooGenericExceptionCaught:NotificationHelper.kt$NotificationHelper$e: Throwable TooGenericExceptionCaught:NotificationHelper.kt$NotificationHelper$t: Throwable + TooGenericExceptionCaught:NotificationLifecycleService.kt$NotificationLifecycleService$e: Exception TooGenericExceptionCaught:NotificationLimitManager.kt$NotificationLimitManager$t: Throwable TooGenericExceptionCaught:NotificationRepository.kt$NotificationRepository$t: Throwable TooGenericExceptionCaught:NotificationRestoreProcessor.kt$NotificationRestoreProcessor$t: Throwable diff --git a/OneSignalSDK/detekt/detekt-baseline-otel.xml b/OneSignalSDK/detekt/detekt-baseline-otel.xml new file mode 100644 index 0000000000..751e432022 --- /dev/null +++ b/OneSignalSDK/detekt/detekt-baseline-otel.xml @@ -0,0 +1,10 @@ + + + + + LongParameterList:OtelLoggingHelper.kt$OtelLoggingHelper$( telemetry: IOtelOpenTelemetryRemote, level: String, message: String, exceptionType: String? = null, exceptionMessage: String? = null, exceptionStacktrace: String? = null, ) + ReturnCount:OtelConfigRemoteOneSignal.kt$OtelConfigRemoteOneSignal.ExporterLoggingConfig.LoggingLogRecordExporter$@Suppress("TooGenericExceptionCaught") private fun resolveHttpFailureMessage(throwable: Throwable?): String + TooGenericExceptionCaught:OtelCrashHandler.kt$OtelCrashHandler$t: Throwable + UndocumentedPublicFunction:IOtelCrashReporter.kt$IOtelCrashReporter$suspend fun saveCrash(thread: Thread, throwable: Throwable) + + diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt index 0f48ec1c7c..477f7e90ac 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -228,14 +228,29 @@ interface IOneSignal { suspend fun logoutSuspend() /** - * Update the JWT token for a user + * Update the JWT bearer token for a user identified by [externalId]. Call this when + * a token is about to expire or after receiving an [IUserJwtInvalidatedListener] callback. + * + * @param externalId The external ID of the user whose token is being updated. + * @param token The new JWT bearer token. */ fun updateUserJwt( externalId: String, token: String, ) + /** + * Add a listener that will be called when a user's JWT is invalidated (e.g. expired + * or rejected by the server). Use this to provide a fresh token via [updateUserJwt]. + * + * @param listener The listener to add. + */ fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) + /** + * Remove a previously added [IUserJwtInvalidatedListener]. + * + * @param listener The listener to remove. + */ fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt index b6acf51a8b..55c343631f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -343,6 +343,13 @@ object OneSignal { @JvmStatic fun logout() = oneSignal.logout() + /** + * Update the JWT bearer token for a user identified by [externalId]. Call this when + * a token is about to expire or after receiving an [IUserJwtInvalidatedListener] callback. + * + * @param externalId The external ID of the user whose token is being updated. + * @param token The new JWT bearer token. + */ @JvmStatic fun updateUserJwt( externalId: String, @@ -351,11 +358,22 @@ object OneSignal { oneSignal.updateUserJwt(externalId, token) } + /** + * Add a listener that will be called when a user's JWT is invalidated (e.g. expired + * or rejected by the server). Use this to provide a fresh token via [updateUserJwt]. + * + * @param listener The listener to add. + */ @JvmStatic fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { oneSignal.addUserJwtInvalidatedListener(listener) } + /** + * Remove a previously added [IUserJwtInvalidatedListener]. + * + * @param listener The listener to remove. + */ @JvmStatic fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { oneSignal.removeUserJwtInvalidatedListener(listener) From 00d379968169f557fca3d6b859a25f8c807a8440 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 11:27:12 -0700 Subject: [PATCH 19/29] Fix unit test compilation for identity verification parameters Made-with: Cursor --- .../internal/operations/OperationRepoTests.kt | 7 ++ .../user/internal/LoginHelperTests.kt | 9 +++ .../user/internal/LogoutHelperTests.kt | 9 +++ .../RecoverFromDroppedLoginBugTests.kt | 3 + .../CustomEventOperationExecutorTests.kt | 3 +- .../IdentityOperationExecutorTests.kt | 21 +++--- .../LoginUserOperationExecutorTests.kt | 74 +++++++++++-------- .../RefreshUserOperationExecutorTests.kt | 7 ++ .../SubscriptionOperationExecutorTests.kt | 17 +++++ .../UpdateUserOperationExecutorTests.kt | 17 +++++ .../internal/InAppMessagesManagerTests.kt | 66 +++++++++-------- .../backend/InAppBackendServiceTests.kt | 20 ++--- .../java/com/onesignal/mocks/MockHelper.kt | 1 + 13 files changed, 173 insertions(+), 81 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 164949612c..ab24266f3d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -12,6 +12,7 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.LoginUserOperation import io.kotest.core.spec.style.FunSpec @@ -72,6 +73,8 @@ private class Mocks { configModelStore, Time(), getNewRecordState(configModelStore), + mockk(relaxed = true), + MockHelper.identityModelStore(), ), recordPrivateCalls = true, ) @@ -97,6 +100,8 @@ class OperationRepoTests : FunSpec({ mocks.configModelStore, Time(), getNewRecordState(mocks.configModelStore), + mockk(relaxed = true), + MockHelper.identityModelStore(), ), ) @@ -913,6 +918,8 @@ class OperationRepoTests : FunSpec({ every { operation.modifyComparisonKey } returns modifyComparisonKey every { operation.translateIds(any()) } just runs every { operation.applyToRecordId } returns applyToRecordId + every { operation.externalId } returns null + every { operation.externalId = any() } just runs return operation } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt index a501e73bcf..ec64157170 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LoginHelperTests.kt @@ -6,6 +6,7 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.properties.PropertiesModel import io.kotest.core.spec.style.FunSpec @@ -48,6 +49,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val loginHelper = @@ -56,6 +58,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) @@ -87,6 +90,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -108,6 +112,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) @@ -152,6 +157,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -173,6 +179,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) @@ -212,6 +219,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -234,6 +242,7 @@ class LoginHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + jwtTokenStore = mockk(relaxed = true), lock = loginLock, ) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt index 4921ed6bb6..7d4f485952 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/LogoutHelperTests.kt @@ -6,6 +6,7 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.operations.LoginUserOperation +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -41,6 +42,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -49,6 +51,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) @@ -71,6 +74,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -79,6 +83,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) @@ -110,6 +115,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -118,6 +124,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) @@ -142,6 +149,7 @@ class LogoutHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns false val logoutLock = Any() val logoutHelper = @@ -150,6 +158,7 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), lock = logoutLock, ) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt index 554c09ac96..0caf8f2f49 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/migrations/RecoverFromDroppedLoginBugTests.kt @@ -6,6 +6,7 @@ import com.onesignal.core.internal.time.impl.Time import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks import com.onesignal.user.internal.operations.LoginUserOperation import io.kotest.core.spec.style.FunSpec @@ -38,6 +39,8 @@ private class Mocks { configModelStore, Time(), ExecutorMocks.getNewRecordState(configModelStore), + mockk(relaxed = true), + MockHelper.identityModelStore(), ), ) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt index 044d4c3726..5c6d8d6c60 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt @@ -9,6 +9,7 @@ import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.Operation import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.customEvents.ICustomEventBackendService +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.equals.shouldBeEqual @@ -36,7 +37,7 @@ class CustomEventOperationExecutorTests : FunSpec({ val properties = JSONObject().put("key", "value").toString() val customEventOperationExecutor = - CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService) + CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService, mockk(relaxed = true)) val operations = listOf(TrackCustomEventOperation("appId", "onesignalId", null, 1, "event-name", properties)) // When diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt index 34d0681c48..2ac7861484 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt @@ -10,6 +10,7 @@ import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor import io.kotest.core.spec.style.FunSpec @@ -39,7 +40,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -69,7 +70,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -90,7 +91,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -111,7 +112,7 @@ class IdentityOperationExecutorTests : FunSpec({ every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -134,7 +135,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, mockConfigModelStore, mockk(relaxed = true)) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -160,7 +161,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -183,7 +184,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -203,7 +204,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -225,7 +226,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -250,7 +251,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, mockConfigModelStore, mockk(relaxed = true)) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt index d80dc5531a..001a3d96f4 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/LoginUserOperationExecutorTests.kt @@ -14,6 +14,7 @@ import com.onesignal.user.internal.backend.PropertiesObject import com.onesignal.user.internal.backend.SubscriptionObject import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor import com.onesignal.user.internal.properties.PropertiesModel @@ -53,7 +54,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login anonymous user successfully creates user") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -76,6 +77,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -94,6 +96,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -101,7 +104,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login anonymous user fails with retry when network condition exists") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT", retryAfterSeconds = 10) + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT", retryAfterSeconds = 10) val mockIdentityOperationExecutor = mockk() @@ -120,6 +123,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -133,13 +137,13 @@ class LoginUserOperationExecutorTests : FunSpec({ // Then response.result shouldBe ExecutionResult.FAIL_RETRY response.retryAfterSeconds shouldBe 10 - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } test("login anonymous user fails with no retry when backend error condition exists") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(404, "NOT FOUND") + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(404, "NOT FOUND") val mockIdentityOperationExecutor = mockk() @@ -148,7 +152,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) val operations = listOf( LoginUserOperation(appId, localOneSignalId, null, null), @@ -160,13 +164,13 @@ class LoginUserOperationExecutorTests : FunSpec({ // Then response.result shouldBe ExecutionResult.FAIL_PAUSE_OPREPO - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } test("login identified user without association successfully creates user") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -176,7 +180,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) // When @@ -186,7 +190,7 @@ class LoginUserOperationExecutorTests : FunSpec({ response.result shouldBe ExecutionResult.SUCCESS coVerify( exactly = 1, - ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any()) } + ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any(), any()) } } // If the User is identified then the backend may have found an existing User, if so @@ -194,7 +198,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login identified user returns result with RefreshUser") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -214,6 +218,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) @@ -242,7 +247,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -267,7 +272,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login identified user with association fails with retry when association fails with retry") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -278,7 +283,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -303,7 +308,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("login identified user with association successfully creates user when association fails with no retry") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -314,7 +319,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -336,13 +341,13 @@ class LoginUserOperationExecutorTests : FunSpec({ } coVerify( exactly = 1, - ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any()) } + ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any(), any()) } } test("login identified user with association fails with retry when association fails with no retry and network condition exists") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT") + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } throws BackendException(408, "TIMEOUT") val mockIdentityOperationExecutor = mockk() coEvery { mockIdentityOperationExecutor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_NORETRY) @@ -352,7 +357,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -374,13 +379,13 @@ class LoginUserOperationExecutorTests : FunSpec({ } coVerify( exactly = 1, - ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any()) } + ) { mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), any(), any(), any()) } } test("creating user will merge operations into one backend call") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -403,6 +408,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -459,6 +465,7 @@ class LoginUserOperationExecutorTests : FunSpec({ SubscriptionStatus.fromInt(subscription.notificationTypes!!) shouldBe SubscriptionStatus.SUBSCRIBED }, any(), + any(), ) } } @@ -466,7 +473,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("creating user will hydrate when the user hasn't changed") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -504,6 +511,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -545,6 +553,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -552,7 +561,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("creating user will not hydrate when the user has changed") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -590,6 +599,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -631,6 +641,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -638,7 +649,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("creating user will provide local to remote translations") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -662,6 +673,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) val operations = listOf( @@ -698,6 +710,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mapOf(), any(), any(), + any(), ) } } @@ -705,7 +718,7 @@ class LoginUserOperationExecutorTests : FunSpec({ test("ensure anonymous login with no other operations will fail with FAIL_NORETRY") { // Given val mockUserBackendService = mockk() - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) val mockIdentityOperationExecutor = mockk() @@ -725,6 +738,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) // anonymous Login request val operations = listOf(LoginUserOperation(appId, localOneSignalId, null, null)) @@ -737,14 +751,14 @@ class LoginUserOperationExecutorTests : FunSpec({ // ensure user is not created by the bad request coVerify( exactly = 0, - ) { mockUserBackendService.createUser(appId, any(), any(), any()) } + ) { mockUserBackendService.createUser(appId, any(), any(), any(), any()) } } test("create user maps subscriptions when backend order is different (match by id/token)") { // Given val mockUserBackendService = mockk() // backend returns EMAIL first (with token), then PUSH — out of order - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -771,6 +785,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), + mockk(relaxed = true), ) // send PUSH then EMAIL (local IDs 1,2) — order differs from backend response @@ -795,14 +810,14 @@ class LoginUserOperationExecutorTests : FunSpec({ // email localSubscriptionId2 to remoteSubscriptionId2, ) - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } test("create user maps push subscription by type when id and token don't match (case for deleted push sub)") { // Given val mockUserBackendService = mockk() // simulate server-side push sub recreated with new ID and no token; must match by type - coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + coEvery { mockUserBackendService.createUser(any(), any(), any(), any(), any()) } returns CreateUserResponse( mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), @@ -835,6 +850,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, configModelStore, MockHelper.languageContext(), + mockk(relaxed = true), ) val ops = @@ -857,6 +873,6 @@ class LoginUserOperationExecutorTests : FunSpec({ localPushModel.id shouldBe remoteSubscriptionId1 // pushSubscriptionId should be updated from local to remote id configModelStore.model.pushSubscriptionId shouldBe remoteSubscriptionId1 - coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any()) } + coVerify(exactly = 1) { mockUserBackendService.createUser(appId, mapOf(), any(), any(), any()) } } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt index 2689d761af..a32a004c73 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt @@ -14,6 +14,7 @@ import com.onesignal.user.internal.backend.SubscriptionObject import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.impl.executors.RefreshUserOperationExecutor import com.onesignal.user.internal.properties.PropertiesModel @@ -107,6 +108,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockConfigModelStore, mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -191,6 +193,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -230,6 +233,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -265,6 +269,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -300,6 +305,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) @@ -337,6 +343,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ MockHelper.configModelStore(), mockBuildUserService, newRecordState, + mockk(relaxed = true), ) val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt index 4ae3053247..1658207431 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt @@ -13,6 +13,7 @@ import com.onesignal.user.internal.backend.ISubscriptionBackendService import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.impl.executors.SubscriptionOperationExecutor import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -68,6 +69,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -128,6 +130,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -178,6 +181,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -233,6 +237,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -288,6 +293,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -331,6 +337,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -377,6 +384,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -447,6 +455,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -508,6 +517,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -560,6 +570,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -614,6 +625,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -656,6 +668,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -690,6 +703,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -725,6 +739,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -760,6 +775,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + mockk(relaxed = true), ) val operations = @@ -801,6 +817,7 @@ class SubscriptionOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + mockk(relaxed = true), ) val operations = diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt index de2e148ff8..de3ff7b89b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt @@ -10,6 +10,7 @@ import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.backend.IUserBackendService import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.builduser.IRebuildUserService +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState import com.onesignal.user.internal.operations.impl.executors.UpdateUserOperationExecutor import com.onesignal.user.internal.properties.PropertiesModel @@ -56,6 +57,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) @@ -96,6 +99,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -158,6 +163,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -203,6 +210,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -268,6 +277,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf( @@ -316,6 +327,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) @@ -349,6 +362,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, newRecordState, mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) @@ -379,6 +394,8 @@ class UpdateUserOperationExecutorTests : mockBuildUserService, getNewRecordState(), mockConsistencyManager, + MockHelper.configModelStore(), + mockk(relaxed = true), ) val operations = diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 418cce53cc..60a75847bd 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -31,6 +31,7 @@ import com.onesignal.session.internal.influence.IInfluenceManager import com.onesignal.session.internal.outcomes.IOutcomeEventsController import com.onesignal.session.internal.session.ISessionService import com.onesignal.user.IUserManager +import com.onesignal.user.internal.identity.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.IPushSubscription @@ -77,7 +78,7 @@ private class Mocks { val inAppLifecycleService = mockk(relaxed = true) val languageContext = MockHelper.languageContext() val time = MockHelper.time(1000) - val inAppMessageLifecycleListener = spyk() + val inAppMessageLifecycleListener = mockk(relaxed = true) val inAppMessageClickListener = spyk() val rywData = RywData("token", 100L) @@ -89,6 +90,8 @@ private class Mocks { coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred } + val jwtTokenStore = mockk(relaxed = true) + val subscriptionManager = mockk(relaxed = true) { every { subscriptions } returns mockk { every { push } returns pushSubscription @@ -187,6 +190,7 @@ private class Mocks { languageContext, time, consistencyManager, + jwtTokenStore, ) } @@ -455,7 +459,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null val args = ModelChangedArgs( ConfigModel(), ConfigModel::appId.name, @@ -470,7 +474,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then // Should trigger fetchMessagesWhenConditionIsMet - coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onModelUpdated does nothing when non-appId property changes") { @@ -488,7 +492,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onModelReplaced fetches messages") { @@ -497,7 +501,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null // When mocks.inAppMessagesManager.onModelReplaced(ConfigModel(), "tag") @@ -505,7 +509,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then coVerify { - mocks.backend.listInAppMessages(any(), any(), any(), any()) + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } } @@ -525,7 +529,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null // When mocks.inAppMessagesManager.onSubscriptionChanged(mocks.pushSubscription, args) @@ -533,7 +537,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then coVerify { - mocks.backend.listInAppMessages(any(), any(), any(), any()) + mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } @@ -555,7 +559,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSubscriptionChanged does nothing when id path does not match") { @@ -575,7 +579,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSubscriptionAdded does not fetch") { @@ -587,7 +591,7 @@ class InAppMessagesManagerTests : FunSpec({ iamManager.onSubscriptionAdded(mockSubscription) // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSubscriptionRemoved does not fetch") { @@ -599,7 +603,7 @@ class InAppMessagesManagerTests : FunSpec({ iamManager.onSubscriptionRemoved(mockSubscription) // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } } @@ -622,7 +626,7 @@ class InAppMessagesManagerTests : FunSpec({ } returns mockDeferred every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns null // When mocks.inAppMessagesManager.start() @@ -633,7 +637,7 @@ class InAppMessagesManagerTests : FunSpec({ // Verify messages were reset and backend was called message1.isDisplayedInSession shouldBe false message2.isDisplayedInSession shouldBe false - coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("onSessionActive does nothing") { @@ -772,7 +776,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // Fetch messages first mocks.inAppMessagesManager.onSessionStarted() @@ -792,7 +796,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // Fetch messages first mocks.inAppMessagesManager.onSessionStarted() @@ -943,7 +947,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is empty") { @@ -957,7 +961,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is local ID") { @@ -971,7 +975,7 @@ class InAppMessagesManagerTests : FunSpec({ awaitIO() // Then - coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } } test("fetchMessagesWhenConditionIsMet evaluates messages when new messages are returned") { @@ -981,14 +985,14 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // When mocks.inAppMessagesManager.onSessionStarted() awaitIO() // Then - coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } verify { mocks.triggerController.evaluateMessageTriggers(any()) } } } @@ -1028,7 +1032,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.inAppStateService.inAppMessageIdShowing } returns null every { mocks.inAppStateService.paused } returns true coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true // Fetch messages first @@ -1277,7 +1281,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false // Mock backend to return both messages - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message1, message2) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message1, message2) // Start the manager to load redisplayed messages mocks.inAppMessagesManager.start() @@ -1311,8 +1315,8 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" - every { mocks.configModelStore.model.appId } returns "test-app-id" - every { mocks.configModelStore.model.fetchIAMMinInterval } returns 0L + mocks.configModelStore.model.appId = "test-app-id" + mocks.configModelStore.model.fetchIAMMinInterval = 0L every { mocks.triggerModelStore.get(any()) } returns null every { mocks.triggerModelStore.add(any()) } answers {} @@ -1324,7 +1328,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false // Mock first fetch to return the message - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) mocks.inAppMessagesManager.start() awaitIO() @@ -1344,7 +1348,7 @@ class InAppMessagesManagerTests : FunSpec({ earlySessionTriggers.contains("lateTrigger") shouldBe false // Mock second fetch to return the same message - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(message) // Trigger second fetch mocks.inAppMessagesManager.onSessionStarted() @@ -1367,11 +1371,11 @@ class InAppMessagesManagerTests : FunSpec({ every { mockTriggerModelStore.add(any()) } answers {} coEvery { mockRepository.listInAppMessages() } returns mutableListOf() every { mockTriggerController.evaluateMessageTriggers(any()) } returns false - coEvery { mockBackend.listInAppMessages(any(), any(), any(), any()) } returns listOf(mocks.createInAppMessage()) + coEvery { mockBackend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(mocks.createInAppMessage()) every { mocks.pushSubscription.id } returns "test-sub-id" - every { mocks.configModelStore.model.appId } returns "test-app-id" - every { mocks.configModelStore.model.fetchIAMMinInterval } returns 0L + mocks.configModelStore.model.appId = "test-app-id" + mocks.configModelStore.model.fetchIAMMinInterval = 0L every { mocks.applicationService.isInForeground } returns true iamManager.start() @@ -1399,7 +1403,7 @@ class InAppMessagesManagerTests : FunSpec({ val messageAfterClear = mocks.createInAppMessage() // Mock backend for second fetch - coEvery { mockBackend.listInAppMessages(any(), any(), any(), any()) } returns listOf(messageAfterClear) + coEvery { mockBackend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) } returns listOf(messageAfterClear) // Mock that message is in redisplayed and matches the cleared triggers coEvery { mockRepository.listInAppMessages() } returns mutableListOf(messageAfterClear) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt index 72f986e0ec..d2c0eb561e 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt @@ -40,12 +40,12 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) // Then response shouldNotBe null response!!.count() shouldBe 0 - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } test("listInAppMessages with 1 message returns one-lengthed array") { @@ -63,7 +63,7 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) // Then response shouldNotBe null @@ -84,7 +84,7 @@ class InAppBackendServiceTests : response[0].redisplayStats.displayLimit shouldBe 11111 response[0].redisplayStats.displayDelay shouldBe 22222 - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } test("listInAppMessages returns null when non-success response") { @@ -96,11 +96,11 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("123", 500L), mockSessionDurationProvider) // Then response shouldBe null - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", any()) } } test( @@ -125,7 +125,7 @@ class InAppBackendServiceTests : val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", RywData("1234", 500L), mockSessionDurationProvider) + val response = inAppBackendService.listInAppMessages("appId", "onesignal_id", "user123", "subscriptionId", RywData("1234", 500L), mockSessionDurationProvider) // Then response shouldNotBe null @@ -133,7 +133,7 @@ class InAppBackendServiceTests : coVerify(exactly = 1) { mockHttpClient.get( - "apps/appId/subscriptions/subscriptionId/iams", + "apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", match { it.rywToken == "1234" && it.retryCount == null && it.sessionDuration == mockSessionDurationProvider() }, @@ -143,7 +143,7 @@ class InAppBackendServiceTests : // Verify that the get method retried twice with the RYW token coVerify(exactly = 3) { mockHttpClient.get( - "apps/appId/subscriptions/subscriptionId/iams", + "apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", match { it.rywToken == "1234" && it.sessionDuration == mockSessionDurationProvider() && it.retryCount != null }, @@ -153,7 +153,7 @@ class InAppBackendServiceTests : // Verify that the get method was retried the final time without the RYW token coVerify(exactly = 1) { mockHttpClient.get( - "apps/appId/subscriptions/subscriptionId/iams", + "apps/appId/users/by/onesignal_id/user123/subscriptions/subscriptionId/iams", match { it.rywToken == null && it.sessionDuration == mockSessionDurationProvider() && it.retryCount == null }, diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt index 500b736e79..107e395d43 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt @@ -53,6 +53,7 @@ object MockHelper { configModel.foregroundFetchNotificationPermissionInterval = 1 configModel.appId = DEFAULT_APP_ID + configModel.useIdentityVerification = false if (action != null) { action(configModel) From 762917bc1d35dd37a5540bcd1f16a3d0463595a5 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 23:36:46 -0700 Subject: [PATCH 20/29] Fix demo app stalling on failed login by dismissing loading immediately Made-with: Cursor --- .../main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index d5f08ca78d..46a7d4f50d 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -262,7 +262,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshTriggers() loadExistingTags() refreshPushSubscription() - // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it + _isLoading.value = false } } } From dd288a4abc324bfa1e2ecd8f553cd8b738ec6cdc Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 23:43:58 -0700 Subject: [PATCH 21/29] Fix runtime 401 not notifying developer to provide a new JWT When OperationRepo handled FAIL_UNAUTHORIZED it invalidated the JWT and re-queued operations but never fired IUserJwtInvalidatedListener, leaving the queue permanently stuck. Wire a callback from OperationRepo through IdentityVerificationService to UserManager.fireJwtInvalidated() so the developer is notified and can supply a fresh token. Made-with: Cursor --- .../impl/IdentityVerificationService.kt | 3 + .../internal/operations/IOperationRepo.kt | 7 ++ .../internal/operations/impl/OperationRepo.kt | 7 ++ .../internal/operations/OperationRepoTests.kt | 82 +++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt index 8529850d99..171be38190 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt @@ -29,6 +29,9 @@ internal class IdentityVerificationService( ) : IStartableService, ISingletonModelStoreChangeHandler { override fun start() { _configModelStore.subscribe(this) + _operationRepo.setJwtInvalidatedHandler { externalId -> + _userManager.fireJwtInvalidated(externalId) + } } override fun onModelReplaced( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt index 6bc70bffc1..2f8a8ac8fe 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt @@ -49,6 +49,13 @@ interface IOperationRepo { * purge operations that cannot be executed without an authenticated user. */ fun removeOperationsWithoutExternalId() + + /** + * Register a handler to be called when a runtime 401 Unauthorized response + * invalidates a JWT. This allows the caller to notify the developer so they + * can supply a fresh token via [OneSignal.updateUserJwt]. + */ + fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?) } // Extension function so the syntax containsInstanceOf() can be used over diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 93fa92ab48..1f6c548feb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -44,6 +44,8 @@ internal class OperationRepo( } } + private var _jwtInvalidatedHandler: ((String) -> Unit)? = null + internal class LoopWaiterMessage( val force: Boolean, val previousWaitedTime: Long = 0, @@ -284,6 +286,7 @@ internal class OperationRepo( val externalId = startingOp.operation.externalId if (externalId != null) { _jwtTokenStore.invalidateJwt(externalId) + _jwtInvalidatedHandler?.invoke(externalId) Logging.warn("Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. Operations re-queued.") synchronized(queue) { ops.reversed().forEach { queue.add(0, it) } @@ -512,4 +515,8 @@ internal class OperationRepo( } } } + + override fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?) { + _jwtInvalidatedHandler = handler + } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index ab24266f3d..93ad749282 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -894,6 +894,88 @@ class OperationRepoTests : FunSpec({ // Verify that the grouped execution happened with both operations // We can't easily verify the exact list content with MockK, but we verified it in the execution order tracking } + + test("FAIL_UNAUTHORIZED invalidates JWT and fires handler for identified user") { + // Given + val configModelStore = + MockHelper.configModelStore { + it.useIdentityVerification = true + } + val identityModelStore = + MockHelper.identityModelStore { + it.externalId = "test-user" + } + val jwtTokenStore = mockk(relaxed = true) + every { jwtTokenStore.getJwt("test-user") } returns "valid-jwt" + + val operationModelStore = + run { + val operationStoreList = mutableListOf() + val mock = mockk() + every { mock.loadOperations() } just runs + every { mock.list() } answers { operationStoreList.toList() } + every { mock.add(any()) } answers { operationStoreList.add(firstArg()) } + every { mock.remove(any()) } answers { + val id = firstArg() + operationStoreList.removeIf { it.id == id } + } + mock + } + + val executor = mockk() + every { executor.operations } returns listOf("DUMMY_OPERATION") + coEvery { executor.execute(any()) } returns + ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) andThen + ExecutionResponse(ExecutionResult.SUCCESS) + + val operationRepo = + spyk( + OperationRepo( + listOf(executor), + operationModelStore, + configModelStore, + Time(), + getNewRecordState(configModelStore), + jwtTokenStore, + identityModelStore, + ), + recordPrivateCalls = true, + ) + + var handlerCalledWith: String? = null + operationRepo.setJwtInvalidatedHandler { externalId -> + handlerCalledWith = externalId + } + + val operation = mockOperation() + every { operation.externalId } returns "test-user" + + // When + operationRepo.start() + val response = operationRepo.enqueueAndWait(operation) + + // Then + response shouldBe true + verify { jwtTokenStore.invalidateJwt("test-user") } + handlerCalledWith shouldBe "test-user" + } + + test("FAIL_UNAUTHORIZED drops operations for anonymous user") { + // Given + val mocks = Mocks() + coEvery { mocks.executor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) + + val operation = mockOperation() + // externalId defaults to null in mockOperation + + // When + mocks.operationRepo.start() + val response = mocks.operationRepo.enqueueAndWait(operation) + + // Then + response shouldBe false + verify { mocks.operationModelStore.remove(operation.id) } + } }) { companion object { private fun mockOperation( From ba938da0f6643e5e8acc80b074292e246278fdca Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:01:15 -0700 Subject: [PATCH 22/29] Propagate externalId to executor result operations in OperationRepo Follow-up operations returned by executors (e.g. RefreshUserOperation) were inserted directly into the queue without externalId, causing them to be permanently blocked when identity verification is enabled. Made-with: Cursor --- .../onesignal/core/internal/operations/impl/OperationRepo.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 1f6c548feb..8a2753fb3f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -340,9 +340,13 @@ internal class OperationRepo( // if there are operations provided on the result, we need to enqueue them at the // beginning of the queue. if (response.operations != null) { + val parentExternalId = startingOp.operation.externalId synchronized(queue) { for (op in response.operations.reversed()) { op.id = UUID.randomUUID().toString() + if (op.externalId == null && parentExternalId != null) { + op.externalId = parentExternalId + } val queueItem = OperationQueueItem(op, bucket = 0) queue.add(0, queueItem) _operationModelStore.add(0, queueItem.operation) From 4cb12422dfe9c853084ad1bc4dc5ccdff9947923 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:08:38 -0700 Subject: [PATCH 23/29] Fix race condition: stamp externalId synchronously before async enqueue The externalId was being stamped inside internalEnqueue which runs on the OperationRepo coroutine thread. When LogoutHelper set isDisabledInternally=true (triggering an UpdateSubscriptionOperation) then immediately called createAndSwitchToNewUser(), the identity model's externalId could be cleared before the coroutine ran, leaving the operation with externalId=null and permanently blocked. Move stamping to enqueue()/enqueueAndWait() on the caller's thread so the externalId is captured at the moment the operation is created. Made-with: Cursor --- .../internal/operations/impl/OperationRepo.kt | 21 ++++-- .../internal/operations/OperationRepoTests.kt | 64 +++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 8a2753fb3f..9d00f0996e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -129,6 +129,7 @@ internal class OperationRepo( Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)") operation.id = UUID.randomUUID().toString() + stampExternalId(operation) scope.launch { internalEnqueue(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush, true) } @@ -141,6 +142,7 @@ internal class OperationRepo( Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)") operation.id = UUID.randomUUID().toString() + stampExternalId(operation) val waiter = WaiterWithValue() scope.launch { internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) @@ -154,19 +156,24 @@ internal class OperationRepo( * * @returns true if the OperationQueueItem was added, false if not */ + /** + * Capture the externalId from the current identity model onto the operation + * synchronously on the caller's thread, before the async enqueue coroutine runs. + * Operations that already set externalId in their constructor (e.g. LoginUserOperation) + * are left unchanged. + */ + private fun stampExternalId(operation: Operation) { + if (operation.externalId == null) { + operation.externalId = _identityModelStore.model.externalId + } + } + private fun internalEnqueue( queueItem: OperationQueueItem, flush: Boolean, addToStore: Boolean, index: Int? = null, ) { - // Stamp externalId on new operations from the current identity model. - // Operations loaded from persistence (addToStore=false) already have their externalId. - // Operations that set externalId in their constructor (e.g. LoginUserOperation) are skipped. - if (addToStore && queueItem.operation.externalId == null) { - queueItem.operation.externalId = _identityModelStore.model.externalId - } - synchronized(queue) { val hasExisting = queue.any { it.operation.id == queueItem.operation.id } if (hasExisting) { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 93ad749282..2e42854690 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -960,6 +960,70 @@ class OperationRepoTests : FunSpec({ handlerCalledWith shouldBe "test-user" } + test("enqueue stamps externalId synchronously before async dispatch") { + // Verifies the fix for a race condition where createAndSwitchToNewUser() + // could clear the identity model's externalId before the async internalEnqueue + // had a chance to stamp it. + + // Given + val identityModel = com.onesignal.user.internal.identity.IdentityModel() + identityModel.id = "-singleton" + identityModel.onesignalId = "onesignal-id" + identityModel.externalId = "old-user" + + val identityModelStore = mockk(relaxed = true) + every { identityModelStore.model } returns identityModel + + val configModelStore = + MockHelper.configModelStore { + it.useIdentityVerification = true + } + val jwtTokenStore = mockk(relaxed = true) + every { jwtTokenStore.getJwt("old-user") } returns "valid-jwt" + + val operationModelStore = + run { + val operationStoreList = mutableListOf() + val mock = mockk() + every { mock.loadOperations() } just runs + every { mock.list() } answers { operationStoreList.toList() } + every { mock.add(any()) } answers { operationStoreList.add(firstArg()) } + every { mock.remove(any()) } answers { + val id = firstArg() + operationStoreList.removeIf { it.id == id } + } + mock + } + + val executor = mockk() + every { executor.operations } returns listOf("DUMMY_OPERATION") + coEvery { executor.execute(any()) } returns ExecutionResponse(ExecutionResult.SUCCESS) + + val operationRepo = + spyk( + OperationRepo( + listOf(executor), + operationModelStore, + configModelStore, + Time(), + getNewRecordState(configModelStore), + jwtTokenStore, + identityModelStore, + ), + recordPrivateCalls = true, + ) + + val operation = mockOperation() + // externalId starts null — stampExternalId should fill it from the identity model + + // When — enqueue then immediately switch user (simulating LogoutHelper's pattern) + operationRepo.enqueue(operation) + identityModel.externalId = null + + // Then — the operation should have captured "old-user" before the switch + operation.externalId shouldBe "old-user" + } + test("FAIL_UNAUTHORIZED drops operations for anonymous user") { // Given val mocks = Mocks() From f9d911a3a9f788c343a76ee34d5a55d3b3ca20a3 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:17:49 -0700 Subject: [PATCH 24/29] Harden JwtTokenStore against corrupted SharedPreferences data Wrap JSONObject parsing in ensureLoaded() with try-catch so that corrupted persisted data (e.g. from a process kill mid-write) does not permanently break JWT lookups and stall the operation queue. Made-with: Cursor --- .../onesignal/user/internal/identity/JwtTokenStore.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt index e4975b29bb..2bbda35723 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal.identity 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.JSONObject /** @@ -28,9 +29,13 @@ class JwtTokenStore( PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS, ) if (json != null) { - val obj = JSONObject(json) - for (key in obj.keys()) { - tokens[key] = obj.getString(key) + try { + val obj = JSONObject(json) + for (key in obj.keys()) { + tokens[key] = obj.getString(key) + } + } catch (e: Exception) { + Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh", e) } } isLoaded = true From e993fc0643c256fba44964f0116f77c230d33f53 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:20:32 -0700 Subject: [PATCH 25/29] Fix null useIdentityVerification blocking all ops for non-IV apps When the server omits jwt_required from the params response, safeBool() returned null, leaving useIdentityVerification unset. getNextOps() treated null as "not yet known" and returned null permanently, silently blocking every SDK operation for the session. Default to false when the field is absent so non-IV apps are unaffected by the identity verification gating logic. Made-with: Cursor --- .../core/internal/backend/impl/ParamsBackendService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt index f81ec9c39d..273946ef0b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -84,7 +84,7 @@ internal class ParamsBackendService( return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), - useIdentityVerification = responseJson.safeBool("jwt_required"), + useIdentityVerification = responseJson.safeBool("jwt_required") ?: false, notificationChannels = responseJson.optJSONArray("chnl_lst"), firebaseAnalytics = responseJson.safeBool("fba"), restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"), From 76dd958427ae8b75c0cbccfd093606468d2bebb5 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:42:35 -0700 Subject: [PATCH 26/29] Add @Volatile to _jwtInvalidatedHandler for JMM visibility The field is written from the main thread in IdentityVerificationService.start() and read on the OperationRepo coroutine thread. @Volatile guarantees cross-thread visibility. Made-with: Cursor --- .../com/onesignal/core/internal/operations/impl/OperationRepo.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 9d00f0996e..d3a0e2465d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -44,6 +44,7 @@ internal class OperationRepo( } } + @Volatile private var _jwtInvalidatedHandler: ((String) -> Unit)? = null internal class LoopWaiterMessage( From a53f9425f29a088460bc596d57adc6d928beca5d Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:59:33 -0700 Subject: [PATCH 27/29] Skip push subscription disable on logout when JWT is already expired When IV is enabled and the JWT has already been invalidated (e.g. by a prior 401), the UpdateSubscriptionOperation to disable the push subscription would be permanently blocked by hasValidJwtIfRequired. Since the backend call would fail with 401 anyway, skip it entirely and just switch to the new anonymous user. Made-with: Cursor Revert "Skip push subscription disable on logout when JWT is already expired" This reverts commit 5ce284257b6e03b8c862745ff58fcf4293299577. Exempt UpdateSubscriptionOperation from JWT gating The subscription update endpoint does not require a JWT on the backend. Add Operation.requiresJwt (default true) and override it to false in UpdateSubscriptionOperation so these operations are not blocked by hasValidJwtIfRequired when the JWT is expired or missing. This fixes the edge case where logging out with an expired JWT would permanently block the push subscription disable operation. Made-with: Cursor --- .../com/onesignal/core/internal/operations/Operation.kt | 7 +++++++ .../core/internal/operations/impl/OperationRepo.kt | 1 + .../internal/operations/UpdateSubscriptionOperation.kt | 1 + .../core/internal/operations/OperationRepoTests.kt | 1 + 4 files changed, 10 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt index 64a9b5c80c..8227ebb877 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt @@ -61,6 +61,13 @@ abstract class Operation(name: String) : Model() { */ abstract val canStartExecute: Boolean + /** + * Whether this operation requires a valid JWT when identity verification is enabled. + * Override to return `false` for operations whose backend endpoint does not require + * a JWT (e.g. subscription updates). + */ + open val requiresJwt: Boolean get() = true + /** * Called when an operation has resolved a local ID to a backend ID (i.e. successfully * created a backend resource). Any IDs within the operation that could be local IDs should diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index d3a0e2465d..03259156ce 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -438,6 +438,7 @@ internal class OperationRepo( op: Operation, ): Boolean { if (!iv) return true + if (!op.requiresJwt) return true val externalId = op.externalId ?: return false return _jwtTokenStore.getJwt(externalId) != null } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt index 57f17a29f5..426da703dd 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt @@ -86,6 +86,7 @@ class UpdateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.UP override val groupComparisonType: GroupComparisonType = GroupComparisonType.ALTER override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId) override val applyToRecordId: String get() = subscriptionId + override val requiresJwt: Boolean get() = false constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() { this.appId = appId diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 2e42854690..7fae5773af 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -1064,6 +1064,7 @@ class OperationRepoTests : FunSpec({ every { operation.modifyComparisonKey } returns modifyComparisonKey every { operation.translateIds(any()) } just runs every { operation.applyToRecordId } returns applyToRecordId + every { operation.requiresJwt } returns true every { operation.externalId } returns null every { operation.externalId = any() } just runs From f6e4227352b764dc47fb5f32ce97820f3ecdeef9 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 01:47:32 -0700 Subject: [PATCH 28/29] Add identity verification manual test plan Made-with: Cursor --- .../IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md | 966 ++++++++++++++++++ 1 file changed, 966 insertions(+) create mode 100644 temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md diff --git a/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md b/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md new file mode 100644 index 0000000000..4d22244f6d --- /dev/null +++ b/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md @@ -0,0 +1,966 @@ +# Identity Verification (JWT) Manual Test Plan + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Migration Paths Under Test](#migration-paths-under-test) +- [How to Prepare Each Migration Path](#how-to-prepare-each-migration-path) +- [Section 1: Startup and Initialization](#section-1-startup-and-initialization) +- [Section 2: Login with JWT](#section-2-login-with-jwt-iv-on) +- [Section 3: Multi-User Login Sequences](#section-3-multi-user-login-sequences-iv-on) +- [Section 4: Logout](#section-4-logout-iv-on) +- [Section 5: User Data Operations](#section-5-user-data-operations-iv-on) +- [Section 6: In-App Messages](#section-6-in-app-messages-iv-on) +- [Section 7: Caching, Persistence, and Retry](#section-7-caching-persistence-and-retry-iv-on) +- [Section 8: Migration Paths](#section-8-migration-paths) +- [Section 9: IV Toggle (Dashboard Changes)](#section-9-iv-toggle-dashboard-changes) +- [Section 10: Edge Cases and Error Handling](#section-10-edge-cases-and-error-handling) +- [Section 11: IV OFF Regression](#section-11-iv-off-regression) +- [Testing Checklist Summary](#testing-checklist-summary) + +--- + +## Prerequisites + +### Tools +- Android device or emulator +- OneSignal Dashboard access with ability to toggle Identity Verification (JWT) on/off +- A JWT generation tool or server endpoint to produce valid/invalid/expired JWTs for test external IDs +- Network proxy (e.g., Charles Proxy) or `adb logcat` with `LogLevel.VERBOSE` to inspect SDK network requests and logs +- The demo app (`Examples/demo`) built from the `feat/identity_verification_5.8` branch + +### Dashboard Setup +- OneSignal app configured with a REST API key (for the demo app's notification sending) +- Ability to toggle **Identity Verification** on and off in dashboard settings +- At least one In-App Message configured (for Section 6 tests) + +### Key Terminology +- **IV** = Identity Verification (the JWT feature) +- **IV ON** = `jwt_required: true` in remote params, `useIdentityVerification == true` in ConfigModel +- **IV OFF** = `jwt_required: false` in remote params, `useIdentityVerification == false` in ConfigModel +- **IV unknown** = Remote params haven't arrived yet, `useIdentityVerification == null` +- **HYDRATE** = The moment remote params are fetched and applied to ConfigModel +- **Sink user** = The local-only anonymous user created on logout when IV is ON (never sent to backend) + +### How to Verify with the Demo App +- **Login**: Tap "Login" button -> enter External User ID and JWT token -> confirm +- **Logout**: Tap "Logout" button +- **Update JWT**: Tap "Update JWT" button -> enter External User ID and JWT token -> confirm +- **JWT Invalidated Callback**: Watch the log view at the top of the demo app for "JWT invalidated for externalId: ..." messages +- **Add Tags/Aliases/Email/SMS**: Use the corresponding sections in the demo app +- **Network Requests**: Use `adb logcat | grep -i "OneSignal"` with `LogLevel.VERBOSE` or a network proxy + +### Log Messages to Watch For +- `"Identity verification is enabled"` -- logged on HYDRATE when IV turns on +- `"JWT invalidated for externalId: ..."` -- logged when `onUserJwtInvalidated` fires +- `"Authorization: Bearer ..."` -- in HTTP request headers when IV is on +- `"Removing operations without externalId"` -- when anonymous ops are purged +- `"hasValidJwtIfRequired"` -- when ops are gated on JWT availability +- `"FAIL_UNAUTHORIZED"` -- when a 401 response is received + +--- + +## Migration Paths Under Test + +Every scenario should be considered across these four starting states: + +| Path | Description | +|------|-------------| +| **New Install** | Fresh app install, no prior data in SharedPreferences | +| **v4 Player Model** | App was on SDK v4 (legacy player ID stored). Upgrade to this branch | +| **v5 (no IV)** | App was on v5 `main` branch (no JWT feature). Has existing anonymous or identified user. Upgrade to this branch | +| **JWT Beta** | App was on the previous `feat/identity_verification` beta branch (JWT stored as singleton on `IdentityModel`). Upgrade to this branch | + +--- + +## How to Prepare Each Migration Path + +### New Install +1. Uninstall the demo app completely (or clear all app data) +2. Build and install the `feat/identity_verification_5.8` branch + +### v4 Player Model +1. Build and install the demo app from a v4 SDK tag (e.g., `4.x.x`) +2. Open the app, let it register a player +3. Verify a legacy player ID is stored (visible in logcat) +4. WITHOUT uninstalling, build and install the `feat/identity_verification_5.8` branch over the top + +### v5 (no IV) +1. Build and install the demo app from the `main` branch (v5, no JWT feature) +2. Open the app, either leave as anonymous user OR login with an externalId (depending on the test) +3. Let the user sync to backend +4. WITHOUT uninstalling, build and install the `feat/identity_verification_5.8` branch over the top + +### JWT Beta +1. Build and install the demo app from the previous `feat/identity_verification` beta branch +2. Open the app, login with JWT +3. Optionally create the multi-user stuck state (login as userA with expired JWT, then login as userB) +4. WITHOUT uninstalling, build and install the `feat/identity_verification_5.8` branch over the top + +--- + +## Section 1: Startup and Initialization + +These test the critical window between `initWithContext` and remote params arriving, where `useIdentityVerification == null`. + +### Test 1.1: New install, IV ON on dashboard + +**Precondition**: Fresh install. IV is ON in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Uninstall app, build and install from `feat/identity_verification_5.8` | Clean install | +| 2 | Open app | `initWithContext` is called. Logcat shows anonymous `LoginUserOperation` enqueued | +| 3 | Immediately tap "Add Tag" and add key="test", value="1" | Tag op enqueued locally | +| 4 | Wait for remote params to arrive (watch logcat for "Identity verification is enabled") | HYDRATE fires with IV=true | +| 5 | Check logcat for "Removing operations without externalId" | Anonymous `LoginUserOperation` and the tag op are purged | +| 6 | Verify in OneSignal Dashboard: no new user was created | No anonymous user on backend | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.2: New install, IV OFF on dashboard + +**Precondition**: Fresh install. IV is OFF in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Uninstall app, build and install | Clean install | +| 2 | Open app | `initWithContext` called, anonymous `LoginUserOperation` enqueued | +| 3 | Immediately add a tag (key="test", value="1") | Tag op enqueued | +| 4 | Wait for remote params | HYDRATE fires with IV=false | +| 5 | Check logcat | Anonymous user creation request sent, tag request sent | +| 6 | Verify in dashboard | Anonymous user exists with the tag | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.3: New install, IV ON, no internet at startup + +**Precondition**: Fresh install. IV is ON in dashboard. Device in airplane mode. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet | +| 2 | Uninstall app, install from `feat/identity_verification_5.8` | Clean install | +| 3 | Open app | `initWithContext` called. Anonymous op enqueued. Remote params cannot be fetched | +| 4 | Tap Login -> enter externalId="alice", JWT=valid token | `LoginUserOperation` for alice enqueued, JWT stored in `JwtTokenStore` | +| 5 | Disable airplane mode | Internet restored | +| 6 | Wait for remote params to arrive | HYDRATE with IV=true. Anonymous ops purged | +| 7 | Check logcat for alice's `LoginUserOperation` executing with Authorization header | User "alice" created on backend | +| 8 | Verify in dashboard | User "alice" exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.4: Cold start (returning user, IV ON) + +**Precondition**: Previously logged in as "alice" with valid JWT. IV is ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT. Confirm user created on backend | Setup complete | +| 2 | Force-kill the app | App terminated | +| 3 | Reopen the app | `initWithContext` called. Persisted ops reload. JwtTokenStore loaded from SharedPreferences | +| 4 | Wait for HYDRATE | IV=true confirmed. `forceExecuteOperations()` called | +| 5 | Check that "alice" is still the current user (externalId shown in UI) | User identity persisted correctly | +| 6 | Add a tag | Tag sent to backend with Authorization header for alice | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 1.5: Cold start, IV ON, JWT expired in store + +**Precondition**: Logged in as "alice" with a JWT that will expire. IV is ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with a short-lived JWT. Verify user created | Setup complete | +| 2 | Wait for the JWT to expire (or use a pre-expired token from step 1) | JWT is now invalid | +| 3 | Force-kill the app | App terminated | +| 4 | Reopen the app | Persisted ops and JWT loaded | +| 5 | Wait for HYDRATE | Ops attempt to execute with expired JWT | +| 6 | Check logcat for 401 response and "JWT invalidated" | `onUserJwtInvalidated("alice")` fires | +| 7 | Check demo app log view | "JWT invalidated for externalId: alice" appears | +| 8 | Tap "Update JWT" -> enter externalId="alice", JWT=new valid token | JWT updated in store | +| 9 | Check logcat | Pending ops retry with new JWT and succeed | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 2: Login with JWT (IV ON) + +**Precondition for all tests in this section**: IV is ON in dashboard. Fresh install unless stated otherwise. + +### Test 2.1: Login with valid JWT + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install) | App initializes | +| 2 | Wait for HYDRATE (IV=true) | Anonymous ops purged | +| 3 | Tap Login -> externalId="alice", JWT=valid token | Login called | +| 4 | Check logcat for HTTP request | `POST /users` or `GET /users/by/external_id/alice` with `Authorization: Bearer ` | +| 5 | Verify in dashboard | User "alice" exists with push subscription | +| 6 | Check demo app UI | ExternalId shows "alice" | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.2: Login with invalid/expired JWT + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install), wait for HYDRATE | IV=true | +| 2 | Tap Login -> externalId="alice", JWT=expired/invalid token | Login called | +| 3 | Check logcat | `LoginUserOperation` executes, backend returns 401 | +| 4 | Check for callback | `onUserJwtInvalidated("alice")` fires. Demo app log shows "JWT invalidated for externalId: alice" | +| 5 | Verify in dashboard | User "alice" NOT created | +| 6 | Check that ops are re-queued and paused (no more requests until JWT updated) | No repeated 401 requests | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.3: Login then update JWT + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Perform Test 2.2 (login with expired JWT, callback fires) | Setup: alice with invalid JWT, ops paused | +| 2 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT updated in store, `forceExecuteOperations()` called | +| 3 | Check logcat | Ops retry with new JWT. `LoginUserOperation` succeeds | +| 4 | Verify in dashboard | User "alice" now exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.4: Same-user re-login (JWT refresh) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT. Wait for user creation | Alice exists on backend | +| 2 | Tap Login again -> externalId="alice", JWT=new valid token | Login called for same user | +| 3 | Check logcat | No new `LoginUserOperation`. Only JWT updated in store + `forceExecuteOperations()` | +| 4 | Check demo app UI | ExternalId still shows "alice". No loading spinner for user switch | +| 5 | Add a tag | Tag sent with the new JWT in Authorization header | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.5: Login without JWT when IV is ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install), wait for HYDRATE (IV=true) | Setup | +| 2 | Call login with externalId="alice" but no JWT (leave JWT field empty in login dialog) | Login called without JWT | +| 3 | Check logcat | `LoginUserOperation` enqueued but gated (no valid JWT in store) | +| 4 | Verify `onUserJwtInvalidated("alice")` fires | Demo app log shows invalidation message | +| 5 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT provided | +| 6 | Check logcat | Ops unblock and execute | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 2.6: Login with JWT when IV is OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Set IV OFF in dashboard | IV disabled | +| 2 | Open app (fresh install), wait for HYDRATE (IV=false) | Anonymous user created normally | +| 3 | Tap Login -> externalId="alice", JWT=valid token | Login called with JWT | +| 4 | Check logcat | Login proceeds via `onesignal_id`-based URLs (NOT `external_id`). NO `Authorization: Bearer` header sent | +| 5 | Verify in dashboard | User "alice" exists (created via standard flow) | +| 6 | Verify JWT is stored (it will be used later if IV is turned on) | Check logcat for "putJwt" or similar storage log | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 3: Multi-User Login Sequences (IV ON) + +These test the core design change: per-user JWT in `JwtTokenStore` instead of singleton. + +### Test 3.1: Rapid user switching + +**Precondition**: Fresh install. IV ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app, wait for HYDRATE | IV=true, anonymous ops purged | +| 2 | Login as "alice" with valid jwtA | Alice's `LoginUserOperation` enqueued | +| 3 | Add tag key="alice_tag", value="1" | Tag op enqueued with externalId="alice" | +| 4 | Login as "bob" with valid jwtB | Bob's `LoginUserOperation` enqueued. Alice's ops still in queue | +| 5 | Add tag key="bob_tag", value="2" | Tag op enqueued with externalId="bob" | +| 6 | Login as "alice" with valid jwtA2 | JWT refresh for alice. No new user switch if alice was previous user before bob | +| 7 | Add tag key="alice_tag2", value="3" | Tag op enqueued with externalId="alice" | +| 8 | Wait for all ops to process | Check logcat: each op uses correct JWT from JwtTokenStore | +| 9 | Verify in dashboard | alice has tags "alice_tag" and "alice_tag2". bob has tag "bob_tag" | +| 10 | Check demo app | Current user is alice (last login). Push subscription belongs to alice | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 3.2: Multi-user with invalid JWT for one user + +**Precondition**: Fresh install. IV ON. (Matches existing spreadsheet row 10) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "userA" with invalid JWT | Ops enqueued, will fail with 401 | +| 2 | Add tag key="tagA1", value="1" | Tag for userA enqueued | +| 3 | Login as "userB" with invalid JWT | Ops enqueued for userB | +| 4 | Add tag key="tagB1", value="2" | Tag for userB enqueued | +| 5 | Login as "userA" with invalid JWT | JWT refresh for userA (still invalid) | +| 6 | Add tag key="tagA2", value="3" | Another tag for userA | +| 7 | Login as "userB" with VALID JWT | JWT refresh for userB (now valid) | +| 8 | Wait for processing | userB's ops succeed: user created + tagB1 sent. userA's ops get 401, `onUserJwtInvalidated("userA")` fires | +| 9 | Verify in dashboard | userB exists with tagB1. userA does NOT exist yet | +| 10 | Verify current user is userB | Demo app shows externalId="userB" | +| 11 | Force-kill and reopen app | Cold start | +| 12 | Tap "Update JWT" -> externalId="userA", JWT=valid token | userA's JWT updated | +| 13 | Wait for processing | userA's ops execute: user created + tagA1 + tagA2 sent | +| 14 | Verify in dashboard | userA exists with both tags. userB still has its tag | +| 15 | Verify current user is still userB | Push subscription belongs to userB | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 3.3: One user's 401 does not block another + +**Precondition**: Fresh install. IV ON. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with expired JWT | Alice's ops enqueued | +| 2 | Add tag for alice | Tag enqueued for alice | +| 3 | Login as "bob" with valid JWT | Bob's ops enqueued | +| 4 | Add tag for bob | Tag enqueued for bob | +| 5 | Wait for processing | Bob's ops proceed and succeed. Alice's ops get 401, are re-queued | +| 6 | Check callbacks | `onUserJwtInvalidated("alice")` fires. No invalidation for bob | +| 7 | Verify in dashboard | Bob exists with tag. Alice does NOT exist yet | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 4: Logout (IV ON) + +### Test 4.1: Logout with IV ON + +**Precondition**: Logged in as "alice" with valid JWT. IV ON. User exists on backend. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Logout | Logout called | +| 2 | Check logcat | `createAndSwitchToNewUser(suppressBackendOperation=true)` -- local-only sink user created | +| 3 | Check logcat | Push subscription opted out locally (`isDisabledInternally = true`) | +| 4 | Check logcat | NO `LoginUserOperation` enqueued for the anonymous sink user | +| 5 | Check demo app | ExternalId shows empty/null. Push opt-in shows OFF | +| 6 | Wait 30 seconds | No network requests sent for anonymous user | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.2: Logout then add data + +**Precondition**: Perform Test 4.1 (logged out state with IV ON). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add tag key="sink_tag", value="1" | Tag written to local sink user | +| 2 | Add email "test@test.com" | Email written to local sink user | +| 3 | Check logcat | No network requests for tag or email. Ops suppressed by IV+anonymous checks | +| 4 | Wait 30 seconds | No backend calls for any of this data | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.3: Logout then login + +**Precondition**: Perform Test 4.2 (logged out with data on sink user). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Login -> externalId="bob", JWT=valid token | Login called | +| 2 | Check logcat | Sink user replaced entirely by bob. `LoginUserOperation` for bob enqueued and executes | +| 3 | Verify in dashboard | User "bob" exists. No "sink_tag" or "test@test.com" on bob's profile | +| 4 | Check demo app | ExternalId shows "bob" | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.4: Logout, background, reopen, then login (IAM test) + +**Precondition**: Logged in as "alice" with valid JWT. IV ON. At least one IAM configured in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Logout | Logged out, sink user created | +| 2 | Press Home to background the app | App backgrounded | +| 3 | Wait at least 60 seconds | Enough time for new session threshold | +| 4 | Reopen the app | New session triggered | +| 5 | Wait 15 seconds | No IAM fetch request in logcat (anonymous user, IV ON) | +| 6 | Login as "alice" with valid JWT | User re-authenticated | +| 7 | Check logcat | IAM fetch request sent with Authorization header for alice | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 4.5: Logout with IV OFF + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap Logout | Standard v5 logout | +| 2 | Check logcat | New anonymous user created. `LoginUserOperation` enqueued for anonymous user | +| 3 | Wait for processing | Anonymous user created on backend. Push subscription transferred | +| 4 | Verify in dashboard | New anonymous user exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 5: User Data Operations (IV ON) + +**Precondition for all**: IV ON. Logged in as "alice" with valid JWT. User exists on backend. + +### Test 5.1: Add aliases + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Tap "Add Alias" -> label="my_alias", id="123" | Alias add called | +| 2 | Check logcat for HTTP request | URL contains `/users/by/external_id/alice/identity` (NOT `onesignal_id`). `Authorization: Bearer` header present | +| 3 | Verify in dashboard | Alias "my_alias:123" on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.2: Remove aliases + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Remove alias "my_alias" from Test 5.1 | Alias remove called | +| 2 | Check logcat | DELETE request to `/users/by/external_id/alice/identity/my_alias`. Auth header present | +| 3 | Verify in dashboard | Alias removed | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.3: Add tags + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add tag key="color", value="blue" | Tag add called | +| 2 | Check logcat | PATCH request to `/users/by/external_id/alice`. Auth header present | +| 3 | Verify in dashboard | Tag "color:blue" on alice | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.4: Add email/SMS subscriptions + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add email "alice@test.com" | Email subscription add called | +| 2 | Check logcat | POST to create subscription with Auth header | +| 3 | Add SMS "+15551234567" | SMS subscription add called | +| 4 | Check logcat | POST to create subscription with Auth header | +| 5 | Verify in dashboard | Email and SMS subscriptions on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 5.5: All operations while JWT is invalid + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with expired JWT | Ops queued, 401 received, callback fires | +| 2 | Add tag key="pending_tag", value="1" | Tag op queued, gated (no valid JWT) | +| 3 | Add alias label="pending_alias", id="456" | Alias op queued, gated | +| 4 | Add email "pending@test.com" | Email op queued, gated | +| 5 | Check logcat | No HTTP requests for these ops (all waiting for valid JWT) | +| 6 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT updated, `forceExecuteOperations()` | +| 7 | Check logcat | All queued ops flush: user created, tag sent, alias sent, email sent | +| 8 | Verify in dashboard | Alice exists with tag, alias, and email | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 6: In-App Messages (IV ON) + +### Test 6.1: IAM fetch with JWT + +**Precondition**: IV ON. Logged in as "alice" with valid JWT. IAM configured in dashboard for alice's segment. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Background the app for 60+ seconds, then reopen (trigger new session) | Session started | +| 2 | Check logcat for IAM fetch request | URL contains `/users/by/external_id/alice/subscriptions/.../iams`. `Authorization: Bearer` header present | +| 3 | Verify IAM displays correctly | Message appears in app | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 6.2: IAM fetch skipped for anonymous user + +**Precondition**: IV ON. Fresh install, no login. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install) | HYDRATE with IV=true, anonymous ops purged | +| 2 | Background for 60+ seconds, reopen | New session triggered | +| 3 | Check logcat | NO IAM fetch request (anonymous user doesn't exist on backend) | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 6.3: IAM fetch with expired JWT + +**Precondition**: IV ON. Logged in as "alice" but JWT has expired. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with a JWT that will expire soon. Wait for it to expire | JWT now invalid | +| 2 | Background for 60+ seconds, reopen | New session, IAM fetch attempted | +| 3 | Check logcat | IAM fetch fails with 401. `onUserJwtInvalidated("alice")` fires | +| 4 | Update JWT with valid token | JWT refreshed | +| 5 | Background and reopen again | New session | +| 6 | Check logcat | IAM fetch succeeds with new JWT | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 7: Caching, Persistence, and Retry (IV ON) + +### Test 7.1: Offline queueing + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app (fresh install), wait for HYDRATE (IV=true) | Setup | +| 2 | Enable airplane mode | No internet | +| 3 | Login as "alice" with valid JWT | JWT stored. `LoginUserOperation` enqueued. HTTP fails (no network) | +| 4 | Add tag key="offline_tag", value="1" | Tag op enqueued | +| 5 | Add email "offline@test.com" | Email op enqueued | +| 6 | Force-kill the app | Ops persisted to disk | +| 7 | Disable airplane mode | Internet restored | +| 8 | Reopen the app | Persisted ops loaded. JWT still in JwtTokenStore | +| 9 | Wait for HYDRATE and processing | All ops execute with JWT: user created, tag sent, email added | +| 10 | Verify in dashboard | Alice exists with tag and email | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 7.2: Expired JWT in cache + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT. Verify user created | Setup | +| 2 | Update JWT: tap "Update JWT" -> externalId="alice", JWT=expired token | Expired JWT now in store | +| 3 | Add tags and aliases | Ops enqueued | +| 4 | Force-kill the app | Ops and expired JWT persisted | +| 5 | Reopen the app | Ops loaded, JWT loaded | +| 6 | Wait for processing | Ops try with expired JWT, get 401. `onUserJwtInvalidated("alice")` fires | +| 7 | Tap "Update JWT" -> externalId="alice", JWT=new valid token | JWT updated | +| 8 | Wait for processing | Ops retry and succeed | +| 9 | Verify in dashboard | Tags and aliases on alice's profile | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 7.3: JwtTokenStore pruning on cold start + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Login as "alice" with valid JWT | Alice's JWT stored | +| 2 | Add a tag for alice | Op queued for alice | +| 3 | Login as "bob" with valid JWT | Bob's JWT stored | +| 4 | Wait for all ops to complete | Both users created on backend | +| 5 | Force-kill the app | State persisted | +| 6 | Reopen the app | `loadSavedOperations()` runs, `pruneToExternalIds()` called | +| 7 | Check logcat | JwtTokenStore only contains entries for externalIds with pending ops + current identity. No stale entries from old users | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 8: Migration Paths + +### 8A: New Install + +#### Test 8A.1: New install, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Open app | Anonymous `LoginUserOperation` enqueued, held by IV=null gate | +| 2 | Wait for HYDRATE (IV=true) | Anonymous op purged. Log: "Removing operations without externalId" | +| 3 | Verify no user created on backend | Dashboard shows no new anonymous user | +| 4 | Login as "alice" with JWT | User created on backend | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8A.2: New install, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Open app | Anonymous `LoginUserOperation` enqueued | +| 2 | Wait for HYDRATE (IV=false) | Anonymous user created on backend normally | +| 3 | Verify in dashboard | Standard v5 anonymous user exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### 8B: v4 Player Model Migration + +#### Test 8B.1: v4 -> this branch, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 SDK demo app. Open, let player register | Legacy player ID stored | +| 2 | Upgrade to `feat/identity_verification_5.8` (install over) | Migration path triggered | +| 3 | Open app | `LoginUserFromSubscriptionOperation` enqueued. Held until HYDRATE | +| 4 | Wait for HYDRATE (IV=false) | Migration op proceeds: legacy player linked to new v5 user | +| 5 | Verify in dashboard | User has push subscription linked from legacy player | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8B.2: v4 -> this branch, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 SDK demo app. Open, let player register | Legacy player ID stored | +| 2 | Turn IV ON in dashboard | IV enabled | +| 3 | Upgrade to `feat/identity_verification_5.8` (install over) | Migration path triggered | +| 4 | Open app | `LoginUserFromSubscriptionOperation` enqueued (externalId=null). Held until HYDRATE | +| 5 | Wait for HYDRATE (IV=true) | `IdentityVerificationService` purges the op (externalId=null). Legacy player ID cleared | +| 6 | Check logcat | Executor safety net: `FAIL_NORETRY` if somehow reached. Purge message logged | +| 7 | Login as "alice" with JWT | New user created on backend | +| 8 | Verify in dashboard | Alice exists. Legacy player is NOT linked (migration was purged) | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8B.3: v4 -> this branch, IV ON, no internet then login + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 app, let player register. Turn IV ON in dashboard | Setup | +| 2 | Enable airplane mode | No internet | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | Migration op enqueued. No HYDRATE possible | +| 4 | Login as "alice" with valid JWT | Alice's op enqueued, JWT stored | +| 5 | Disable airplane mode | Internet restored | +| 6 | Wait for HYDRATE (IV=true) | Legacy migration op purged. Alice's op executes with JWT | +| 7 | Verify in dashboard | Alice exists. No legacy player linkage | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### 8C: v5 (no IV) Migration + +#### Test 8C.1: v5 (anonymous user) -> this branch, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Open, let anonymous user sync | Anonymous user on backend | +| 2 | Upgrade to `feat/identity_verification_5.8` | Migration | +| 3 | Open app. Wait for HYDRATE (IV=false) | Normal startup. Existing anonymous user continues | +| 4 | Verify | No behavioral change from standard v5 | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8C.2: v5 (anonymous user) -> this branch, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Open, let anonymous user sync | Anonymous user on backend | +| 2 | Turn IV ON in dashboard | IV enabled | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | Anonymous ops purged on HYDRATE | +| 4 | Wait for HYDRATE | SDK in "logged out" state. No anonymous user creation attempted | +| 5 | Login as "alice" with JWT | New user created on backend | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8C.3: v5 (identified user) -> this branch, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Login as "alice" (no JWT). Verify user on backend | Identified user exists | +| 2 | Turn IV ON in dashboard | IV enabled | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | HYDRATE fires | +| 4 | Check logcat | `IdentityVerificationService` detects externalId="alice" but no JWT in JwtTokenStore | +| 5 | Check callback | `onUserJwtInvalidated("alice")` fires. Demo app log shows it | +| 6 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT provided | +| 7 | Check logcat | Ops resume with JWT. Requests now include Authorization header | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8C.4: v5 (identified user) -> this branch, IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Login as "alice". Verify user on backend | Identified user | +| 2 | IV remains OFF | No IV | +| 3 | Upgrade to `feat/identity_verification_5.8`, open app | Normal startup | +| 4 | Verify | Standard v5 behavior. No JWT required. No Authorization headers | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### 8D: JWT Beta Branch Migration + +#### Test 8D.1: Beta -> this branch, logged in user, IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install beta branch demo app. Login as "alice" with JWT | Beta stores JWT on singleton IdentityModel | +| 2 | Upgrade to `feat/identity_verification_5.8` (install over) | Migration | +| 3 | Open app | Persisted ops from beta loaded. Beta ops lack `externalId` field (loaded as null) | +| 4 | Wait for HYDRATE (IV=true) | Ops with null externalId purged by IVS or skipped by OperationRepo | +| 5 | Check logcat | Stale `jwt_token` key on IdentityModel is harmless (not read) | +| 6 | Check callback | `IdentityVerificationService` detects: externalId="alice" + no JWT in new JwtTokenStore -> `onUserJwtInvalidated("alice")` fires | +| 7 | Tap "Update JWT" or re-login as "alice" with JWT | Fresh JWT provided | +| 8 | Check logcat | Ops execute with new JWT. User synced to backend | + +**Result**: [ ] PASS / [ ] FAIL + +#### Test 8D.2: Beta -> this branch, multi-user stuck state + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install beta branch. Login as "userA" with expired JWT (ops stuck with 401) | Beta's singleton JWT bug: userA's 401 blocks everything | +| 2 | Login as "userB" on beta (overwrites singleton JWT) | Beta may be in inconsistent state | +| 3 | Upgrade to `feat/identity_verification_5.8` | Migration | +| 4 | Open app. Wait for HYDRATE (IV=true) | All stuck beta ops have null externalId -> purged. Clean slate | +| 5 | Login as "userA" with valid JWT | Fresh user creation for userA | +| 6 | Login as "userB" with valid JWT | Fresh user creation for userB | +| 7 | Verify both users on dashboard | Both exist independently | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 9: IV Toggle (Dashboard Changes) + +### Test 9.1: IV OFF -> IV ON (between app sessions) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Login as "alice" (no JWT). User exists on backend | Setup | +| 2 | Close the app (force kill) | App terminated | +| 3 | Turn IV ON in dashboard | IV now enabled | +| 4 | Reopen app | HYDRATE with IV=true | +| 5 | Check logcat | Alice has externalId but no JWT in store. `onUserJwtInvalidated("alice")` fires | +| 6 | Verify ops are gated | No backend requests until JWT provided | +| 7 | Tap "Update JWT" -> externalId="alice", JWT=valid token | JWT provided | +| 8 | Check logcat | Ops resume with JWT Authorization headers | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 9.2: IV ON -> IV OFF + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with JWT. User exists | Setup | +| 2 | Close the app | App terminated | +| 3 | Turn IV OFF in dashboard | IV disabled | +| 4 | Reopen app | HYDRATE with IV=false | +| 5 | Check logcat | All ops proceed without JWT gating. No Authorization headers. URLs use `onesignal_id` instead of `external_id` | +| 6 | Add a tag | Tag sent without auth header, via onesignal_id URL | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 9.3: Pre-provision JWT before IV ON + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF | IV disabled | +| 2 | Login as "alice" with valid JWT | JWT stored unconditionally in JwtTokenStore. Login proceeds normally without auth header | +| 3 | Verify user on backend via standard flow | Alice exists (created via onesignal_id) | +| 4 | Close app | App terminated | +| 5 | Turn IV ON in dashboard | IV enabled | +| 6 | Reopen app | HYDRATE with IV=true | +| 7 | Check logcat | Stored JWT immediately available. No `onUserJwtInvalidated` callback | +| 8 | Add a tag | Request uses `external_id` URL with Authorization header from pre-provisioned JWT | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 10: Edge Cases and Error Handling + +### Test 10.1: Callback contains correct externalId + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with expired JWT | 401 received | +| 2 | Check `onUserJwtInvalidated` event | `event.externalId` == "alice" (the user whose JWT failed, which IS the current user) | +| 3 | Login as "bob" with valid JWT, then update alice's JWT to expired | Bob current, alice has pending ops with bad JWT | +| 4 | Check callback | `event.externalId` == "alice" (NOT "bob", the current user) | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.2: Rapid login/logout cycles + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Fresh install, wait for HYDRATE | Setup | +| 2 | Login "a" with jwt -> logout -> login "b" with jwt -> logout -> login "c" with jwt (rapidly) | Multiple user switches | +| 3 | Wait for all ops to settle | Only "c" should have active ops that need to execute | +| 4 | Check demo app | Current user is "c" | +| 5 | Verify in dashboard | User "c" exists. No leaked data from "a" or "b" sink users on "c"'s profile | +| 6 | Check that users "a" and "b" exist on backend (their LoginUserOps executed before logout purged sink data) | Depends on timing -- they may or may not exist | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.3: updateUserJwt for non-current user + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with expired JWT (ops stuck). Then login as "bob" with valid JWT | Bob is current user. Alice has pending ops with bad JWT | +| 2 | Tap "Update JWT" -> externalId="alice", JWT=valid token | Alice's JWT updated | +| 3 | Check logcat | Alice's pending ops (from earlier) now execute with the new JWT | +| 4 | Check demo app | Current user remains "bob" | +| 5 | Verify in dashboard | Both alice and bob exist with correct data | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.4: No internet at startup, login, kill, internet on, reopen + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode. Fresh install | No internet | +| 2 | Open app | `initWithContext` called. Anonymous op enqueued. No HYDRATE possible | +| 3 | Login as "alice" with valid JWT | Alice's `LoginUserOperation` enqueued. JWT stored | +| 4 | Force-kill the app | Ops persisted | +| 5 | Disable airplane mode | Internet restored | +| 6 | Reopen app | Persisted ops loaded. HYDRATE arrives (IV=true). Anonymous ops purged | +| 7 | Check logcat | Alice's ops execute with JWT | +| 8 | Verify in dashboard | Alice exists | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 10.5: Delete user on server, then new session (IV ON) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV ON. Login as "alice" with JWT. Verify user on backend | Setup | +| 2 | Delete user "alice" via OneSignal Dashboard or API | User removed from backend | +| 3 | Background app 60+ seconds, reopen | New session triggered | +| 4 | Check logcat | Session-related ops for alice may fail with an error. App should not crash | +| 5 | Check app behavior | SDK handles error gracefully | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Section 11: IV OFF Regression + +Ensure all existing v5 behavior remains unchanged when IV is OFF. These are NOT new tests -- they are the standard v5 test suite that must still pass. + +### Test 11.1: Anonymous user creation on startup + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Fresh install. Open app | Anonymous user created on backend | +| 2 | Verify in dashboard | User exists with push subscription | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 11.2: Login with externalId (no JWT) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Login as "alice" (no JWT) | User created/identified on backend via standard flow | +| 2 | Verify | No Authorization headers. onesignal_id-based URLs | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 11.3: Logout creates new anonymous user on backend + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Logged in as "alice". Tap Logout | Standard logout | +| 2 | Verify | New anonymous user created on backend. Push subscription transferred | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 11.4: Tags, aliases, email/SMS + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Logged in. Add tags, aliases, email, SMS | Standard operations | +| 2 | Verify in dashboard | All data on user's profile. No auth headers. onesignal_id URLs | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 11.5: IAM fetching + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. IAM configured. Trigger new session | IAM fetched | +| 2 | Verify | IAM displays. No auth headers. onesignal_id-based URL | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 11.6: Cached requests (offline/online) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | IV OFF. Airplane mode. Add tags, aliases | Ops queued | +| 2 | Disable airplane mode | Ops flush and succeed | + +**Result**: [ ] PASS / [ ] FAIL + +### Test 11.7: v4 -> v5 migration (IV OFF) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4, register player. Upgrade to this branch. IV OFF | Standard migration | +| 2 | Verify | `LoginUserFromSubscriptionOperation` succeeds. Legacy player linked to new user | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +## Testing Checklist Summary + +For each migration path (New Install, v4, v5 no-IV, Beta), verify: + +| Check | New Install | v4 | v5 (no IV) | Beta | +|-------|:-----------:|:--:|:----------:|:----:| +| IV ON: No anonymous user created on backend | [ ] | [ ] | [ ] | [ ] | +| IV ON: Login with valid JWT creates user | [ ] | [ ] | [ ] | [ ] | +| IV ON: Login with invalid JWT fires callback | [ ] | [ ] | [ ] | [ ] | +| IV ON: updateUserJwt unblocks pending ops | [ ] | [ ] | [ ] | [ ] | +| IV ON: Logout creates local-only sink user, push disabled | [ ] | [ ] | [ ] | [ ] | +| IV ON: Multi-user JWT isolation (A's bad JWT doesn't block B) | [ ] | [ ] | [ ] | [ ] | +| IV ON: Cold start restores ops and JWTs correctly | [ ] | [ ] | [ ] | [ ] | +| IV ON: IAM fetch uses external_id + JWT | [ ] | [ ] | [ ] | [ ] | +| IV OFF: Identical to current v5 behavior (no regressions) | [ ] | [ ] | [ ] | [ ] | +| Migration-specific: Correct handling of legacy data | N/A | [ ] | [ ] | [ ] | From b9b637d552d3f428e0da27a7150994a7aa9e5000 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 21:24:30 -0700 Subject: [PATCH 29/29] Update IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md --- .../IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md | 366 ++++++++++++++++-- 1 file changed, 334 insertions(+), 32 deletions(-) diff --git a/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md b/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md index 4d22244f6d..282cd89665 100644 --- a/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md +++ b/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md @@ -881,68 +881,355 @@ These test the core design change: per-user JWT in `JwtTokenStore` instead of si ## Section 11: IV OFF Regression -Ensure all existing v5 behavior remains unchanged when IV is OFF. These are NOT new tests -- they are the standard v5 test suite that must still pass. +This branch modifies the core operation pipeline for ALL apps, even when Identity Verification is OFF. The most significant change is that `OperationRepo.getNextOps` now returns `null` (holding all ops) whenever `useIdentityVerification == null` -- which happens on every fresh launch before remote params arrive. Additionally, `externalId` is now stamped on all operations unconditionally, and the 401/FAIL_UNAUTHORIZED handler runs regardless of IV status. These tests ensure no regressions. -### Test 11.1: Anonymous user creation on startup +### Test 11.1: Anonymous user creation on startup (HYDRATE timing) + +**Precondition**: Fresh install. IV is OFF in dashboard. Good network. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Uninstall app. Build and install from `feat/identity_verification_5.8` | Clean install | +| 2 | Open app. Start a timer | `initWithContext` called. Anonymous `LoginUserOperation` enqueued | +| 3 | Watch logcat for `useIdentityVerification` changing from null to false | HYDRATE arrives. Note the elapsed time | +| 4 | Verify the anonymous user creation request is sent immediately after HYDRATE | Request visible in logcat (POST /users) within seconds of app launch | +| 5 | Verify in dashboard | Anonymous user exists with push subscription | +| 6 | Note total time from app open to user creation | Should be comparable to pre-IV-branch behavior (remote params fetch is the only new gate) | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.2: HYDRATE stall -- cold start with persisted config + +**Precondition**: App was previously launched with IV OFF. Config is persisted. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app, wait for HYDRATE (IV=false), confirm anonymous user created | First launch done. Config persisted with `useIdentityVerification = false` | +| 2 | Force-kill the app | App terminated | +| 3 | Reopen the app. Watch logcat carefully | On cold start, persisted `ConfigModel` should already have `useIdentityVerification = false` | +| 4 | Check if ops are held or execute immediately | Ops should NOT be held waiting for HYDRATE -- persisted config has a known `false` value. Verify there is no unnecessary stall | +| 5 | Add a tag immediately after opening | Tag should be sent promptly without waiting for fresh remote params | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.3: HYDRATE stall -- prolonged offline (no remote params) + +**Precondition**: Fresh install. IV OFF in dashboard. Device in airplane mode. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet | +| 2 | Uninstall and reinstall app | Fresh install, no persisted config | +| 3 | Open app | `initWithContext` called. Anonymous op enqueued. Remote params fetch fails | +| 4 | Check logcat: what is the value of `useIdentityVerification`? | Should be `null` (unknown -- no remote params, no persisted config) | +| 5 | Wait 30 seconds. Check if any ops have executed | Ops should be HELD (queue stalled because IV is null). No network requests attempted for user creation | +| 6 | Add a tag, add an alias | Ops enqueued but also held | +| 7 | Disable airplane mode | Internet restored | +| 8 | Wait for remote params to arrive (HYDRATE) | `useIdentityVerification` set to `false` | +| 9 | Check logcat | All held ops (anonymous user creation, tag, alias) should now flush and execute | +| 10 | Verify in dashboard | Anonymous user exists with tag and alias | + +**Result**: [ ] PASS / [ ] FAIL + +**NOTE**: This test reveals the new queue-stall behavior. On the previous v5 main branch, ops would execute immediately even without remote params. Document any timing difference. + +--- + +### Test 11.4: HYDRATE stall -- remote params never arrive + +**Precondition**: Fresh install. Airplane mode stays ON the entire test. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet for entire test | +| 2 | Uninstall and reinstall app | Fresh install | +| 3 | Open app | Anonymous op enqueued. Remote params unreachable | +| 4 | Wait 2 minutes. Check logcat | Ops should remain held. `useIdentityVerification` stays `null`. The SDK should not crash or log errors beyond network failure | +| 5 | Add tags, aliases, login as "alice" (no JWT) | All ops enqueued but held | +| 6 | Force-kill and reopen app (still offline) | Persisted ops reload. Config still has `useIdentityVerification = null`. Ops still held | +| 7 | Disable airplane mode | Internet restored | +| 8 | Wait for HYDRATE | `useIdentityVerification` set to `false`. All held ops flush | +| 9 | Verify in dashboard | User exists (anonymous or alice depending on order). Tags and aliases present | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.5: Login with externalId (no JWT) + +**Precondition**: Fresh install. IV OFF. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Open app. Wait for HYDRATE (IV=false) | Anonymous user created | +| 2 | Tap Login -> externalId="alice", leave JWT empty | Login called without JWT | +| 3 | Check logcat | `LoginUserOperation` enqueued with `existingOneSignalId` set (alias-first flow: attach externalId to existing anonymous user). No Authorization header | +| 4 | Check URL in request | Uses `onesignal_id`-based URL (NOT `external_id`) | +| 5 | Verify in dashboard | User "alice" exists. Previous anonymous user's onesignal_id is alice's onesignal_id (merged) | +| 6 | Verify no JWT-related log messages | No "JWT invalidated", no "Authorization: Bearer" in any request | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.6: Login with externalId that already exists on backend (IV OFF) + +**Precondition**: IV OFF. User "alice" already exists on backend (from a previous device or test). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Fresh install. Open app. Wait for HYDRATE | Anonymous user created | +| 2 | Tap Login -> externalId="alice" (no JWT) | Login called | +| 3 | Check logcat | SDK identifies the existing backend user "alice" and associates this device | +| 4 | Verify in dashboard | Push subscription transferred to existing "alice" user | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.7: Logout creates new anonymous user on backend (IV OFF) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Logged in as "alice". Verify in dashboard | Setup | +| 2 | Tap Logout | Logout called | +| 3 | Check logcat | `createAndSwitchToNewUser()` called (NOT `suppressBackendOperation`). `LoginUserOperation` enqueued for new anonymous user | +| 4 | Check logcat for push | Push subscription transferred to new anonymous user (NOT disabled internally) | +| 5 | Verify in dashboard | New anonymous user created. Push subscription belongs to this new user. Alice's profile no longer has this device's push sub | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.8: Tags, aliases, email/SMS (IV OFF) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add tag key="color", value="red" | Tag sent | +| 2 | Check logcat | PATCH to `/users/by/onesignal_id/`. NO Authorization header | +| 3 | Add alias label="my_alias", id="123" | Alias sent | +| 4 | Check logcat | POST to `/users/by/onesignal_id//identity`. NO Authorization header | +| 5 | Add email "alice@test.com" | Email subscription created | +| 6 | Check logcat | POST to create subscription. NO Authorization header | +| 7 | Add SMS "+15551234567" | SMS subscription created | +| 8 | Verify all in dashboard | All data on alice's profile | +| 9 | Remove the alias | Delete request uses `onesignal_id` URL | +| 10 | Remove the tag | PATCH request uses `onesignal_id` URL | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.9: IAM fetching (IV OFF) + +**Precondition**: IV OFF. Logged in. IAM configured in dashboard. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | IV OFF. Fresh install. Open app | Anonymous user created on backend | -| 2 | Verify in dashboard | User exists with push subscription | +| 1 | Background app for 60+ seconds, reopen | New session triggered | +| 2 | Check logcat for IAM fetch | URL uses `onesignal_id` (NOT `external_id`). NO Authorization header | +| 3 | Verify IAM displays | Message appears correctly | **Result**: [ ] PASS / [ ] FAIL -### Test 11.2: Login with externalId (no JWT) +--- + +### Test 11.10: IAM fetching for anonymous user (IV OFF) + +**Precondition**: IV OFF. Anonymous user (no login). IAM configured for "All Users" segment. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | IV OFF. Login as "alice" (no JWT) | User created/identified on backend via standard flow | -| 2 | Verify | No Authorization headers. onesignal_id-based URLs | +| 1 | Fresh install. Wait for HYDRATE. Anonymous user created | Setup | +| 2 | Background for 60+ seconds, reopen | New session | +| 3 | Check logcat | IAM fetch IS sent for anonymous user (unlike IV ON, where it's skipped). URL uses `onesignal_id` | +| 4 | Verify IAM displays | Message appears | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.11: Cached requests offline/online (IV OFF) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Enable airplane mode | No internet | +| 2 | Add tag key="offline", value="1" | Op enqueued, network fails | +| 3 | Add alias label="offline_alias", id="789" | Op enqueued | +| 4 | Force-kill the app | Ops persisted | +| 5 | Disable airplane mode | Internet restored | +| 6 | Reopen app | Persisted ops loaded | +| 7 | Wait for ops to flush | Ops execute with `onesignal_id` URLs, no auth headers | +| 8 | Verify in dashboard | Tag and alias on alice's profile | **Result**: [ ] PASS / [ ] FAIL -### Test 11.3: Logout creates new anonymous user on backend +--- + +### Test 11.12: Multi-user login/logout sequence (IV OFF) + +**Precondition**: IV OFF. Fresh install. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | IV OFF. Logged in as "alice". Tap Logout | Standard logout | -| 2 | Verify | New anonymous user created on backend. Push subscription transferred | +| 1 | Open app. Wait for HYDRATE. Anonymous user created | Setup | +| 2 | Login as "alice" (no JWT) | Alice's user created/merged from anonymous | +| 3 | Add tag key="alice_tag", value="1" | Tag sent for alice | +| 4 | Login as "bob" (no JWT) | Bob's user created. New session for bob | +| 5 | Add tag key="bob_tag", value="2" | Tag sent for bob | +| 6 | Logout | New anonymous user created on backend | +| 7 | Login as "alice" (no JWT) | Alice re-identified | +| 8 | Verify in dashboard | alice has "alice_tag". bob has "bob_tag". Push subscription is on alice (last login) | +| 9 | Check logcat throughout | No Authorization headers anywhere. All URLs use `onesignal_id`. No JWT-related log messages | **Result**: [ ] PASS / [ ] FAIL -### Test 11.4: Tags, aliases, email/SMS +--- + +### Test 11.13: Login with JWT when IV is OFF (JWT stored but unused) + +**Precondition**: IV OFF. Fresh install. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | IV OFF. Logged in. Add tags, aliases, email, SMS | Standard operations | -| 2 | Verify in dashboard | All data on user's profile. No auth headers. onesignal_id URLs | +| 1 | Open app. Wait for HYDRATE (IV=false) | Anonymous user created | +| 2 | Login as "alice" with a valid JWT token | Login proceeds | +| 3 | Check logcat | JWT stored in JwtTokenStore (unconditional). BUT login request uses `onesignal_id` URL with NO Authorization header | +| 4 | Add a tag | Tag request: `onesignal_id` URL, no auth header | +| 5 | Verify in dashboard | Alice exists, tag present. Standard v5 flow despite JWT being provided | **Result**: [ ] PASS / [ ] FAIL -### Test 11.5: IAM fetching +--- + +### Test 11.14: 401 response handling when IV is OFF + +**Precondition**: IV OFF. Logged in as "alice" with a JWT stored (from Test 11.13 or similar). This tests the unconditional FAIL_UNAUTHORIZED code path. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | IV OFF. IAM configured. Trigger new session | IAM fetched | -| 2 | Verify | IAM displays. No auth headers. onesignal_id-based URL | +| 1 | Force a 401 scenario (e.g., delete user on backend, then try to add a tag) | Operation sent, backend returns 401 | +| 2 | Check logcat for FAIL_UNAUTHORIZED handling | SDK calls `jwtTokenStore.invalidateJwt("alice")` and fires `onUserJwtInvalidated("alice")` -- even though IV is OFF | +| 3 | Check demo app log | "JWT invalidated for externalId: alice" appears | +| 4 | Verify the app does not crash or enter a bad state | App continues functioning. The callback is informational but does not block anything (IV is OFF, so ops are not JWT-gated) | +| 5 | Check if the failed op is retried or dropped | Verify the retry/drop behavior matches standard v5 error handling | **Result**: [ ] PASS / [ ] FAIL -### Test 11.6: Cached requests (offline/online) +**NOTE**: This is a new behavior introduced by this branch. Document whether the `onUserJwtInvalidated` callback firing with IV OFF is acceptable or needs to be gated. + +--- + +### Test 11.15: Cold start with IV OFF (returning user) + +**Precondition**: IV OFF. Previously logged in as "alice". App was killed. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | IV OFF. Airplane mode. Add tags, aliases | Ops queued | -| 2 | Disable airplane mode | Ops flush and succeed | +| 1 | Login as "alice". Add a tag. Verify on backend | Setup complete | +| 2 | Force-kill app | App terminated | +| 3 | Reopen app | Cold start. Persisted config has `useIdentityVerification = false` | +| 4 | Check logcat timing | Ops should NOT be stalled waiting for HYDRATE (persisted config already has `false`) | +| 5 | Check that "alice" is still the current user | ExternalId shown in demo app | +| 6 | Add a new tag immediately | Tag should be sent promptly to backend | +| 7 | Verify in dashboard | New tag on alice's profile | **Result**: [ ] PASS / [ ] FAIL -### Test 11.7: v4 -> v5 migration (IV OFF) +--- + +### Test 11.16: v4 -> this branch migration (IV OFF) + +**Precondition**: App was on v4 SDK with a registered player. IV OFF in dashboard. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install v4 demo app. Open, let player register | Legacy player ID in SharedPreferences | +| 2 | Upgrade to `feat/identity_verification_5.8` (install over top) | Migration path triggered | +| 3 | Open app | `LoginUserFromSubscriptionOperation` enqueued. Held until HYDRATE (IV=null) | +| 4 | Wait for HYDRATE (IV=false) | Migration op executes: legacy player linked to new v5 user | +| 5 | Note timing: how long from app open to migration completion? | Should be only the remote-params fetch time (same as standard upgrade) | +| 6 | Verify in dashboard | User has push subscription linked from legacy player | +| 7 | Add tags, aliases | Standard operations work | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.17: v5 (no IV) -> this branch (anonymous user, IV OFF) + +**Precondition**: App was on v5 main (no JWT feature). Anonymous user exists on backend. + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Open, let anonymous user sync | Anonymous user on backend | +| 2 | Upgrade to `feat/identity_verification_5.8` | SDK upgrade | +| 3 | Open app | Config persisted from prior session may not have `useIdentityVerification` field | +| 4 | Check logcat: is the queue stalled until HYDRATE? | If prior config lacked `useIdentityVerification`, it will be `null` until HYDRATE. Verify ops are held briefly | +| 5 | Wait for HYDRATE (IV=false) | Ops resume. Existing anonymous user continues | +| 6 | Add tags, login, logout | All standard v5 operations work identically | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.18: v5 (no IV) -> this branch (identified user, IV OFF) + +**Precondition**: App was on v5 main. Logged in as "alice" (no JWT). + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Install main branch demo app. Login as "alice". Verify on backend | Identified user exists | +| 2 | Upgrade to `feat/identity_verification_5.8`. IV stays OFF | SDK upgrade | +| 3 | Open app | Config loaded | +| 4 | Check logcat | No `onUserJwtInvalidated` callback (IV is OFF, so IVS does not fire invalidation) | +| 5 | Check demo app | "alice" is still the current user | +| 6 | Add tags, aliases | Standard operations. `onesignal_id` URLs. No auth headers | +| 7 | Logout and re-login | Standard v5 flow | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.19: externalId stamped on operations (IV OFF -- verify no side effects) + +**Precondition**: IV OFF. Logged in as "alice". + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Add a tag | Tag op enqueued | +| 2 | Check logcat/debug: does the operation carry `externalId = "alice"`? | Yes -- OperationRepo stamps externalId unconditionally on new ops | +| 3 | Verify the presence of `externalId` on the op does NOT cause it to use `external_id` in the URL | URL still uses `onesignal_id` (resolveAlias checks `useIdentityVerification == true` before using external_id) | +| 4 | Verify no Authorization header | No auth header (JWT lookup returns null or is not used for auth when IV is false) | +| 5 | Force-kill, reopen | Persisted op has externalId field | +| 6 | Verify ops reload and execute correctly | No issues from the extra field on persisted ops | + +**Result**: [ ] PASS / [ ] FAIL + +--- + +### Test 11.20: JwtTokenStore pruning does not interfere (IV OFF) + +**Precondition**: IV OFF. Login as "alice" with JWT, then login as "bob" with JWT. | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | Install v4, register player. Upgrade to this branch. IV OFF | Standard migration | -| 2 | Verify | `LoginUserFromSubscriptionOperation` succeeds. Legacy player linked to new user | +| 1 | Login as "alice" with JWT. Login as "bob" with JWT | JWTs stored for both | +| 2 | Wait for all ops to complete | Both users on backend | +| 3 | Force-kill and reopen | `loadSavedOperations` runs, `pruneToExternalIds` called | +| 4 | Check logcat | JwtTokenStore pruned. Should not cause errors or affect op execution | +| 5 | Add a tag for bob | Tag sent normally. No auth header. `onesignal_id` URL | +| 6 | Verify no interference from JWT store | Operations proceed identically to pre-IV-branch behavior | **Result**: [ ] PASS / [ ] FAIL @@ -954,13 +1241,28 @@ For each migration path (New Install, v4, v5 no-IV, Beta), verify: | Check | New Install | v4 | v5 (no IV) | Beta | |-------|:-----------:|:--:|:----------:|:----:| -| IV ON: No anonymous user created on backend | [ ] | [ ] | [ ] | [ ] | -| IV ON: Login with valid JWT creates user | [ ] | [ ] | [ ] | [ ] | -| IV ON: Login with invalid JWT fires callback | [ ] | [ ] | [ ] | [ ] | -| IV ON: updateUserJwt unblocks pending ops | [ ] | [ ] | [ ] | [ ] | -| IV ON: Logout creates local-only sink user, push disabled | [ ] | [ ] | [ ] | [ ] | -| IV ON: Multi-user JWT isolation (A's bad JWT doesn't block B) | [ ] | [ ] | [ ] | [ ] | -| IV ON: Cold start restores ops and JWTs correctly | [ ] | [ ] | [ ] | [ ] | -| IV ON: IAM fetch uses external_id + JWT | [ ] | [ ] | [ ] | [ ] | -| IV OFF: Identical to current v5 behavior (no regressions) | [ ] | [ ] | [ ] | [ ] | -| Migration-specific: Correct handling of legacy data | N/A | [ ] | [ ] | [ ] | +| **IV ON** | | | | | +| No anonymous user created on backend | [ ] | [ ] | [ ] | [ ] | +| Login with valid JWT creates user | [ ] | [ ] | [ ] | [ ] | +| Login with invalid JWT fires callback | [ ] | [ ] | [ ] | [ ] | +| updateUserJwt unblocks pending ops | [ ] | [ ] | [ ] | [ ] | +| Logout creates local-only sink user, push disabled | [ ] | [ ] | [ ] | [ ] | +| Multi-user JWT isolation (A's bad JWT doesn't block B) | [ ] | [ ] | [ ] | [ ] | +| Cold start restores ops and JWTs correctly | [ ] | [ ] | [ ] | [ ] | +| IAM fetch uses external_id + JWT | [ ] | [ ] | [ ] | [ ] | +| **IV OFF** | | | | | +| HYDRATE stall: ops held until IV resolved, then execute | [ ] | [ ] | [ ] | [ ] | +| Cold start with persisted config: no unnecessary stall | [ ] | [ ] | [ ] | [ ] | +| Prolonged offline: ops held but resume after HYDRATE | [ ] | [ ] | [ ] | [ ] | +| Anonymous user creation timing comparable to pre-IV | [ ] | [ ] | [ ] | [ ] | +| Login/logout standard v5 flow (no auth headers) | [ ] | [ ] | [ ] | [ ] | +| Multi-user login/logout (no JWT interference) | [ ] | [ ] | [ ] | [ ] | +| Tags, aliases, email/SMS via onesignal_id URLs | [ ] | [ ] | [ ] | [ ] | +| IAM fetch for anonymous and identified users | [ ] | [ ] | [ ] | [ ] | +| Offline caching and retry works | [ ] | [ ] | [ ] | [ ] | +| 401 handling does not break app (callback may fire) | [ ] | [ ] | [ ] | [ ] | +| externalId on ops does not affect URL or auth | [ ] | [ ] | [ ] | [ ] | +| **Migration-specific** | | | | | +| Correct handling of legacy player ID / beta JWT / existing identified user | N/A | [ ] | [ ] | [ ] | +| v4 migration completes after HYDRATE stall | N/A | [ ] | N/A | N/A | +| v5 upgrade with no prior IV config field | N/A | N/A | [ ] | [ ] |