From ca6974eb97fac620691d0e1571c3aaebc7b22f25 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 26 Mar 2026 22:52:47 -0700 Subject: [PATCH 01/45] add public API to interface --- .../src/main/java/com/onesignal/IOneSignal.kt | 21 +++++++++++ .../onesignal/IUserJwtInvalidatedListener.kt | 15 ++++++++ .../src/main/java/com/onesignal/OneSignal.kt | 36 +++++++++++++++++++ .../com/onesignal/UserJwtInvalidatedEvent.kt | 10 ++++++ 4 files changed, 82 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..99d6ec0fd3 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,25 @@ interface IOneSignal { * Logout the current user (suspend version). */ suspend fun logoutSuspend() + + /** + * 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, + ) + + 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/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..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,42 @@ 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, + token: String, + ) { + 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) + } + 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 d59aa970d6a0611a73446acacdb3005ea50c4bf8 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 16:16:29 -0700 Subject: [PATCH 02/45] 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 08215b2910d58b04f6c870d40dbe5455f37d5398 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 16:58:11 -0700 Subject: [PATCH 03/45] 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 6c2a7bb55e99b087e65fb6ba8b0b1454925bb25c Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 17:09:50 -0700 Subject: [PATCH 04/45] 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 cd42bf69057f897493dec463391cca87ce148ec8 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 17:45:18 -0700 Subject: [PATCH 05/45] 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 c51074d6785047c2c8966dec3b7315eff7743f07 Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 17:59:42 -0700 Subject: [PATCH 06/45] 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 c6bc9c069f6023802ef87c3172832020e80ae1ac Mon Sep 17 00:00:00 2001 From: Nan Date: Sun, 29 Mar 2026 19:54:49 -0700 Subject: [PATCH 07/45] 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 268af36aef0918c42c2af85bbe4ada6bc9718d4c Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:10:45 -0700 Subject: [PATCH 08/45] 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 c49f26d4eb7f02c274b90e0b743bcde3a755b740 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:10:52 -0700 Subject: [PATCH 09/45] 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 18d8575cc1f41a3411af12384c9cdaf4bc8a7fd1 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:48:45 -0700 Subject: [PATCH 10/45] 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 82dfc01183..e07fc90881 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 d87089d5b9eda7f2e91bed235e3150ea1e5b1f36 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:56:37 -0700 Subject: [PATCH 11/45] 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 1dab8e0eb849bc573958ee296bd26e066ee69dc7 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 00:59:18 -0700 Subject: [PATCH 12/45] 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 50965e0a1eb2036c675864bb2ac3f66a95bd8b70 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 01:28:18 -0700 Subject: [PATCH 13/45] 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 e07fc90881..8eeeecc91b 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 0c8abb8cdd070de54faf5eeda7e5520895a301a3 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 01:46:55 -0700 Subject: [PATCH 14/45] 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 b145834e92dce142a4cd082f491048fec19c139f Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:05:30 -0700 Subject: [PATCH 15/45] 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 04166773aa755973ec5f8664a61309c2eef33af5 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:17:51 -0700 Subject: [PATCH 16/45] 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 4a9291dd575ce44c9399730a8997f9171decbcb5 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 02:25:24 -0700 Subject: [PATCH 17/45] 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 632a3580c8a94f1333dcc01ec356218a7baeb9c7 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 11:27:12 -0700 Subject: [PATCH 18/45] 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 0f1ad82a94b60dc490b4bdbeb8e54e84ec936a91 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 23:36:46 -0700 Subject: [PATCH 19/45] 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 09a4afa62a6a9f3a0e4a0b90b9e91babe0fdb847 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 30 Mar 2026 23:43:58 -0700 Subject: [PATCH 20/45] 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 4316bd24b7418ea2b6d2e2f3f9600c0dc491582f Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:01:15 -0700 Subject: [PATCH 21/45] 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 0585461e9d1fad86badc17300f89fcc340415be7 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:08:38 -0700 Subject: [PATCH 22/45] 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 b0118ed4c50cb87d82ba1f68cb3690ee21252735 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:17:49 -0700 Subject: [PATCH 23/45] 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 56862246ca430712bf227b29adeedb761c38cefb Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:20:32 -0700 Subject: [PATCH 24/45] 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 c7bc6b2cfb7af942ba0335d7a6a2ec06ef5ac599 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:42:35 -0700 Subject: [PATCH 25/45] 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 b12c74fa1abb92ed833086891b3b5beb8d44f897 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 00:59:33 -0700 Subject: [PATCH 26/45] 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 e818c0a6b6942834028dad060a3dc0705a8cb600 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 01:47:32 -0700 Subject: [PATCH 27/45] 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 d663e43ffb9e661db1467f89b7f949d350a0324a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 31 Mar 2026 21:24:30 -0700 Subject: [PATCH 28/45] 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 | [ ] | [ ] | From ab453bb1bd79e6e0d1b3081ec80a4d057b7eecdd Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 1 Apr 2026 20:04:02 -0700 Subject: [PATCH 29/45] Add KDoc to hasValidJwtIfRequired explain gating --- .../internal/operations/impl/OperationRepo.kt | 15 +++++++++++++++ 1 file changed, 15 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 03259156ce..e5d7dba123 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 @@ -433,6 +433,21 @@ internal class OperationRepo( } } + /** + * Determines whether [op] is allowed to execute given the current identity + * verification (IV) state. Used by [getNextOps] to skip operations that + * cannot yet be authenticated. + * + * Returns true (allow) when any of: + * - IV is disabled for this app + * - The operation opts out of JWT gating ([Operation.requiresJwt] = false) + * - A valid JWT is stored for the operation's [Operation.externalId] + * + * Returns false (hold) when IV is enabled and no valid JWT is available, + * which keeps the operation in the queue until the developer supplies one + * via [OneSignal.updateUserJwt]. Anonymous operations (null externalId) are + * also held because they cannot be authenticated. + */ private fun hasValidJwtIfRequired( iv: Boolean, op: Operation, From 8cc4812628d109a548cf39a04c573c933039c996 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 1 Apr 2026 20:38:35 -0700 Subject: [PATCH 30/45] Set externalId at operation creation time instead of stamping in OperationRepo Pass externalId alongside onesignalId in every operation constructor, eliminating the fragile stampExternalId workaround and parentExternalId propagation from OperationRepo. Every creation site (model store listeners, session listener, executors, etc.) now explicitly provides the externalId at construction time. Made-with: Cursor --- .../internal/operations/impl/OperationRepo.kt | 18 ----- .../purchases/impl/TrackGooglePurchase.kt | 1 + .../internal/session/impl/SessionListener.kt | 4 +- .../onesignal/user/internal/UserSwitcher.kt | 1 + .../builduser/impl/RebuildUserService.kt | 3 +- .../operations/CreateSubscriptionOperation.kt | 3 +- .../operations/DeleteAliasOperation.kt | 3 +- .../operations/DeleteSubscriptionOperation.kt | 3 +- .../internal/operations/DeleteTagOperation.kt | 3 +- .../LoginUserFromSubscriptionOperation.kt | 3 +- .../operations/RefreshUserOperation.kt | 3 +- .../internal/operations/SetAliasOperation.kt | 3 +- .../operations/SetPropertyOperation.kt | 3 +- .../internal/operations/SetTagOperation.kt | 3 +- .../operations/TrackPurchaseOperation.kt | 3 +- .../operations/TrackSessionEndOperation.kt | 3 +- .../operations/TrackSessionStartOperation.kt | 3 +- .../TransferSubscriptionOperation.kt | 3 +- .../operations/UpdateSubscriptionOperation.kt | 3 +- ...inUserFromSubscriptionOperationExecutor.kt | 2 +- .../executors/LoginUserOperationExecutor.kt | 3 +- .../SubscriptionOperationExecutor.kt | 1 + .../listeners/IdentityModelStoreListener.kt | 4 +- .../listeners/PropertiesModelStoreListener.kt | 6 +- .../SubscriptionModelStoreListener.kt | 4 +- .../internal/service/UserRefreshService.kt | 1 + .../operations/OperationModelStoreTests.kt | 2 +- .../internal/operations/OperationRepoTests.kt | 67 +------------------ .../IdentityOperationExecutorTests.kt | 20 +++--- .../LoginUserOperationExecutorTests.kt | 18 +++-- .../RefreshUserOperationExecutorTests.kt | 12 ++-- .../SubscriptionOperationExecutorTests.kt | 24 +++++-- .../UpdateUserOperationExecutorTests.kt | 51 +++++++------- 33 files changed, 124 insertions(+), 160 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 e5d7dba123..99ecab2bc7 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 @@ -130,7 +130,6 @@ 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) } @@ -143,7 +142,6 @@ 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) @@ -157,18 +155,6 @@ 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, @@ -348,13 +334,9 @@ 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) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt index 35f95fac5f..8b2694bcb7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/purchases/impl/TrackGooglePurchase.kt @@ -240,6 +240,7 @@ internal class TrackGooglePurchase( TrackPurchaseOperation( _configModelStore.model.appId, _identityModelStore.model.onesignalId, + _identityModelStore.model.externalId, newAsExisting, BigDecimal(0), purchasesToReport, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt index 1fcdcd8641..4f2ee4959c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt @@ -44,7 +44,7 @@ internal class SessionListener( override fun onSessionStarted() { _propertiesModelStore.model.timezone = TimeUtils.getTimeZoneId() - _operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId), true) + _operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, _identityModelStore.model.externalId), true) } override fun onSessionActive() { @@ -60,7 +60,7 @@ internal class SessionListener( } _operationRepo.enqueue( - TrackSessionEndOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, durationInSeconds), + TrackSessionEndOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, _identityModelStore.model.externalId, durationInSeconds), ) suspendifyOnIO { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt index 5fba367b1a..f1401e031a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserSwitcher.kt @@ -173,6 +173,7 @@ class UserSwitcher( LoginUserFromSubscriptionOperation( configModel.appId, identityModelStore.model.onesignalId, + identityModelStore.model.externalId, legacyPlayerId, ), ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt index a9f42bcfe1..22442072e5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/builduser/impl/RebuildUserService.kt @@ -52,6 +52,7 @@ class RebuildUserService( CreateSubscriptionOperation( appId, onesignalId, + identityModel.externalId, pushSubscription.id, pushSubscription.type, pushSubscription.optedIn, @@ -60,7 +61,7 @@ class RebuildUserService( ), ) } - operations.add(RefreshUserOperation(appId, onesignalId)) + operations.add(RefreshUserOperation(appId, onesignalId, identityModel.externalId)) return operations } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt index 35484f670d..c1335a1b25 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/CreateSubscriptionOperation.kt @@ -88,9 +88,10 @@ class CreateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.CR override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.subscriptionId = subscriptionId this.type = type this.enabled = enabled diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt index 1595a6de2b..cfb1bf0bac 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteAliasOperation.kt @@ -43,9 +43,10 @@ class DeleteAliasOperation() : Operation(IdentityOperationExecutor.DELETE_ALIAS) override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, label: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, label: String) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.label = label } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt index 14c9aee448..8a5cc6bb52 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteSubscriptionOperation.kt @@ -44,9 +44,10 @@ class DeleteSubscriptionOperation() : Operation(SubscriptionOperationExecutor.DE override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId) override val applyToRecordId: String get() = subscriptionId - constructor(appId: String, onesignalId: String, subscriptionId: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.subscriptionId = subscriptionId } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt index f88ae3c568..4819c3248f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/DeleteTagOperation.kt @@ -44,9 +44,10 @@ class DeleteTagOperation() : Operation(UpdateUserOperationExecutor.DELETE_TAG) { override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, key: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, key: String) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.key = key } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt index 9597283f75..655e71777c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserFromSubscriptionOperation.kt @@ -42,9 +42,10 @@ class LoginUserFromSubscriptionOperation() : Operation(LoginUserFromSubscription override val canStartExecute: Boolean = true override val applyToRecordId: String get() = subscriptionId - constructor(appId: String, onesignalId: String, subscriptionId: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.subscriptionId = subscriptionId } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt index 953cbe7b9c..5f57cbac79 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/RefreshUserOperation.kt @@ -35,9 +35,10 @@ class RefreshUserOperation() : Operation(RefreshUserOperationExecutor.REFRESH_US override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId } override fun translateIds(map: Map) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt index c88c23e46f..3bf5fd678b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetAliasOperation.kt @@ -53,9 +53,10 @@ class SetAliasOperation() : Operation(IdentityOperationExecutor.SET_ALIAS) { override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, label: String, value: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, label: String, value: String) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.label = label this.value = value } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt index 2aff9c174a..0be614b9eb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetPropertyOperation.kt @@ -52,9 +52,10 @@ class SetPropertyOperation() : Operation(UpdateUserOperationExecutor.SET_PROPERT override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, property: String, value: Any?) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, property: String, value: Any?) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.property = property this.value = value } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt index 88bfa06eda..505b79506c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/SetTagOperation.kt @@ -53,9 +53,10 @@ class SetTagOperation() : Operation(UpdateUserOperationExecutor.SET_TAG) { override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, key: String, value: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, key: String, value: String) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.key = key this.value = value } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt index 78da8cfb0a..7abb7170be 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackPurchaseOperation.kt @@ -65,9 +65,10 @@ class TrackPurchaseOperation() : Operation(UpdateUserOperationExecutor.TRACK_PUR override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, treatNewAsExisting: Boolean, amountSpent: BigDecimal, purchases: List) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, treatNewAsExisting: Boolean, amountSpent: BigDecimal, purchases: List) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.treatNewAsExisting = treatNewAsExisting this.amountSpent = amountSpent this.purchases = purchases diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt index 940051e91b..c0dfce68cc 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionEndOperation.kt @@ -43,9 +43,10 @@ class TrackSessionEndOperation() : Operation(UpdateUserOperationExecutor.TRACK_S override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String, sessionTime: Long) : this() { + constructor(appId: String, onesignalId: String, externalId: String?, sessionTime: Long) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.sessionTime = sessionTime } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt index e5b9e0f29c..5b1285f5f7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackSessionStartOperation.kt @@ -34,9 +34,10 @@ class TrackSessionStartOperation() : Operation(UpdateUserOperationExecutor.TRACK override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) override val applyToRecordId: String get() = onesignalId - constructor(appId: String, onesignalId: String) : this() { + constructor(appId: String, onesignalId: String, externalId: String?) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId } override fun translateIds(map: Map) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt index 54aa3bae27..c0d227260e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TransferSubscriptionOperation.kt @@ -50,10 +50,11 @@ class TransferSubscriptionOperation() : Operation(SubscriptionOperationExecutor. override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId) override val applyToRecordId: String get() = subscriptionId - constructor(appId: String, subscriptionId: String, onesignalId: String) : this() { + constructor(appId: String, subscriptionId: String, onesignalId: String, externalId: String?) : this() { this.appId = appId this.subscriptionId = subscriptionId this.onesignalId = onesignalId + this.externalId = externalId } override fun translateIds(map: Map) { 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 426da703dd..9507bdc185 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 @@ -88,9 +88,10 @@ class UpdateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.UP 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() { + constructor(appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus) : this() { this.appId = appId this.onesignalId = onesignalId + this.externalId = externalId this.subscriptionId = subscriptionId this.type = type this.enabled = enabled 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 cf63ab2e20..15072ec014 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 @@ -81,7 +81,7 @@ internal class LoginUserFromSubscriptionOperationExecutor( return ExecutionResponse( ExecutionResult.SUCCESS, idTranslations, - listOf(RefreshUserOperation(loginUserOp.appId, backendOneSignalId)), + listOf(RefreshUserOperation(loginUserOp.appId, backendOneSignalId, loginUserOp.externalId)), ) } catch (ex: BackendException) { val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) 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 c80adff4ae..a2b5365eb8 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 @@ -91,6 +91,7 @@ internal class LoginUserOperationExecutor( SetAliasOperation( loginUserOp.appId, loginUserOp.existingOnesignalId!!, + loginUserOp.externalId, IdentityConstants.EXTERNAL_ID, loginUserOp.externalId!!, ), @@ -226,7 +227,7 @@ internal class LoginUserOperationExecutor( val wasPossiblyAnUpsert = identities.isNotEmpty() val followUpOperations = if (wasPossiblyAnUpsert) { - listOf(RefreshUserOperation(createUserOperation.appId, backendOneSignalId)) + listOf(RefreshUserOperation(createUserOperation.appId, backendOneSignalId, createUserOperation.externalId)) } else { null } 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 97d78ec4d7..78c1e21ee9 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 @@ -232,6 +232,7 @@ internal class SubscriptionOperationExecutor( CreateSubscriptionOperation( lastOperation.appId, lastOperation.onesignalId, + lastOperation.externalId, lastOperation.subscriptionId, lastOperation.type, lastOperation.enabled, 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 b34d7069b7..bab60f0d55 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 @@ -33,9 +33,9 @@ internal class IdentityModelStoreListener( if (shouldSuppressForAnonymousUser()) return null return if (newValue != null && newValue is String) { - SetAliasOperation(_configModelStore.model.appId, model.onesignalId, property, newValue) + SetAliasOperation(_configModelStore.model.appId, model.onesignalId, model.externalId, property, newValue) } else { - DeleteAliasOperation(_configModelStore.model.appId, model.onesignalId, property) + DeleteAliasOperation(_configModelStore.model.appId, model.onesignalId, model.externalId, property) } } } 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 8ca4d7326a..79cabb8f8c 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 @@ -46,12 +46,12 @@ internal class PropertiesModelStoreListener( if (path.startsWith(PropertiesModel::tags.name)) { return if (newValue != null && newValue is String) { - SetTagOperation(_configModelStore.model.appId, model.onesignalId, property, newValue) + SetTagOperation(_configModelStore.model.appId, model.onesignalId, _identityModelStore.model.externalId, property, newValue) } else { - DeleteTagOperation(_configModelStore.model.appId, model.onesignalId, property) + DeleteTagOperation(_configModelStore.model.appId, model.onesignalId, _identityModelStore.model.externalId, property) } } - return SetPropertyOperation(_configModelStore.model.appId, model.onesignalId, property, newValue) + return SetPropertyOperation(_configModelStore.model.appId, model.onesignalId, _identityModelStore.model.externalId, property, newValue) } } 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 4be3aa2e31..dd0a81eec6 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 @@ -29,6 +29,7 @@ internal class SubscriptionModelStoreListener( return CreateSubscriptionOperation( _configModelStore.model.appId, _identityModelStore.model.onesignalId, + _identityModelStore.model.externalId, model.id, model.type, enabledAndStatus.first, @@ -40,7 +41,7 @@ internal class SubscriptionModelStoreListener( override fun getRemoveOperation(model: SubscriptionModel): Operation? { if (shouldSuppressForAnonymousUser()) return null - return DeleteSubscriptionOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, model.id) + return DeleteSubscriptionOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, _identityModelStore.model.externalId, model.id) } override fun getUpdateOperation( @@ -56,6 +57,7 @@ internal class SubscriptionModelStoreListener( return UpdateSubscriptionOperation( _configModelStore.model.appId, _identityModelStore.model.onesignalId, + _identityModelStore.model.externalId, model.id, model.type, enabledAndStatus.first, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt index 7b04d7981e..98e6f719db 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/service/UserRefreshService.kt @@ -32,6 +32,7 @@ class UserRefreshService( RefreshUserOperation( _configModelStore.model.appId, _identityModelStore.model.onesignalId, + _identityModelStore.model.externalId, ), ) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt index d4fcee869d..644b4f4102 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationModelStoreTests.kt @@ -28,7 +28,7 @@ class OperationModelStoreTests : FunSpec({ val jsonArray = JSONArray() // 1. Create a VALID Operation with onesignalId - val validOperation = SetPropertyOperation(UUID.randomUUID().toString(), UUID.randomUUID().toString(), "property", "value") + val validOperation = SetPropertyOperation(UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, "property", "value") validOperation.id = UUID.randomUUID().toString() // 2. Create a VALID operation missing onesignalId 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 7fae5773af..6858b8db21 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,77 +960,12 @@ 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() coEvery { mocks.executor.execute(any()) } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) val operation = mockOperation() - // externalId defaults to null in mockOperation // When mocks.operationRepo.start() @@ -1038,7 +973,7 @@ class OperationRepoTests : FunSpec({ // Then response shouldBe false - verify { mocks.operationModelStore.remove(operation.id) } + verify { mocks.operationModelStore.remove(any()) } } }) { companion object { 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 2ac7861484..8ad425895c 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 @@ -41,7 +41,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) + val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1")) // When val response = identityOperationExecutor.execute(operations) @@ -71,7 +71,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) + val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1")) // When @@ -92,7 +92,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) + val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1")) // When @@ -113,7 +113,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) + val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1")) // When @@ -136,7 +136,7 @@ class IdentityOperationExecutorTests : FunSpec({ val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, mockConfigModelStore, mockk(relaxed = true)) - val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) + val operations = listOf(SetAliasOperation("appId", "onesignalId", null, "aliasKey1", "aliasValue1")) // When @@ -162,7 +162,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) + val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1")) // When val response = identityOperationExecutor.execute(operations) @@ -185,7 +185,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) + val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1")) // When @@ -205,7 +205,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) + val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1")) // When @@ -227,7 +227,7 @@ class IdentityOperationExecutorTests : FunSpec({ val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState(), MockHelper.configModelStore(), mockk(relaxed = true)) - val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) + val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1")) // When @@ -252,7 +252,7 @@ class IdentityOperationExecutorTests : FunSpec({ val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState, mockConfigModelStore, mockk(relaxed = true)) - val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) + val operations = listOf(DeleteAliasOperation("appId", "onesignalId", null, "aliasKey1")) // When val response = identityOperationExecutor.execute(operations) 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 001a3d96f4..df9b566f2f 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 @@ -44,6 +44,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, "subscriptionId1", SubscriptionType.PUSH, true, @@ -416,6 +417,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, "subscriptionId1", SubscriptionType.PUSH, true, @@ -425,6 +427,7 @@ class LoginUserOperationExecutorTests : FunSpec({ UpdateSubscriptionOperation( appId, localOneSignalId, + null, "subscriptionId1", SubscriptionType.PUSH, true, @@ -434,13 +437,14 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, "subscriptionId2", SubscriptionType.EMAIL, true, "name@company.com", SubscriptionStatus.SUBSCRIBED, ), - DeleteSubscriptionOperation(appId, localOneSignalId, "subscriptionId2"), + DeleteSubscriptionOperation(appId, localOneSignalId, null, "subscriptionId2"), ) // When @@ -519,6 +523,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, localSubscriptionId1, SubscriptionType.PUSH, true, @@ -528,6 +533,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, localSubscriptionId2, SubscriptionType.EMAIL, true, @@ -607,6 +613,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, localSubscriptionId1, SubscriptionType.PUSH, true, @@ -616,6 +623,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, localSubscriptionId2, SubscriptionType.EMAIL, true, @@ -681,6 +689,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, localSubscriptionId1, SubscriptionType.PUSH, true, @@ -690,6 +699,7 @@ class LoginUserOperationExecutorTests : FunSpec({ CreateSubscriptionOperation( appId, localOneSignalId, + null, localSubscriptionId2, SubscriptionType.EMAIL, true, @@ -792,8 +802,8 @@ class LoginUserOperationExecutorTests : FunSpec({ val ops = listOf( LoginUserOperation(appId, localOneSignalId, null, null), - CreateSubscriptionOperation(appId, localOneSignalId, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken2", SubscriptionStatus.SUBSCRIBED), - CreateSubscriptionOperation(appId, localOneSignalId, localSubscriptionId2, SubscriptionType.EMAIL, true, "name@company.com", SubscriptionStatus.SUBSCRIBED), + CreateSubscriptionOperation(appId, localOneSignalId, null, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken2", SubscriptionStatus.SUBSCRIBED), + CreateSubscriptionOperation(appId, localOneSignalId, null, localSubscriptionId2, SubscriptionType.EMAIL, true, "name@company.com", SubscriptionStatus.SUBSCRIBED), ) // When @@ -856,7 +866,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val ops = listOf( LoginUserOperation(appId, localOneSignalId, null, null), - CreateSubscriptionOperation(appId, localOneSignalId, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken1", SubscriptionStatus.SUBSCRIBED), + CreateSubscriptionOperation(appId, localOneSignalId, null, localSubscriptionId1, SubscriptionType.PUSH, true, "pushToken1", SubscriptionStatus.SUBSCRIBED), ) // When 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 a32a004c73..12f71ff579 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 @@ -111,7 +111,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) - val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null)) try { // When @@ -196,7 +196,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) - val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null)) // When val response = refreshUserOperationExecutor.execute(operations) @@ -236,7 +236,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) - val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null)) // When val response = refreshUserOperationExecutor.execute(operations) @@ -272,7 +272,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) - val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null)) // When val response = refreshUserOperationExecutor.execute(operations) @@ -308,7 +308,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) - val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null)) // When val response = refreshUserOperationExecutor.execute(operations) @@ -346,7 +346,7 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) - val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId, null)) // When val response = refreshUserOperationExecutor.execute(operations) 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 1658207431..7b5a8a5b14 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 @@ -77,6 +77,7 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, @@ -138,6 +139,7 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, @@ -189,6 +191,7 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, @@ -245,6 +248,7 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, @@ -301,6 +305,7 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, @@ -345,13 +350,14 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, "pushToken", SubscriptionStatus.SUBSCRIBED, ), - DeleteSubscriptionOperation(appId, remoteOneSignalId, localSubscriptionId), + DeleteSubscriptionOperation(appId, remoteOneSignalId, null, localSubscriptionId), ) // When @@ -392,6 +398,7 @@ class SubscriptionOperationExecutorTests : CreateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, @@ -401,6 +408,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, localSubscriptionId, SubscriptionType.PUSH, true, @@ -463,6 +471,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, @@ -472,6 +481,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, @@ -525,6 +535,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, @@ -578,6 +589,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, @@ -633,6 +645,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, @@ -673,7 +686,7 @@ class SubscriptionOperationExecutorTests : val operations = listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId), ) // When @@ -708,7 +721,7 @@ class SubscriptionOperationExecutorTests : val operations = listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId), ) // When @@ -744,7 +757,7 @@ class SubscriptionOperationExecutorTests : val operations = listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId), ) // When @@ -780,7 +793,7 @@ class SubscriptionOperationExecutorTests : val operations = listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + DeleteSubscriptionOperation(appId, remoteOneSignalId, null, remoteSubscriptionId), ) // When @@ -825,6 +838,7 @@ class SubscriptionOperationExecutorTests : UpdateSubscriptionOperation( appId, remoteOneSignalId, + null, remoteSubscriptionId, SubscriptionType.PUSH, true, 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 de3ff7b89b..e69b64fd3d 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 @@ -60,7 +60,7 @@ class UpdateUserOperationExecutorTests : MockHelper.configModelStore(), mockk(relaxed = true), ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1")) // When val response = loginUserOperationExecutor.execute(operations) @@ -104,21 +104,21 @@ class UpdateUserOperationExecutorTests : ) val operations = listOf( - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-1"), - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-2"), - SetTagOperation(appId, remoteOneSignalId, "tagKey2", "tagValue2"), - SetTagOperation(appId, remoteOneSignalId, "tagKey3", "tagValue3"), - DeleteTagOperation(appId, remoteOneSignalId, "tagKey3"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang1"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang2"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::timezone.name, "timezone"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::country.name, "country"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLatitude.name, 123.45), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLongitude.name, 678.90), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationType.name, 1), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationAccuracy.name, 0.15), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationBackground.name, true), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationTimestamp.name, 1111L), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1-1"), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1-2"), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey2", "tagValue2"), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey3", "tagValue3"), + DeleteTagOperation(appId, remoteOneSignalId, null, "tagKey3"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::language.name, "lang1"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::language.name, "lang2"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::timezone.name, "timezone"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::country.name, "country"), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationLatitude.name, 123.45), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationLongitude.name, 678.90), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationType.name, 1), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationAccuracy.name, 0.15), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationBackground.name, true), + SetPropertyOperation(appId, localOneSignalId, null, PropertiesModel::locationTimestamp.name, 1111L), ) // When @@ -168,7 +168,7 @@ class UpdateUserOperationExecutorTests : ) val operations = listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 1111), ) // When @@ -215,10 +215,11 @@ class UpdateUserOperationExecutorTests : ) val operations = listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 1111), TrackPurchaseOperation( appId, remoteOneSignalId, + null, false, BigDecimal(2222), listOf( @@ -226,7 +227,7 @@ class UpdateUserOperationExecutorTests : PurchaseInfo("sku2", "iso2", BigDecimal(1222)), ), ), - TrackSessionEndOperation(appId, remoteOneSignalId, 3333), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 3333), ) // When @@ -282,9 +283,9 @@ class UpdateUserOperationExecutorTests : ) val operations = listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1"), - TrackSessionEndOperation(appId, remoteOneSignalId, 3333), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 1111), + SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1"), + TrackSessionEndOperation(appId, remoteOneSignalId, null, 3333), ) // When @@ -330,7 +331,7 @@ class UpdateUserOperationExecutorTests : MockHelper.configModelStore(), mockk(relaxed = true), ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1")) // When val response = loginUserOperationExecutor.execute(operations) @@ -365,7 +366,7 @@ class UpdateUserOperationExecutorTests : MockHelper.configModelStore(), mockk(relaxed = true), ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, null, "tagKey1", "tagValue1")) // When val response = loginUserOperationExecutor.execute(operations) @@ -400,7 +401,7 @@ class UpdateUserOperationExecutorTests : val operations = listOf( - TrackSessionStartOperation(appId, onesignalId = remoteOneSignalId), + TrackSessionStartOperation(appId, onesignalId = remoteOneSignalId, externalId = null), ) // When From e4bf21243d60e8bf2e4ada8bf251bc10d26e1adb Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 09:56:39 -0700 Subject: [PATCH 31/45] fireJwtInvalidated off main --- .../src/main/java/com/onesignal/user/internal/UserManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4ec95c0820..5a1ad799e2 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 @@ -56,7 +56,7 @@ internal open class UserManager( } fun fireJwtInvalidated(externalId: String) { - jwtInvalidatedNotifier.fireOnMain { + jwtInvalidatedNotifier.fire { it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) } } From 374528b1dc575ac52956b797cbe1d2c8e0bb403d Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 09:57:17 -0700 Subject: [PATCH 32/45] nit: update formatting --- .../core/src/main/java/com/onesignal/user/UserModule.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0b92fb85bc..15a1e9dc96 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,9 @@ internal class UserModule : IModule { builder.register().provides() builder.register().provides() builder.register().provides() - builder.register().provides().provides() + builder.register() + .provides() + .provides() builder.register().provides() builder.register().provides() builder.register().provides() From 8606964e1781ca578c19fb3e98f7147d56b9f4a9 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 11:35:33 -0700 Subject: [PATCH 33/45] Add updateUserJwtSuspend and waitForInit to updateUserJwt Add suspend variant of updateUserJwt for API consistency with login/loginSuspend. Also add waitForInit to the non-suspend updateUserJwt so it safely handles calls before SDK initialization. --- .../src/main/java/com/onesignal/IOneSignal.kt | 19 +++++++++++++ .../src/main/java/com/onesignal/OneSignal.kt | 20 +++++++++++-- .../com/onesignal/internal/OneSignalImp.kt | 28 ++++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) 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 99d6ec0fd3..77b13c8187 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -239,6 +239,25 @@ interface IOneSignal { token: String, ) + /** + * Update the JWT bearer token for a user identified by [externalId] (suspend version). + * Call this when a token is about to expire or after receiving an [IUserJwtInvalidatedListener] + * callback. This suspend variant waits for the SDK to be initialized before proceeding. + * + * @param externalId The external ID of the user whose token is being updated. + * @param token The new JWT bearer token. + */ + suspend fun updateUserJwtSuspend( + 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) /** 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 55c343631f..5f2d657a22 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -354,9 +354,7 @@ object OneSignal { fun updateUserJwt( externalId: String, token: String, - ) { - oneSignal.updateUserJwt(externalId, token) - } + ) = oneSignal.updateUserJwt(externalId, token) /** * Add a listener that will be called when a user's JWT is invalidated (e.g. expired @@ -441,6 +439,22 @@ object OneSignal { oneSignal.logoutSuspend() } + /** + * Update the JWT bearer token for a user identified by [externalId] (suspend version). + * 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 + suspend fun updateUserJwtSuspend( + externalId: String, + token: String, + ) { + oneSignal.updateUserJwtSuspend(externalId, token) + } + /** * Used to retrieve services from the SDK when constructor dependency injection is not an * option. 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 875d32042d..e4341abbfc 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 @@ -419,7 +419,33 @@ internal class OneSignalImp( externalId: String, token: String, ) { - Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId)") + Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: $token)") + + if (isBackgroundThreadingEnabled) { + waitForInit(operationName = "updateUserJwt") + jwtTokenStore.putJwt(externalId, token) + operationRepo.forceExecuteOperations() + } else { + if (!isInitialized) { + throw IllegalStateException("Must call 'initWithContext' before 'updateUserJwt'") + } + jwtTokenStore.putJwt(externalId, token) + operationRepo.forceExecuteOperations() + } + } + + override suspend fun updateUserJwtSuspend( + externalId: String, + token: String, + ) = withContext(runtimeIoDispatcher) { + Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: $token)") + + suspendUntilInit(operationName = "updateUserJwt") + + if (!isInitialized) { + throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'") + } + jwtTokenStore.putJwt(externalId, token) operationRepo.forceExecuteOperations() } From 89ca4312459f58fd40fba8e771ac41ca7b563149 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 12:28:02 -0700 Subject: [PATCH 34/45] Address detekt changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Baseline 2 new UseCheckOrError entries for OneSignalImp.kt for updateUserJwt/updateUserJwtSuspend init guards that follow the existing login/logout pattern. - Baseline ReturnCount for hasValidJwtIfRequired — the guard-clause style is clearer than collapsing into a single boolean expression for this JWT gating logic. - Narrow JwtTokenStore catch from Exception to JSONException to satisfy detekt's TooGenericExceptionCaught rule, no baseline added. --- OneSignalSDK/detekt/detekt-baseline-core.xml | 3 +++ .../onesignal/core/internal/operations/impl/OperationRepo.kt | 5 ++--- .../com/onesignal/user/internal/identity/JwtTokenStore.kt | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 0530b2d0eb..ef4b69f083 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -279,6 +279,7 @@ PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase$e PrintStackTrace:TrackGooglePurchase.kt$TrackGooglePurchase.<no name provided>$t RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e + ReturnCount:OperationRepo.kt$OperationRepo$private fun hasValidJwtIfRequired( identityVerificationEnabled: Boolean, op: Operation, ): Boolean 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 @@ -609,6 +610,8 @@ 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("'initWithContext failed' before 'updateUserJwt'") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'updateUserJwt'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") 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 99ecab2bc7..baadb8acbc 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 @@ -431,11 +431,10 @@ internal class OperationRepo( * also held because they cannot be authenticated. */ private fun hasValidJwtIfRequired( - iv: Boolean, + identityVerificationEnabled: Boolean, op: Operation, ): Boolean { - if (!iv) return true - if (!op.requiresJwt) return true + if (!identityVerificationEnabled || !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/identity/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/JwtTokenStore.kt index 2bbda35723..c678d6d219 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 @@ -4,6 +4,7 @@ import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging +import org.json.JSONException import org.json.JSONObject /** @@ -34,7 +35,7 @@ class JwtTokenStore( for (key in obj.keys()) { tokens[key] = obj.getString(key) } - } catch (e: Exception) { + } catch (e: JSONException) { Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh", e) } } From 12e479469ab27b91a29ca51327d2122d761fc897 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 12:52:31 -0700 Subject: [PATCH 35/45] detekt: Baseline 19 ConstructorParameterNaming entries for constructor params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All new _prefix constructor parameters (e.g. _jwtTokenStore, _identityModelStore, _configModelStore) follow the existing codebase convention — 80+ identical entries already exist in the baseline. These are DI-injected dependencies across IdentityVerificationService, OperationRepo, JwtTokenStore, and various executors/listeners. --- OneSignalSDK/detekt/detekt-baseline-core.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index ef4b69f083..49b99e8b1f 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 _identityBackend: IIdentityBackendService + ConstructorParameterNaming:IdentityOperationExecutor.kt$IdentityOperationExecutor$private val _configModelStore: ConfigModelStore 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 From b7b1edb3f5b51c35ef7efeebcdca1d6c3cc50413 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 13:18:32 -0700 Subject: [PATCH 36/45] detekt: update 8 LongParameterList entries for IV changes - Update 4 existing baseline entries (added jwt/_jwtTokenStore to signatures) - Baseline 4 new LongParameterList entries for IV constructors: CreateSubscriptionOperation, UpdateSubscriptionOperation, RefreshUserOperationExecutor, and UpdateUserOperationExecutor crossed the constructor threshold (8) after adding externalId and _jwtTokenStore params. This follows existing patterns. --- OneSignalSDK/detekt/detekt-baseline-core.xml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 49b99e8b1f..80fc3cdec0 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -211,15 +211,19 @@ 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:CreateSubscriptionOperation.kt$CreateSubscriptionOperation$( appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus, ) + 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:UpdateSubscriptionOperation.kt$UpdateSubscriptionOperation$( appId: String, onesignalId: String, externalId: String?, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus, ) + 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 From 8e260bd9b859ea4bf05312e35f1344285d100981 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 2 Apr 2026 20:40:40 -0700 Subject: [PATCH 37/45] Decouple JWT invalidated delivery and document threading OperationRepo: on FAIL_UNAUTHORIZED, invalidate JWT and re-queue before notifying the app; invoke setJwtInvalidatedHandler synchronously with try/catch so listener failures do not hit executeOperations outer catch. UserManager.fireJwtInvalidated performs the single async hop (SupervisorJob + Dispatchers.Default) with Otel-style launch try/catch and per-listener isolation. IdentityVerificationService: call forceExecuteOperations before fireJwtInvalidated on HYDRATE. Documentation: IOperationRepo handler runs on the op-repo thread and must return quickly; public API documents background-thread delivery for IUserJwtInvalidatedListener, addUserJwtInvalidatedListener, and UserJwtInvalidatedEvent (LiveData postValue guidance). Tests: FAIL_UNAUTHORIZED JWT handler coverage; no artificial delay after sync repo handler. --- .../src/main/java/com/onesignal/IOneSignal.kt | 2 + .../onesignal/IUserJwtInvalidatedListener.kt | 8 ++- .../src/main/java/com/onesignal/OneSignal.kt | 2 + .../com/onesignal/UserJwtInvalidatedEvent.kt | 5 +- .../impl/IdentityVerificationService.kt | 7 ++- .../internal/operations/IOperationRepo.kt | 4 ++ .../internal/operations/impl/OperationRepo.kt | 9 ++- .../onesignal/user/internal/UserManager.kt | 22 +++++++- .../internal/operations/OperationRepoTests.kt | 56 +++++++++++++++++++ 9 files changed, 104 insertions(+), 11 deletions(-) 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 77b13c8187..f42ed725c8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt @@ -256,6 +256,8 @@ interface IOneSignal { * 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]. * + * The listener is invoked on a background thread; see [IUserJwtInvalidatedListener]. + * * @param listener The listener to add. */ fun addUserJwtInvalidatedListener(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 index 82cc6e1d7b..99b853ba49 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IUserJwtInvalidatedListener.kt @@ -2,14 +2,16 @@ 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. + * to be notified when the JWT for a user is invalidated. * + * Callbacks are delivered on a background thread. */ interface IUserJwtInvalidatedListener { /** - * Called when the JWT is invalidated + * Called when the JWT is invalidated for [UserJwtInvalidatedEvent.externalId]. + * Invoked on a background thread; see [IUserJwtInvalidatedListener] class documentation. * - * @param event The user JWT that expired. + * @param event Describes which user's JWT was invalidated. */ 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 5f2d657a22..fbd07d0389 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt @@ -360,6 +360,8 @@ object OneSignal { * 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]. * + * The listener is invoked on a background thread; see [IUserJwtInvalidatedListener]. + * * @param listener The listener to add. */ @JvmStatic diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt index 9c7ddcb87b..291591ada8 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/UserJwtInvalidatedEvent.kt @@ -1,9 +1,8 @@ package com.onesignal /** - * The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated], it provides access - * to the external ID whose JWT has just been invalidated. - * + * The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated]. Delivery occurs on + * a background thread; see [IUserJwtInvalidatedListener]. */ class UserJwtInvalidatedEvent( val externalId: String, 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 171be38190..597cd908f6 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 @@ -42,18 +42,21 @@ internal class IdentityVerificationService( val useIV = model.useIdentityVerification + var jwtInvalidatedExternalId: String? = null 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.fireJwtInvalidated(externalId) + Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, will fire invalidated event after queue wake") + jwtInvalidatedExternalId = externalId } } _operationRepo.forceExecuteOperations() + + jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) } } override fun onModelUpdated( 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 2f8a8ac8fe..84b047e0cf 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 @@ -54,6 +54,10 @@ interface IOperationRepo { * 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]. + * + * The handler is invoked synchronously on the operation repo thread immediately + * after JWT invalidation and re-queue. It must return quickly; defer heavy work + * to another thread. The SDK default handler only schedules listener delivery. */ fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?) } 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 baadb8acbc..da708c6531 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 @@ -247,6 +247,13 @@ internal class OperationRepo( } } + private fun dispatchJwtInvalidatedToApp(externalId: String) { + _jwtInvalidatedHandler?.let { handler -> + runCatching { handler(externalId) } + .onFailure { Logging.warn("Failed to run JWT invalidated handler for externalId=$externalId", it) } + } + } + internal suspend fun executeOperations(ops: List) { try { val startingOp = ops.first() @@ -280,11 +287,11 @@ 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) } } + dispatchJwtInvalidatedToApp(externalId) } else { Logging.warn("Operation execution failed with 401 Unauthorized for anonymous user. Operations dropped.") ops.forEach { _operationModelStore.remove(it.operation.id) } 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 5a1ad799e2..e55e03ff63 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 @@ -24,6 +24,10 @@ import com.onesignal.user.state.IUserStateObserver import com.onesignal.user.state.UserChangedState import com.onesignal.user.state.UserState import com.onesignal.user.subscriptions.IPushSubscription +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch internal open class UserManager( private val _subscriptionManager: ISubscriptionManager, @@ -47,6 +51,10 @@ internal open class UserManager( val changeHandlersNotifier = EventProducer() private val jwtInvalidatedNotifier = EventProducer() + // Coroutine scope for async JWT invalidated listener delivery (non-blocking) + private val jwtInvalidatedAppCallbackScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) + fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { jwtInvalidatedNotifier.subscribe(listener) } @@ -55,9 +63,19 @@ internal open class UserManager( jwtInvalidatedNotifier.unsubscribe(listener) } + /** + * Schedules [IUserJwtInvalidatedListener] delivery on a background dispatcher so HYDRATE and + * operation-repo paths can finish internal work before app code runs. + */ fun fireJwtInvalidated(externalId: String) { - jwtInvalidatedNotifier.fire { - it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + jwtInvalidatedAppCallbackScope.launch { + runCatching { + jwtInvalidatedNotifier.fire { listener -> + listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) + } + }.onFailure { + Logging.warn("Failed to deliver JWT invalidated event for externalId=$externalId", it) + } } } 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 6858b8db21..0f43679200 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,62 @@ class OperationRepoTests : FunSpec({ handlerCalledWith shouldBe "test-user" } + test("FAIL_UNAUTHORIZED still re-queues when JWT invalidated handler throws") { + 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 = + OperationRepo( + listOf(executor), + operationModelStore, + configModelStore, + Time(), + getNewRecordState(configModelStore), + jwtTokenStore, + identityModelStore, + ) + + operationRepo.setJwtInvalidatedHandler { throw IllegalStateException("app callback failed") } + + val operation = mockOperation() + every { operation.externalId } returns "test-user" + + operationRepo.start() + val response = operationRepo.enqueueAndWait(operation) + + response shouldBe true + verify { jwtTokenStore.invalidateJwt("test-user") } + coVerify(exactly = 2) { executor.execute(any()) } + } + test("FAIL_UNAUTHORIZED drops operations for anonymous user") { // Given val mocks = Mocks() From d19815338861ba99cfa8dfd46f16e2d7df1396a1 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Thu, 9 Apr 2026 09:09:51 -0400 Subject: [PATCH 38/45] fix: login race condition and JWT FAIL_UNAUTHORIZED blocking callers (#2600) Co-authored-by: AR Abdul Azeez --- .../internal/operations/impl/OperationRepo.kt | 19 ++- .../com/onesignal/internal/OneSignalImp.kt | 13 +- .../onesignal/user/internal/LoginHelper.kt | 59 ++++--- .../internal/operations/OperationRepoTests.kt | 10 +- .../user/internal/LoginHelperTests.kt | 147 ++++++++++-------- 5 files changed, 154 insertions(+), 94 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 da708c6531..f7d1b943cd 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 @@ -288,8 +288,15 @@ internal class OperationRepo( if (externalId != null) { _jwtTokenStore.invalidateJwt(externalId) Logging.warn("Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. Operations re-queued.") + // Unblock any enqueueAndWait callers so loginSuspend doesn't hang. + ops.forEach { it.waiter?.wake(false) } + // Re-queue with waiter = null: the operation is preserved for retry + // (once a new JWT is provided via updateUserJwt), but the original + // waiter is detached since it was already woken above. synchronized(queue) { - ops.reversed().forEach { queue.add(0, it) } + ops.reversed().forEach { + queue.add(0, OperationQueueItem(it.operation, waiter = null, bucket = it.bucket, retries = it.retries)) + } } dispatchJwtInvalidatedToApp(externalId) } else { @@ -331,9 +338,15 @@ internal class OperationRepo( Logging.error("Operation execution failed with eventual retry, pausing the operation repo: $operations") // keep the failed operation and pause the operation repo from executing paused = true - // add back all operations to the front of the queue to be re-executed. + // Unblock any enqueueAndWait callers so loginSuspend doesn't hang. + ops.forEach { it.waiter?.wake(false) } + // Re-queue with waiter = null: the operation is preserved for retry + // on next cold start, but the original waiter is detached since it + // was already woken above. synchronized(queue) { - ops.reversed().forEach { queue.add(0, it) } + ops.reversed().forEach { + queue.add(0, OperationQueueItem(it.operation, waiter = null, bucket = it.bucket, retries = it.retries)) + } } } } 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 e4341abbfc..e68535dd16 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 @@ -384,14 +384,20 @@ internal class OneSignalImp( if (isBackgroundThreadingEnabled) { waitForInit(operationName = "login") - suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } } else { if (!isInitialized) { throw IllegalStateException("Must call 'initWithContext' before 'login'") } + } + + val context = loginHelper.switchUser(externalId, jwtBearerToken) ?: return + + if (isBackgroundThreadingEnabled) { + suspendifyOnIO { loginHelper.enqueueLogin(context) } + } else { Thread { runBlocking(runtimeIoDispatcher) { - loginHelper.login(externalId, jwtBearerToken) + loginHelper.enqueueLogin(context) } }.start() } @@ -695,7 +701,8 @@ internal class OneSignalImp( throw IllegalStateException("'initWithContext failed' before 'login'") } - loginHelper.login(externalId, jwtBearerToken) + val context = loginHelper.switchUser(externalId, jwtBearerToken) ?: return@withContext + loginHelper.enqueueLogin(context) } override suspend fun logoutSuspend() = 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 cc9bb14daf..ef516d06a7 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 @@ -15,22 +15,30 @@ class LoginHelper( private val jwtTokenStore: JwtTokenStore, private val lock: Any, ) { - suspend fun login( + internal data class LoginEnqueueContext( + val appId: String, + val newIdentityOneSignalId: String, + val externalId: String, + val existingOneSignalId: String?, + ) + + /** + * Synchronously switches local user models under the login/logout lock. + * Returns context needed for [enqueueLogin], or null if the user was + * already logged in with [externalId] (no switch needed). + */ + internal fun switchUser( externalId: String, jwtBearerToken: String? = null, - ) { - var currentIdentityExternalId: String? = null - var currentIdentityOneSignalId: String? = null - var newIdentityOneSignalId: String = "" - + ): LoginEnqueueContext? { synchronized(lock) { - currentIdentityExternalId = identityModelStore.model.externalId - currentIdentityOneSignalId = identityModelStore.model.onesignalId + val currentExternalId = identityModelStore.model.externalId + val currentOneSignalId = identityModelStore.model.onesignalId - if (currentIdentityExternalId == externalId) { + if (currentExternalId == externalId) { jwtTokenStore.putJwt(externalId, jwtBearerToken) operationRepo.forceExecuteOperations() - return + return null } jwtTokenStore.putJwt(externalId, jwtBearerToken) @@ -39,23 +47,30 @@ class LoginHelper( identityModel.externalId = externalId } - newIdentityOneSignalId = identityModelStore.model.onesignalId - } + val newOneSignalId = identityModelStore.model.onesignalId - val existingOneSignalId = - if (configModel.useIdentityVerification == true) { - null - } else { - if (currentIdentityExternalId == null) currentIdentityOneSignalId else null - } + val existingOneSignalId = + if (configModel.useIdentityVerification == true) { + null + } else { + if (currentExternalId == null) currentOneSignalId else null + } + + return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, existingOneSignalId) + } + } + /** + * Enqueues the [LoginUserOperation] and suspends until it completes. + */ + internal suspend fun enqueueLogin(context: LoginEnqueueContext) { val result = operationRepo.enqueueAndWait( LoginUserOperation( - configModel.appId, - newIdentityOneSignalId, - externalId, - existingOneSignalId, + context.appId, + context.newIdentityOneSignalId, + context.externalId, + context.existingOneSignalId, ), ) 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 0f43679200..d1f129bc2c 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 @@ -954,8 +954,9 @@ class OperationRepoTests : FunSpec({ operationRepo.start() val response = operationRepo.enqueueAndWait(operation) - // Then - response shouldBe true + // Then – waiter is woken with false immediately on FAIL_UNAUTHORIZED + // (operation is re-queued with waiter=null for retry when a new JWT is provided) + response shouldBe false verify { jwtTokenStore.invalidateJwt("test-user") } handlerCalledWith shouldBe "test-user" } @@ -1011,8 +1012,11 @@ class OperationRepoTests : FunSpec({ operationRepo.start() val response = operationRepo.enqueueAndWait(operation) - response shouldBe true + // Waiter is woken with false immediately; operation re-queued with waiter=null + response shouldBe false verify { jwtTokenStore.invalidateJwt("test-user") } + // The re-queued op (waiter=null) retries asynchronously; wait for it to complete + delay(3000) coVerify(exactly = 2) { executor.execute(any()) } } 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 ec64157170..70d9cbd3b7 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 @@ -11,6 +11,7 @@ import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.properties.PropertiesModel import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -19,15 +20,7 @@ import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.runBlocking -/** - * Unit tests for the LoginHelper class - * - * These tests focus on the pure business logic of user login operations, - * complementing the integration tests in SDKInitTests.kt which test - * end-to-end SDK initialization and login behavior. - */ class LoginHelperTests : FunSpec({ - // Test constants - using consistent naming with SDKInitTests val appId = "appId" val currentExternalId = "current-user" val newExternalId = "new-user" @@ -38,8 +31,26 @@ class LoginHelperTests : FunSpec({ Logging.logLevel = LogLevel.NONE } - test("login with same external id returns early without creating user") { - // Given + fun createLoginHelper( + identityModelStore: com.onesignal.user.internal.identity.IdentityModelStore, + userSwitcher: UserSwitcher = mockk(relaxed = true), + operationRepo: IOperationRepo = mockk(relaxed = true), + configModel: ConfigModel = mockk().also { + every { it.appId } returns appId + every { it.useIdentityVerification } returns null + }, + jwtTokenStore: JwtTokenStore = mockk(relaxed = true), + lock: Any = Any(), + ) = LoginHelper( + identityModelStore = identityModelStore, + userSwitcher = userSwitcher, + operationRepo = operationRepo, + configModel = configModel, + jwtTokenStore = jwtTokenStore, + lock = lock, + ) + + test("switchUser with same external id returns null without creating user") { val mockIdentityModelStore = MockHelper.identityModelStore { model -> model.externalId = currentExternalId @@ -47,33 +58,22 @@ class LoginHelperTests : FunSpec({ } val mockUserSwitcher = mockk(relaxed = true) val mockOperationRepo = mockk(relaxed = true) - val mockConfigModel = mockk() - every { mockConfigModel.appId } returns appId - every { mockConfigModel.useIdentityVerification } returns false - val loginLock = Any() val loginHelper = - LoginHelper( + createLoginHelper( identityModelStore = mockIdentityModelStore, userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, - configModel = mockConfigModel, - jwtTokenStore = mockk(relaxed = true), - lock = loginLock, ) - // When - runBlocking { - loginHelper.login(currentExternalId) - } + val context = loginHelper.switchUser(currentExternalId) - // Then - should return early without any operations + context shouldBe null verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } coVerify(exactly = 0) { mockOperationRepo.enqueueAndWait(any()) } } - test("login with different external id creates and switches to new user") { - // Given + test("switchUser with different external id creates and switches to new user") { val mockIdentityModelStore = MockHelper.identityModelStore { model -> model.externalId = currentExternalId @@ -88,10 +88,6 @@ class LoginHelperTests : FunSpec({ val mockUserSwitcher = mockk() 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>() every { @@ -107,25 +103,65 @@ class LoginHelperTests : FunSpec({ coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true val loginHelper = - LoginHelper( + createLoginHelper( identityModelStore = mockIdentityModelStore, userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, - configModel = mockConfigModel, - jwtTokenStore = mockk(relaxed = true), - lock = loginLock, ) - // When - runBlocking { - loginHelper.login(newExternalId) - } + val context = loginHelper.switchUser(newExternalId) + + context shouldNotBe null + context!!.appId shouldBe appId + context.newIdentityOneSignalId shouldBe newOneSignalId + context.externalId shouldBe newExternalId - // Then - should switch users and enqueue login operation verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } userSwitcherSlot.captured(newIdentityModel, PropertiesModel()) newIdentityModel.externalId shouldBe newExternalId + } + + test("enqueueLogin enqueues login operation and returns") { + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = currentExternalId + model.onesignalId = currentOneSignalId + } + + val newIdentityModel = + IdentityModel().apply { + externalId = newExternalId + onesignalId = newOneSignalId + } + + val mockUserSwitcher = mockk() + val mockOperationRepo = mockk() + + val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() + every { + mockUserSwitcher.createAndSwitchToNewUser( + suppressBackendOperation = any(), + modify = capture(userSwitcherSlot), + ) + } answers { + userSwitcherSlot.captured(newIdentityModel, PropertiesModel()) + every { mockIdentityModelStore.model } returns newIdentityModel + } + + coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true + + val loginHelper = + createLoginHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + ) + + val context = loginHelper.switchUser(newExternalId)!! + runBlocking { + loginHelper.enqueueLogin(context) + } coVerify(exactly = 1) { mockOperationRepo.enqueueAndWait( @@ -133,14 +169,13 @@ class LoginHelperTests : FunSpec({ operation.appId shouldBe appId operation.onesignalId shouldBe newOneSignalId operation.externalId shouldBe newExternalId - operation.existingOnesignalId shouldBe null // Current user already has external ID, so no existing OneSignal ID + operation.existingOnesignalId shouldBe null }, ) } } - test("login with null current external id provides existing onesignal id for conversion") { - // Given - anonymous user (no external ID) + test("switchUser with null current external id provides existing onesignal id for conversion") { val mockIdentityModelStore = MockHelper.identityModelStore { model -> model.externalId = null @@ -158,7 +193,6 @@ class LoginHelperTests : FunSpec({ val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId every { mockConfigModel.useIdentityVerification } returns false - val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() every { @@ -174,35 +208,31 @@ class LoginHelperTests : FunSpec({ coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true val loginHelper = - LoginHelper( + createLoginHelper( identityModelStore = mockIdentityModelStore, userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, - jwtTokenStore = mockk(relaxed = true), - lock = loginLock, ) - // When + val context = loginHelper.switchUser(newExternalId)!! runBlocking { - loginHelper.login(newExternalId) + loginHelper.enqueueLogin(context) } - // Then - should provide existing OneSignal ID for anonymous user conversion coVerify(exactly = 1) { mockOperationRepo.enqueueAndWait( withArg { operation -> operation.appId shouldBe appId operation.onesignalId shouldBe newOneSignalId operation.externalId shouldBe newExternalId - operation.existingOnesignalId shouldBe currentOneSignalId // For conversion + operation.existingOnesignalId shouldBe currentOneSignalId }, ) } } - test("login logs error when operation fails") { - // Given + test("enqueueLogin logs warning when operation fails") { val mockIdentityModelStore = MockHelper.identityModelStore { model -> model.externalId = currentExternalId @@ -217,10 +247,6 @@ class LoginHelperTests : FunSpec({ val mockUserSwitcher = mockk() 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>() every { @@ -233,25 +259,20 @@ class LoginHelperTests : FunSpec({ every { mockIdentityModelStore.model } returns newIdentityModel } - // Mock operation failure coEvery { mockOperationRepo.enqueueAndWait(any()) } returns false val loginHelper = - LoginHelper( + createLoginHelper( identityModelStore = mockIdentityModelStore, userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, - configModel = mockConfigModel, - jwtTokenStore = mockk(relaxed = true), - lock = loginLock, ) - // When + val context = loginHelper.switchUser(newExternalId)!! runBlocking { - loginHelper.login(newExternalId) + loginHelper.enqueueLogin(context) } - // Then - should still switch users but operation fails verify(exactly = 1) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any(), modify = any()) } coVerify(exactly = 1) { mockOperationRepo.enqueueAndWait(any()) } } From ff1a6b285e40c0368115f51c633bdc9f1a14d4f6 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 13 Apr 2026 17:54:33 -0700 Subject: [PATCH 39/45] Fix FAIL_UNAUTHORIZED loop when IV off and stuck login when IV arrives post-enqueue OperationRepo: gate FAIL_UNAUTHORIZED re-queue on useIdentityVerification == true. When IV is OFF, hasValidJwtIfRequired() always returns true so re-queued ops were immediately eligible, creating a ~200ms infinite retry loop. Now IV-OFF treats FAIL_UNAUTHORIZED as FAIL_NORETRY (drop + wake waiters). OperationRepo: in removeOperationsWithoutExternalId(), clear local existingOnesignalId on queued LoginUserOperations. When IV=ON arrives via HYDRATE, anonymous CreateUserOperations are purged, orphaning the local ID that LoginUserOperation.canStartExecute was waiting on translateIds to resolve. Clearing it unblocks the operation and routes the executor through createUser(). LoginUserOperation: widen existingOnesignalId setter to internal. Fix Operation.externalId KDoc to reflect that subclass constructors set this field, not IOperationRepo at enqueue time. Made-with: Cursor --- .../core/internal/operations/Operation.kt | 4 ++-- .../internal/operations/impl/OperationRepo.kt | 20 ++++++++++++++++++- .../internal/operations/LoginUserOperation.kt | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) 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 8227ebb877..76dc5ab837 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 @@ -19,8 +19,8 @@ abstract class Operation(name: String) : Model() { /** * 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. + * Must be set by each concrete [Operation] subclass constructor — typically from the current + * identity model's externalId at the time the operation is created. */ var externalId: String? get() = getOptStringProperty(::externalId.name) 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 f7d1b943cd..7f9cd56330 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 @@ -1,5 +1,6 @@ package com.onesignal.core.internal.operations.impl +import com.onesignal.common.IDManager import com.onesignal.common.threading.WaiterWithValue import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResult @@ -13,6 +14,7 @@ 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.LoginUserOperation import com.onesignal.user.internal.operations.impl.states.NewRecordsState import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -284,8 +286,9 @@ internal class OperationRepo( ops.forEach { it.waiter?.wake(true) } } ExecutionResult.FAIL_UNAUTHORIZED -> { + val identityVerificationEnabled = _configModelStore.model.useIdentityVerification == true val externalId = startingOp.operation.externalId - if (externalId != null) { + if (identityVerificationEnabled && externalId != null) { _jwtTokenStore.invalidateJwt(externalId) Logging.warn("Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. Operations re-queued.") // Unblock any enqueueAndWait callers so loginSuspend doesn't hang. @@ -542,6 +545,21 @@ internal class OperationRepo( if (toRemove.isNotEmpty()) { Logging.debug("OperationRepo: removed ${toRemove.size} anonymous operations (no externalId)") } + + // Any LoginUserOperation whose existingOnesignalId is a local ID was + // waiting on a now-purged anonymous CreateUserOperation to translate it. + // Clear it so canStartExecute unblocks and the executor takes the + // createUser() path instead. + queue.forEach { + val op = it.operation + if (op is LoginUserOperation) { + val existing = op.existingOnesignalId + if (existing != null && IDManager.isLocalId(existing)) { + op.existingOnesignalId = null + Logging.debug("OperationRepo: cleared local existingOnesignalId on LoginUserOperation (was $existing)") + } + } + } } } 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 9164ab39ca..521be948fe 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 @@ -39,7 +39,7 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { */ var existingOnesignalId: String? get() = getOptStringProperty(::existingOnesignalId.name) - private set(value) { + internal set(value) { setOptStringProperty(::existingOnesignalId.name, value) } From 6050a4bf259c18a60be693e69868db2e63bb97d2 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 13 Apr 2026 18:24:56 -0700 Subject: [PATCH 40/45] Handle pre-HYDRATE logout edge case and redact JWT from logs LogoutHelper: add explicit null branch for unknown IV state (pre-HYDRATE). Disables push subscription and suppresses backend op (like IV=ON) while also enqueuing a LoginUserOperation so the anonymous user is created on the backend if IV turns out to be OFF. If IV=ON, removeOperationsWithoutExternalId() purges the anonymous op at HYDRATE. Known IV=ON and IV=OFF paths are unchanged. HttpClient: filter Authorization header from requestProperties before passing to logHTTPSent() so the full Bearer token is not written to debug logs. OneSignalImp: redact token in updateUserJwt/updateUserJwtSuspend debug logs. Made-with: Cursor --- .../core/internal/http/impl/HttpClient.kt | 2 +- .../com/onesignal/internal/OneSignalImp.kt | 4 ++-- .../onesignal/user/internal/LogoutHelper.kt | 21 ++++++++++++++++++- 3 files changed, 23 insertions(+), 4 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 f7c01b843e..22a2d4c517 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 @@ -187,7 +187,7 @@ internal class HttpClient( con.setRequestProperty("Authorization", "Bearer ${headers.jwt}") } - logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties) + logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties.filterKeys { it != "Authorization" }) if (jsonBody != null) { val strJsonBody = JSONUtils.toUnescapedEUIDString(jsonBody) 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 e68535dd16..0108f74f1f 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,7 +425,7 @@ internal class OneSignalImp( externalId: String, token: String, ) { - Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: $token)") + Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: )") if (isBackgroundThreadingEnabled) { waitForInit(operationName = "updateUserJwt") @@ -444,7 +444,7 @@ internal class OneSignalImp( externalId: String, token: String, ) = withContext(runtimeIoDispatcher) { - Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: $token)") + Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: )") suspendUntilInit(operationName = "updateUserJwt") 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 ebe105c585..6f122c7de1 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 @@ -27,9 +27,28 @@ class LogoutHelper( } userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) - } else { + } else if (configModel.useIdentityVerification == false) { userSwitcher.createAndSwitchToNewUser() + operationRepo.enqueue( + LoginUserOperation( + configModel.appId, + identityModelStore.model.onesignalId, + null, + ), + ) + } else { + // IV state unknown (pre-HYDRATE). Take the safe path: disable push + // and suppress backend op (like IV=ON), but also enqueue a LoginUserOperation + // so the anonymous user is created on the backend if IV turns out to be OFF. + // If IV=ON, removeOperationsWithoutExternalId() will purge the anonymous op. + configModel.pushSubscriptionId?.let { pushSubId -> + subscriptionModelStore.get(pushSubId) + ?.let { it.isDisabledInternally = true } + } + + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + operationRepo.enqueue( LoginUserOperation( configModel.appId, From 6ad204e51a4f9d80b4f9d59ff44fa504224d1b01 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 18:30:20 -0700 Subject: [PATCH 41/45] Address Claude review bot feedback on IV PR - Redact JWT from login/loginSuspend logs (show last 8 chars) - Fix error message in updateUserJwtSuspend - Add FAIL_UNAUTHORIZED to deleteSubscription and CustomEvent executors - Drop TransferSubscriptionOperation when IV=ON - Skip IAM fetch when JWT is invalidated (prevent rate-limit poisoning) - Clear existingOnesignalId unconditionally in removeOperationsWithoutExternalId --- .../internal/operations/impl/OperationRepo.kt | 16 +++++----------- .../java/com/onesignal/internal/OneSignalImp.kt | 10 +++++----- .../executors/CustomEventOperationExecutor.kt | 2 ++ .../executors/SubscriptionOperationExecutor.kt | 7 +++++++ .../internal/InAppMessagesManager.kt | 12 +++++++++--- 5 files changed, 28 insertions(+), 19 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 7f9cd56330..4991f7a4a4 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 @@ -1,6 +1,5 @@ package com.onesignal.core.internal.operations.impl -import com.onesignal.common.IDManager import com.onesignal.common.threading.WaiterWithValue import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResult @@ -546,18 +545,13 @@ internal class OperationRepo( Logging.debug("OperationRepo: removed ${toRemove.size} anonymous operations (no externalId)") } - // Any LoginUserOperation whose existingOnesignalId is a local ID was - // waiting on a now-purged anonymous CreateUserOperation to translate it. - // Clear it so canStartExecute unblocks and the executor takes the - // createUser() path instead. + // IV=ON never transfers anonymous state; clear existingOnesignalId so + // the executor takes the createUser (upsert) path. queue.forEach { val op = it.operation - if (op is LoginUserOperation) { - val existing = op.existingOnesignalId - if (existing != null && IDManager.isLocalId(existing)) { - op.existingOnesignalId = null - Logging.debug("OperationRepo: cleared local existingOnesignalId on LoginUserOperation (was $existing)") - } + if (op is LoginUserOperation && op.existingOnesignalId != null) { + Logging.debug("OperationRepo: cleared existingOnesignalId on LoginUserOperation (was ${op.existingOnesignalId})") + op.existingOnesignalId = null } } } 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 0108f74f1f..e4eed6488e 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 @@ -380,7 +380,7 @@ internal class OneSignalImp( externalId: String, jwtBearerToken: String?, ) { - Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") + Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") if (isBackgroundThreadingEnabled) { waitForInit(operationName = "login") @@ -425,7 +425,7 @@ internal class OneSignalImp( externalId: String, token: String, ) { - Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: )") + Logging.log(LogLevel.DEBUG, "updateUserJwt(externalId: $externalId, token: ...${token.takeLast(8)})") if (isBackgroundThreadingEnabled) { waitForInit(operationName = "updateUserJwt") @@ -444,12 +444,12 @@ internal class OneSignalImp( externalId: String, token: String, ) = withContext(runtimeIoDispatcher) { - Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: )") + Logging.log(LogLevel.DEBUG, "updateUserJwtSuspend(externalId: $externalId, token: ...${token.takeLast(8)})") suspendUntilInit(operationName = "updateUserJwt") if (!isInitialized) { - throw IllegalStateException("'initWithContext failed' before 'updateUserJwt'") + throw IllegalStateException("Must call 'initWithContext' before 'updateUserJwt'") } jwtTokenStore.putJwt(externalId, token) @@ -693,7 +693,7 @@ internal class OneSignalImp( externalId: String, jwtBearerToken: String?, ) = withContext(runtimeIoDispatcher) { - Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") + Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: ...${jwtBearerToken?.takeLast(8)})") suspendUntilInit(operationName = "login") 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 14166713df..18e97a1178 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 @@ -61,6 +61,8 @@ internal class CustomEventOperationExecutor( return when (responseType) { NetworkUtils.ResponseStatusType.RETRYABLE -> ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds) + NetworkUtils.ResponseStatusType.UNAUTHORIZED -> + ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds) else -> ExecutionResponse(ExecutionResult.FAIL_NORETRY) } 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 78c1e21ee9..bea804dd44 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 @@ -252,6 +252,11 @@ 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 { + if (_configModelStore.model.useIdentityVerification == true) { + Logging.warn("TransferSubscriptionOperation is not supported when identity verification is enabled. Dropping.") + return ExecutionResponse(ExecutionResult.FAIL_NORETRY) + } + val (aliasLabel, aliasValue) = IdentityConstants.resolveAlias( _configModelStore.model.useIdentityVerification, @@ -323,6 +328,8 @@ internal class SubscriptionOperationExecutor( } NetworkUtils.ResponseStatusType.RETRYABLE -> ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds) + NetworkUtils.ResponseStatusType.UNAUTHORIZED -> + ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED, retryAfterSeconds = ex.retryAfterSeconds) else -> ExecutionResponse(ExecutionResult.FAIL_NORETRY) } 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 80e4ba4f93..a61bc6af71 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 @@ -302,9 +302,15 @@ internal class InAppMessagesManager( } 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 + if (_configModelStore.model.useIdentityVerification == true) { + if (externalId == null) { + Logging.debug("InAppMessagesManager.fetchMessages: Skipping IAM fetch for anonymous user while identity verification is enabled.") + return + } + if (_jwtTokenStore.getJwt(externalId) == null) { + Logging.debug("InAppMessagesManager.fetchMessages: Skipping IAM fetch while JWT is invalidated for user: $externalId") + return + } } fetchIAMMutex.withLock { From fb2353ff0017c30f7bb9b6ff72604491758a2af4 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 19:56:05 -0700 Subject: [PATCH 42/45] detekt: update baseline instead of addressing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MagicNumber(8): log suffix length in takeLast(8) — extracting a named constant for a single-use log format arg adds noise without improving readability - ReturnCount(transferSubscription): early-exit guard follows the same pattern as LoginUserFromSubscriptionOperationExecutor; restructuring to reduce returns would obscure the guard-clause intent - NestedBlockDepth(executeOperations): pre-existing from IV branch, the when-block nesting is inherent to the dispatch logic - UseCheckOrError: pre-existing non-suspend login/logout/accessor throws — consistent with existing codebase pattern - Removed stale entry for renamed error message --- OneSignalSDK/detekt/detekt-baseline-core.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 80fc3cdec0..ed239183e7 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -252,6 +252,7 @@ MagicNumber:OSDatabase.kt$OSDatabase$8 MagicNumber:OSDatabase.kt$OSDatabase$9 MagicNumber:OneSignalDispatchers.kt$OneSignalDispatchers$1024 + MagicNumber:OneSignalImp.kt$OneSignalImp$8 MagicNumber:OperationRepo.kt$OperationRepo$1_000 MagicNumber:OutcomeEventsController.kt$OutcomeEventsController$1000 MagicNumber:PermissionsActivity.kt$PermissionsActivity$23 @@ -288,6 +289,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 @@ -330,6 +332,7 @@ ReturnCount:RefreshUserOperationExecutor.kt$RefreshUserOperationExecutor$private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse ReturnCount:ServiceRegistration.kt$ServiceRegistrationReflection$private fun doesHaveAllParameters( constructor: Constructor<*>, provider: IServiceProvider, ): Boolean ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun createSubscription( createOperation: CreateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse + ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun transferSubscription(startingOperation: TransferSubscriptionOperation): ExecutionResponse ReturnCount:SubscriptionOperationExecutor.kt$SubscriptionOperationExecutor$private suspend fun updateSubscription( startingOperation: UpdateSubscriptionOperation, operations: List<Operation>, ): ExecutionResponse ReturnCount:UpdateUserOperationExecutor.kt$UpdateUserOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse SpreadOperator:AndroidUtils.kt$AndroidUtils$(*packageInfo.requestedPermissions) @@ -633,8 +636,10 @@ 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("'initWithContext failed' before 'updateUserJwt'") + 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 'updateUserJwt'") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") From 13c11ed9cb29dccfce4a96976bb408af6cfc3bae Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 14 Apr 2026 21:29:44 -0700 Subject: [PATCH 43/45] Fix pre-HYDRATE logout and IAM fetch TOCTOU race - LogoutHelper: remove suppressBackendOperation=true in pre-HYDRATE branch so CreateSubscriptionOperation is enqueued alongside the anonymous LoginUserOperation (fixes IV=OFF path) - InAppMessagesManager: capture JWT once before guard check to eliminate TOCTOU between guard and backend call - nit: LoginUserOperationExecutor: add error log when anonymous LoginUserOperation is dropped with no subscription op --- .../main/java/com/onesignal/user/internal/LogoutHelper.kt | 8 +++----- .../impl/executors/LoginUserOperationExecutor.kt | 1 + .../inAppMessages/internal/InAppMessagesManager.kt | 7 ++++--- 3 files changed, 8 insertions(+), 8 deletions(-) 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 6f122c7de1..6290945074 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 @@ -38,16 +38,14 @@ class LogoutHelper( ), ) } else { - // IV state unknown (pre-HYDRATE). Take the safe path: disable push - // and suppress backend op (like IV=ON), but also enqueue a LoginUserOperation - // so the anonymous user is created on the backend if IV turns out to be OFF. - // If IV=ON, removeOperationsWithoutExternalId() will purge the anonymous op. + // IV unknown (pre-HYDRATE): disable push, enqueue anonymous user. + // If IV=ON at HYDRATE, removeOperationsWithoutExternalId() purges these. configModel.pushSubscriptionId?.let { pushSubId -> subscriptionModelStore.get(pushSubId) ?.let { it.isDisabledInternally = true } } - userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + userSwitcher.createAndSwitchToNewUser() operationRepo.enqueue( LoginUserOperation( 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 a2b5365eb8..013c343340 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 @@ -74,6 +74,7 @@ internal class LoginUserOperationExecutor( // Anonymous Login being processed alone will surely be rejected, so we need to drop the request val containsSubscriptionOperation = operations.any { it is CreateSubscriptionOperation || it is TransferSubscriptionOperation } if (!containsSubscriptionOperation && loginUserOp.externalId == null) { + Logging.error("LoginUserOperationExecutor: dropping anonymous LoginUserOperation with no subscription op: $loginUserOp") return ExecutionResponse(ExecutionResult.FAIL_NORETRY) } if (loginUserOp.existingOnesignalId == null || loginUserOp.externalId == null) { 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 a61bc6af71..a5f4c60455 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 @@ -302,12 +302,15 @@ internal class InAppMessagesManager( } val externalId = _identityModelStore.model.externalId + // Capture JWT once to avoid TOCTOU: the same snapshot is used for the guard + // check and the backend call, so a concurrent invalidation can't slip between them. + val jwt = externalId?.let { _jwtTokenStore.getJwt(it) } if (_configModelStore.model.useIdentityVerification == true) { if (externalId == null) { Logging.debug("InAppMessagesManager.fetchMessages: Skipping IAM fetch for anonymous user while identity verification is enabled.") return } - if (_jwtTokenStore.getJwt(externalId) == null) { + if (jwt == null) { Logging.debug("InAppMessagesManager.fetchMessages: Skipping IAM fetch while JWT is invalidated for user: $externalId") return } @@ -328,8 +331,6 @@ internal class InAppMessagesManager( 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, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) From 3f3dd09be645960593e4c3afc2ecbdafc0b7af59 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 15 Apr 2026 11:06:17 -0700 Subject: [PATCH 44/45] Suppress anonymous ops at enqueue and add LogoutHelper IV test coverage - Move anonymous operation suppression from individual model store listeners into OperationRepo.enqueue/enqueueAndWait so all enqueue sites (SessionListener, TrackGooglePurchase, CustomEventController, etc.) are covered. LoginUserOperation is exempt. - Remove redundant shouldSuppressForAnonymousUser() from IdentityModelStoreListener, PropertiesModelStoreListener, and SubscriptionModelStoreListener. - Add LogoutHelper tests for IV=true and IV=null (pre-HYDRATE) branches. --- .../internal/operations/impl/OperationRepo.kt | 14 ++++ .../listeners/IdentityModelStoreListener.kt | 6 -- .../listeners/PropertiesModelStoreListener.kt | 6 -- .../SubscriptionModelStoreListener.kt | 10 --- .../user/internal/LogoutHelperTests.kt | 82 +++++++++++++++++++ 5 files changed, 96 insertions(+), 22 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 4991f7a4a4..4f37c846d6 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 @@ -128,6 +128,8 @@ internal class OperationRepo( operation: Operation, flush: Boolean, ) { + if (shouldSuppressAnonymousOp(operation)) return + Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)") operation.id = UUID.randomUUID().toString() @@ -140,6 +142,8 @@ internal class OperationRepo( operation: Operation, flush: Boolean, ): Boolean { + if (shouldSuppressAnonymousOp(operation)) return false + Logging.log(LogLevel.DEBUG, "OperationRepo.enqueueAndWait(operation: $operation, force: $flush)") operation.id = UUID.randomUUID().toString() @@ -437,6 +441,16 @@ internal class OperationRepo( } } + /** + * Drop anonymous operations at enqueue time when IV is enabled. + * LoginUserOperation is exempt — it's enqueued intentionally during logout + * and purged later by [removeOperationsWithoutExternalId] if needed. + */ + private fun shouldSuppressAnonymousOp(op: Operation): Boolean { + if (op is LoginUserOperation) return false + return _configModelStore.model.useIdentityVerification == true && op.externalId == null + } + /** * Determines whether [op] is allowed to execute given the current identity * verification (IV) state. Used by [getNextOps] to skip operations that 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 bab60f0d55..acd1141bb3 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 @@ -14,10 +14,6 @@ internal class IdentityModelStoreListener( opRepo: IOperationRepo, private val _configModelStore: ConfigModelStore, ) : 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 @@ -30,8 +26,6 @@ internal class IdentityModelStoreListener( oldValue: Any?, newValue: Any?, ): Operation? { - if (shouldSuppressForAnonymousUser()) return null - return if (newValue != null && newValue is String) { SetAliasOperation(_configModelStore.model.appId, model.onesignalId, model.externalId, 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 79cabb8f8c..198326b473 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 @@ -17,10 +17,6 @@ internal class PropertiesModelStoreListener( 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 @@ -33,8 +29,6 @@ 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 dd0a81eec6..cfc11987f2 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,13 +18,7 @@ internal class SubscriptionModelStoreListener( private val _identityModelStore: IdentityModelStore, private val _configModelStore: ConfigModelStore, ) : ModelStoreListener(store, opRepo) { - 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, @@ -39,8 +33,6 @@ internal class SubscriptionModelStoreListener( } override fun getRemoveOperation(model: SubscriptionModel): Operation? { - if (shouldSuppressForAnonymousUser()) return null - return DeleteSubscriptionOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId, _identityModelStore.model.externalId, model.id) } @@ -51,8 +43,6 @@ internal class SubscriptionModelStoreListener( oldValue: Any?, newValue: Any?, ): Operation? { - if (shouldSuppressForAnonymousUser()) return null - val enabledAndStatus = getSubscriptionEnabledAndStatus(model) return UpdateSubscriptionOperation( _configModelStore.model.appId, 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 7d4f485952..68f3ffe281 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.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -177,4 +178,85 @@ class LogoutHelperTests : FunSpec({ verify(atLeast = 1) { mockUserSwitcher.createAndSwitchToNewUser() } verify(atLeast = 1) { mockOperationRepo.enqueue(any()) } } + + test("logout with IV=true disables push and suppresses backend operation") { + // Given - identified user with IV enabled + val pushSubId = "push-sub-id" + val mockSubscriptionModel = mockk(relaxed = true) + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = externalId + model.onesignalId = onesignalId + } + val mockUserSwitcher = mockk(relaxed = true) + val mockOperationRepo = mockk(relaxed = true) + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns true + every { mockConfigModel.pushSubscriptionId } returns pushSubId + val mockSubscriptionModelStore = mockk(relaxed = true) + every { mockSubscriptionModelStore.get(pushSubId) } returns mockSubscriptionModel + + val logoutHelper = + LogoutHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + subscriptionModelStore = mockSubscriptionModelStore, + lock = Any(), + ) + + // When + logoutHelper.logout() + + // Then + verify { mockSubscriptionModel.isDisabledInternally = true } + verify { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) } + verify(exactly = 0) { mockOperationRepo.enqueue(any()) } + } + + test("logout with IV=null (pre-HYDRATE) disables push and enqueues anonymous user") { + // Given - identified user, IV state unknown + val pushSubId = "push-sub-id" + val mockSubscriptionModel = mockk(relaxed = true) + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = externalId + model.onesignalId = onesignalId + } + val mockUserSwitcher = mockk(relaxed = true) + val mockOperationRepo = mockk(relaxed = true) + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns null + every { mockConfigModel.pushSubscriptionId } returns pushSubId + val mockSubscriptionModelStore = mockk(relaxed = true) + every { mockSubscriptionModelStore.get(pushSubId) } returns mockSubscriptionModel + + val logoutHelper = + LogoutHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + subscriptionModelStore = mockSubscriptionModelStore, + lock = Any(), + ) + + // When + logoutHelper.logout() + + // Then - push disabled, no suppression, anonymous LoginUserOperation enqueued + verify { mockSubscriptionModel.isDisabledInternally = true } + verify { mockUserSwitcher.createAndSwitchToNewUser() } + verify { + mockOperationRepo.enqueue( + withArg { operation -> + operation.appId shouldBe appId + operation.externalId shouldBe null + }, + ) + } + } }) From 9d3d46f5411564859c44c39bffa351c8bdf6bcee Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 16 Apr 2026 11:22:03 -0700 Subject: [PATCH 45/45] Remove manual test plan doc from repo Co-Authored-By: Claude Opus 4.6 (1M context) --- .../IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md | 1268 ----------------- 1 file changed, 1268 deletions(-) delete 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 deleted file mode 100644 index 282cd89665..0000000000 --- a/temp/IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md +++ /dev/null @@ -1,1268 +0,0 @@ -# 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 - -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 (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 | 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.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 | 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.12: Multi-user login/logout sequence (IV OFF) - -**Precondition**: IV OFF. Fresh install. - -| Step | Action | Expected Result | -|------|--------|-----------------| -| 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.13: Login with JWT when IV is OFF (JWT stored but unused) - -**Precondition**: IV OFF. Fresh install. - -| Step | Action | Expected Result | -|------|--------|-----------------| -| 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.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 | 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 - -**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 | 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.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 | 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 - ---- - -## 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 | [ ] | [ ] | [ ] | [ ] | -| 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 | [ ] | [ ] |