diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 62ec28c7c..d80e7a8aa 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -52,6 +52,7 @@ ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _subscriptionBackend: ISubscriptionBackendService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _application: IApplicationService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _configModelStore: ConfigModelStore + ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _consistencyManager: IConsistencyManager ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _deviceService: IDeviceService ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityModelStore: IdentityModelStore ConstructorParameterNaming:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private val _identityOperationExecutor: IdentityOperationExecutor @@ -155,7 +156,6 @@ ConstructorParameterNaming:UserBackendService.kt$UserBackendService$private val _httpClient: IHttpClient ConstructorParameterNaming:UserManager.kt$UserManager$private val _customEventController: ICustomEventController ConstructorParameterNaming:UserManager.kt$UserManager$private val _identityModelStore: IdentityModelStore - ConstructorParameterNaming:UserManager.kt$UserManager$private val _jwtTokenStore: JwtTokenStore ConstructorParameterNaming:UserManager.kt$UserManager$private val _languageContext: ILanguageContext ConstructorParameterNaming:UserManager.kt$UserManager$private val _propertiesModelStore: PropertiesModelStore ConstructorParameterNaming:UserManager.kt$UserManager$private val _subscriptionManager: ISubscriptionManager @@ -174,7 +174,6 @@ ForbiddenComment:HttpClient.kt$HttpClient$// TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT? ForbiddenComment:IPreferencesService.kt$PreferenceOneSignalKeys$* (String) The serialized IAMs TODO: This isn't currently used, determine if actually needed for cold start IAM fetch delay ForbiddenComment:IUserBackendService.kt$IUserBackendService$// TODO: Change to send only the push subscription, optimally - ForbiddenComment:LogoutHelper.kt$LogoutHelper$// TODO: remove JWT Token for all future requests. ForbiddenComment:ParamsBackendService.kt$ParamsBackendService$// TODO: New ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO after we remove IAM from being an activity window we may be able to remove this handler ForbiddenComment:PermissionsActivity.kt$PermissionsActivity$// TODO improve this method @@ -213,7 +212,7 @@ 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: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, private val _identityVerificationService: IdentityVerificationService, ) + 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, private val _identityVerificationService: IdentityVerificationService, private val _consistencyManager: IConsistencyManager, ) 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, private val _identityVerificationService: IdentityVerificationService, ) 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, ) @@ -311,6 +310,7 @@ ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean ReturnCount:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private suspend fun loginUser(loginUserOp: LoginUserFromSubscriptionOperation): ExecutionResponse ReturnCount:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun loginUser( loginUserOp: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse + ReturnCount:LogoutHelper.kt$LogoutHelper$internal fun switchUser(): LogoutEnqueueContext? ReturnCount:Model.kt$Model$protected fun getOptBigDecimalProperty( name: String, create: (() -> BigDecimal?)? = null, ): BigDecimal? ReturnCount:Model.kt$Model$protected fun getOptDoubleProperty( name: String, create: (() -> Double?)? = null, ): Double? ReturnCount:Model.kt$Model$protected fun getOptFloatProperty( name: String, create: (() -> Float?)? = null, ): Float? @@ -320,6 +320,7 @@ ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean + ReturnCount:OperationRepo.kt$OperationRepo$private fun shouldSuppressAnonymousOp(op: Operation): Boolean ReturnCount:OperationRepoIvExtensions.kt$internal fun OperationRepo.handleFailUnauthorized( startingOp: OperationRepo.OperationQueueItem, ops: List<OperationRepo.OperationQueueItem>, jwtTokenStore: JwtTokenStore, ivBehaviorActive: Boolean, ): Boolean ReturnCount:OperationRepoIvExtensions.kt$internal fun OperationRepo.hasValidJwtIfRequired( jwtTokenStore: JwtTokenStore, op: com.onesignal.core.internal.operations.Operation, ivBehaviorActive: Boolean, ): Boolean ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? @@ -413,7 +414,7 @@ TooManyFunctions:OutcomeEventsController.kt$OutcomeEventsController : IOutcomeEventsControllerIStartableServiceISessionLifecycleHandler TooManyFunctions:PreferencesService.kt$PreferencesService : IPreferencesServiceIStartableService TooManyFunctions:SubscriptionManager.kt$SubscriptionManager : ISubscriptionManagerIModelStoreChangeHandlerISessionLifecycleHandler - TooManyFunctions:UserManager.kt$UserManager : IUserManagerISingletonModelStoreChangeHandlerIJwtUpdateListener + TooManyFunctions:UserManager.kt$UserManager : IUserManagerISingletonModelStoreChangeHandler UndocumentedPublicClass:AlertDialogPrepromptForAndroidSettings.kt$AlertDialogPrepromptForAndroidSettings$Callback UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils UndocumentedPublicClass:AndroidUtils.kt$AndroidUtils$SchemaType @@ -458,7 +459,6 @@ UndocumentedPublicClass:JSONConverter.kt$JSONConverter UndocumentedPublicClass:JSONUtils.kt$JSONUtils UndocumentedPublicClass:Logging.kt$Logging - UndocumentedPublicClass:LogoutHelper.kt$LogoutHelper UndocumentedPublicClass:MigrationRecovery.kt$MigrationRecovery : IMigrationRecovery UndocumentedPublicClass:NetworkUtils.kt$NetworkUtils UndocumentedPublicClass:NetworkUtils.kt$NetworkUtils$ResponseStatusType 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 dc2eb6d3c..8af1ae17a 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 @@ -24,7 +24,7 @@ import com.onesignal.user.internal.jwt.JwtRequirement * Consumers (e.g. OperationRepo) wire post-HYDRATE behavior via [setOnJwtConfigHydratedHandler]; * the handler fires once per HYDRATE with `ivRequired = useIdentityVerification == REQUIRED`. */ -internal class IdentityVerificationService( +class IdentityVerificationService( private val featureManager: IFeatureManager, private val configModelStore: ConfigModelStore, ) : IStartableService, ISingletonModelStoreChangeHandler { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt index a2a5b3315..c7fb69804 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -3,7 +3,7 @@ package com.onesignal.core.internal.features /** * Controls when remote config changes for a feature are applied. */ -internal enum class FeatureActivationMode { +enum class FeatureActivationMode { /** * Apply config changes immediately during the current app run. */ @@ -20,7 +20,7 @@ internal enum class FeatureActivationMode { * * [key] values are **lowercase** strings as returned from remote config / Turbine `features` arrays. */ -internal enum class FeatureFlag( +enum class FeatureFlag( val key: String, val activationMode: FeatureActivationMode ) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index c9e43bad6..41f54c68a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -10,7 +10,7 @@ import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.debug.internal.logging.Logging import kotlinx.serialization.json.JsonObject -internal interface IFeatureManager { +interface IFeatureManager { fun isEnabled(feature: FeatureFlag): Boolean /** 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 c27527a05..c1a8db7ba 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 @@ -135,6 +135,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() @@ -147,6 +149,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() @@ -157,6 +161,26 @@ internal class OperationRepo( return waiter.waitForWake() } + /** + * Drop anonymous (externalId == null) operations at enqueue time when IV is required — + * they cannot be authenticated and would otherwise sit in the queue forever, blocked by + * `hasValidJwtIfRequired`. LoginUserOperation is exempt because it's enqueued + * intentionally during logout and purged later by [removeOperationsWithoutExternalId] + * if needed. Outer-gated on `newCodePathsRun` so Phase 1 customers stay byte-for-byte + * on the legacy enqueue path. + */ + private fun shouldSuppressAnonymousOp(op: Operation): Boolean { + if (!_identityVerificationService.newCodePathsRun) return false + if (op is LoginUserOperation) return false + val suppress = + _configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED && + op.externalId == null + if (suppress) { + Logging.debug("OperationRepo: suppressing anonymous op under IV-required: $op") + } + return suppress + } + /** * Only used inside this class, adds OperationQueueItem to queue * WARNING: Never set flush=true until budget rules are added, even for internal use! @@ -272,6 +296,18 @@ internal class OperationRepo( val anonymous = queue.filter { it.operation.externalId == null } anonymous.forEach { it.waiter?.wake(false) } queue.removeAll(anonymous) + // IV=ON never transfers anonymous state; clear existingOnesignalId so the + // executor takes the createUser (upsert) path. The merge-anon-into-identified + // path can't dispatch under IV — anon user creation requires a JWT-less call + // the backend rejects — and a stale local-id existingOnesignalId would leave + // canStartExecute=false forever, deadlocking the queue. + queue.forEach { item -> + val op = item.operation + if (op is LoginUserOperation && op.existingOnesignalId != null) { + Logging.debug("OperationRepo: cleared existingOnesignalId on LoginUserOperation (was ${op.existingOnesignalId})") + op.existingOnesignalId = null + } + } Logging.debug("OperationRepo: removeOperationsWithoutExternalId removed ${anonymous.size} of ${anonymous.size + queue.size} operations") anonymous.map { it.operation.id } } 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 b3043623e..9694d617f 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 @@ -17,6 +17,7 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.operations.IOperationRepo @@ -145,6 +146,7 @@ internal class OneSignalImp( 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 identityVerificationService: IdentityVerificationService by lazy { services.getService() } private val listOfModules = listOf( "com.onesignal.notifications.NotificationsModule", @@ -234,6 +236,8 @@ internal class OneSignalImp( userSwitcher = userSwitcher, operationRepo = operationRepo, configModel = configModel, + subscriptionModelStore = subscriptionModelStore, + identityVerificationService = identityVerificationService, lock = loginLogoutLock, ) } 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 9d3a8db1a..b86f366db 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.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation @@ -56,8 +57,18 @@ internal class LoginHelper( } val newOneSignalId = identityModelStore.model.onesignalId + // Under IV-required, the merge-anon-into-identified path can't dispatch — the + // anon user was never created server-side (no JWT) so the local-id reference + // would deadlock LoginUserOperation.canStartExecute. Skip the link entirely so + // the executor takes the createUser (upsert) path. val existingOneSignalId = - if (currentExternalId == null) currentOneSignalId else null + if (configModel.useIdentityVerification == JwtRequirement.REQUIRED) { + null + } else if (currentExternalId == null) { + currentOneSignalId + } else { + null + } return LoginEnqueueContext(configModel.appId, newOneSignalId, externalId, 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 e0017d5b3..1f5853336 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 @@ -1,15 +1,19 @@ package com.onesignal.user.internal import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.impl.IdentityVerificationService 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( +internal class LogoutHelper( private val identityModelStore: IdentityModelStore, private val userSwitcher: UserSwitcher, private val operationRepo: IOperationRepo, private val configModel: ConfigModel, + private val subscriptionModelStore: SubscriptionModelStore, + private val identityVerificationService: IdentityVerificationService, private val lock: Any, ) { internal data class LogoutEnqueueContext( @@ -29,11 +33,26 @@ class LogoutHelper( return null } + // Outer gate: dispatch to IV extension only on new code paths. The extension's + // inner gate (ivBehaviorActive) keeps Phase 3 users on the legacy logout flow. + val handled = + identityVerificationService.newCodePathsRun && + switchUserIv( + userSwitcher, + subscriptionModelStore, + configModel, + identityVerificationService.ivBehaviorActive, + ) + if (handled) { + // IV-required: subscription is internally disabled and the user-switch + // suppressed backend op enqueue. Don't enqueue anonymous LoginUserOperation — + // the anonymous user cannot authenticate without a JWT. + return null + } + // Create new device-scoped user (clears external ID) userSwitcher.createAndSwitchToNewUser() - // TODO: remove JWT Token for all future requests. - return LogoutEnqueueContext(configModel.appId, identityModelStore.model.onesignalId) } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt new file mode 100644 index 000000000..f0ff49f1f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/LogoutHelperIvExtensions.kt @@ -0,0 +1,44 @@ +package com.onesignal.user.internal + +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.user.internal.subscriptions.SubscriptionModelStore + +/** + * IV-specific behavior for [LogoutHelper]. The base-class call site dispatches via + * `if (newCodePathsRun) switchUserIv(...) else legacyLogout()`; this extension + * internally short-circuits on `ivBehaviorActive` to keep Phase 3 users (new code path on, + * IV behavior off) on the legacy logout flow. + */ + +/** + * Performs the IV-aware logout user-switch when [ivBehaviorActive] is true. + * + * Order matters and is intentional (mirrors reference branches #2599 and #2613): + * 1. Set `isDisabledInternally = true` on the CURRENT push subscription with the default + * NORMAL tag. This propagates through [com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener.getUpdateOperation], + * which reads the still-current OLD identity and enqueues an `UpdateSubscriptionOperation` + * carrying `(enabled = false, status = UNSUBSCRIBE)` — letting the backend know this device's + * push subscription is unsubscribing as the user logs out. The OLD user's JWT is still valid + * here, so the op dispatches successfully. + * 2. Switch to the new device-scoped (anonymous) user via + * [UserSwitcher.createAndSwitchToNewUser] with `suppressBackendOperation = true` so the + * subscription replacement does NOT propagate to listeners — the new anonymous user has no + * JWT and any create-subscription op for it would 401 indefinitely. + * + * Returns `true` when IV-specific handling was applied (caller skips legacy enqueue), + * or `false` when IV behavior is inactive (caller falls through to the legacy logout). + */ +internal fun switchUserIv( + userSwitcher: UserSwitcher, + subscriptionModelStore: SubscriptionModelStore, + configModel: ConfigModel, + ivBehaviorActive: Boolean, +): Boolean { + if (!ivBehaviorActive) return false + + configModel.pushSubscriptionId?.let { pushSubId -> + subscriptionModelStore.get(pushSubId)?.let { it.isDisabledInternally = true } + } + userSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + return true +} 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 b849fc4c4..7cc509d45 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 @@ -84,4 +84,11 @@ class CreateUserResponse( * The subscriptions for the user. */ val subscriptions: List, + /** + * Read-your-write data for IAM fetch consistency, when the backend supplies it. + * Populated under Identity Verification so [com.onesignal.inAppMessages] can await + * the user record's propagation before fetching IAMs. `null` for non-IV apps and + * older backends that don't include `ryw_token` in the response. + */ + val rywData: com.onesignal.common.consistency.RywData? = null, ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt index ff3745b32..5c971df6e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/JSONConverter.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal.backend.impl +import com.onesignal.common.consistency.RywData import com.onesignal.common.expandJSONArray import com.onesignal.common.putJSONArray import com.onesignal.common.putMap @@ -8,6 +9,7 @@ import com.onesignal.common.safeBool import com.onesignal.common.safeDouble import com.onesignal.common.safeInt import com.onesignal.common.safeJSONObject +import com.onesignal.common.safeLong import com.onesignal.common.safeString import com.onesignal.common.toMap import com.onesignal.user.internal.backend.CreateUserResponse @@ -55,7 +57,12 @@ object JSONConverter { return@expandJSONArray null } - return CreateUserResponse(respIdentities, respProperties, respSubscriptions) + // Backend may include `ryw_token` (and optional `ryw_delay`) under Identity Verification + // so InAppMessagesManager can gate IAM fetch on read-your-write consistency. + val rywToken = jsonObject.safeString("ryw_token") + val rywData = rywToken?.let { RywData(it, jsonObject.safeLong("ryw_delay")) } + + return CreateUserResponse(respIdentities, respProperties, respSubscriptions, rywData) } fun convertToJSON(properties: PropertiesObject): JSONObject { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt index 1e8630a33..412602900 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/IJwtUpdateListener.kt @@ -6,7 +6,7 @@ package com.onesignal.user.internal.jwt * developer-facing 401-invalidation event is delivered separately via * [com.onesignal.IUserJwtInvalidatedListener] (see [JwtTokenStore.addUserJwtInvalidatedListener]). */ -internal interface IJwtUpdateListener { +interface IJwtUpdateListener { /** Fired when a JWT was added or refreshed (`putJwt`), or when stale entries are pruned. */ fun onJwtUpdated(externalId: String) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt index f78de6135..6e53c4a04 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/jwt/JwtTokenStore.kt @@ -22,7 +22,7 @@ import org.json.JSONObject * ([addUserJwtInvalidatedListener]). Pure pub/sub: only listeners subscribed at the time * of [invalidateJwt] receive the event. Matches iOS — no buffering for late subscribers. */ -internal class JwtTokenStore( +class JwtTokenStore( private val _prefs: IPreferencesService, ) { private val tokens: MutableMap = mutableMapOf() 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 4b980e801..0d9e975f0 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 @@ -8,6 +8,8 @@ import com.onesignal.common.NetworkUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.RootToolsInternalMethods import com.onesignal.common.TimeUtils +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.application.IApplicationService @@ -51,6 +53,7 @@ internal class LoginUserOperationExecutor( private val _languageContext: ILanguageContext, private val _jwtTokenStore: JwtTokenStore, private val _identityVerificationService: IdentityVerificationService, + private val _consistencyManager: IConsistencyManager, ) : IOperationExecutor { override val operations: List get() = listOf(LOGIN_USER) @@ -235,6 +238,16 @@ internal class LoginUserOperationExecutor( backendSubscriptions.remove(backendSubscription) } + // Forward the create-user RYW data to ConsistencyManager so InAppMessagesManager + // can fetch IAMs once the user record has propagated. Gated on newCodePathsRun: + // Phase 1 users don't await RYW (legacy IAM fetch path), so storing it would be + // a no-op anyway. Backend only sends rywData under IV. + if (_identityVerificationService.newCodePathsRun) { + response.rywData?.let { rywData -> + _consistencyManager.setRywData(backendOneSignalId, IamFetchRywTokenKey.USER, rywData) + } + } + val wasPossiblyAnUpsert = identities.isNotEmpty() val followUpOperations = if (wasPossiblyAnUpsert) { 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 a4af81c0f..c210193e6 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 @@ -63,6 +63,13 @@ internal class SubscriptionModelStoreListener( companion object { fun getSubscriptionEnabledAndStatus(model: SubscriptionModel): Pair { + // Internal-disabled subscription (e.g. the post-logout anonymous user under IV) + // must not generate backend ops; report as disabled+unsubscribe regardless of + // optedIn/status. See [SubscriptionModel.isDisabledInternally]. + 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 c7bde3aae..a3d1bcfe0 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,23 @@ class SubscriptionModel : Model() { setBooleanProperty(::optedIn.name, value) } + /** + * Internal-only flag (not surfaced via the public API) used to suppress backend + * subscription operations for this model. Set to `true` on logout under Identity + * Verification: the new device-scoped (anonymous) user can't authenticate without + * a JWT, so the SDK must not generate create-subscription ops for it. The + * [SubscriptionModelStoreListener] honors this flag by short-circuiting to + * `(enabled = false, status = UNSUBSCRIBE)` regardless of [optedIn] / [status]. + * + * Defaults to `false`. On the next login, [com.onesignal.user.internal.UserSwitcher] + * creates a fresh model that does not carry this flag, 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) { 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 c53c0f682..11fe4834b 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 @@ -7,6 +7,7 @@ import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.MockHelper import com.onesignal.mocks.MockPreferencesService import com.onesignal.user.internal.identity.IdentityModel +import com.onesignal.user.internal.jwt.JwtRequirement import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.properties.PropertiesModel @@ -50,6 +51,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val loginHelper = @@ -91,6 +93,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -158,6 +161,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -202,6 +206,68 @@ class LoginHelperTests : FunSpec({ } } + test("login under IV-required does NOT carry existingOnesignalId from anonymous user") { + // Given - anonymous user, IV is REQUIRED. The anon user was never created server-side + // (no JWT), so its onesignalId would be permanently local. Carrying it as + // existingOnesignalId on the new LoginUserOperation would deadlock canStartExecute. + val mockIdentityModelStore = + MockHelper.identityModelStore { model -> + model.externalId = null + model.onesignalId = currentOneSignalId + } + + val newIdentityModel = + IdentityModel().apply { + externalId = newExternalId + onesignalId = newOneSignalId + } + + val mockUserSwitcher = mockk() + val mockOperationRepo = mockk() + val mockConfigModel = mockk() + every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.REQUIRED + + 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 = + LoginHelper( + identityModelStore = mockIdentityModelStore, + userSwitcher = mockUserSwitcher, + operationRepo = mockOperationRepo, + configModel = mockConfigModel, + jwtTokenStore = JwtTokenStore(MockPreferencesService()), + lock = Any(), + ) + + // When + runBlocking { + val context = loginHelper.switchUser(newExternalId, jwtBearerToken = "fresh-jwt") + if (context != null) loginHelper.enqueueLogin(context) + } + + // Then — under IV, the executor must take the createUser (upsert) path; no merge link. + coVerify(exactly = 1) { + mockOperationRepo.enqueueAndWait( + withArg { operation -> + operation.externalId shouldBe newExternalId + operation.existingOnesignalId shouldBe null + }, + ) + } + } + test("login logs error when operation fails") { // Given val mockIdentityModelStore = @@ -220,6 +286,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk() val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val loginLock = Any() val userSwitcherSlot = slot<(IdentityModel, PropertiesModel) -> Unit>() @@ -279,6 +346,7 @@ class LoginHelperTests : FunSpec({ coEvery { mockOperationRepo.enqueueAndWait(any()) } returns true val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val jwtTokenStore = JwtTokenStore(MockPreferencesService()) val loginHelper = @@ -312,6 +380,7 @@ class LoginHelperTests : FunSpec({ val mockOperationRepo = mockk(relaxed = true) val mockConfigModel = mockk() every { mockConfigModel.appId } returns appId + every { mockConfigModel.useIdentityVerification } returns JwtRequirement.NOT_REQUIRED val jwtTokenStore = JwtTokenStore(MockPreferencesService()) jwtTokenStore.putJwt(currentExternalId, "old-jwt") 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 f78340868..c804dc456 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,9 @@ 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 com.onesignal.user.internal.subscriptions.SubscriptionType import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -49,6 +52,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -80,6 +88,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -120,6 +133,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -153,6 +171,11 @@ class LogoutHelperTests : FunSpec({ userSwitcher = mockUserSwitcher, operationRepo = mockOperationRepo, configModel = mockConfigModel, + subscriptionModelStore = mockk(relaxed = true), + identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + }, lock = logoutLock, ) @@ -172,4 +195,52 @@ class LogoutHelperTests : FunSpec({ verify(atLeast = 1) { mockUserSwitcher.createAndSwitchToNewUser() } verify(atLeast = 1) { mockOperationRepo.enqueue(any()) } } + + test("switchUserIv marks current push sub as internally disabled before switching users") { + // Given - IV active, push sub model in store + val pushSubId = "push-sub-id" + val pushSubModel = + SubscriptionModel().apply { + id = pushSubId + type = SubscriptionType.PUSH + } + val mockSubscriptionModelStore = mockk(relaxed = true) + every { mockSubscriptionModelStore.get(pushSubId) } returns pushSubModel + val mockUserSwitcher = mockk(relaxed = true) + val mockConfigModel = mockk() + every { mockConfigModel.pushSubscriptionId } returns pushSubId + + // When + val handled = switchUserIv(mockUserSwitcher, mockSubscriptionModelStore, mockConfigModel, ivBehaviorActive = true) + + // Then + handled shouldBe true + + // Order: flag must be set on the OLD push sub BEFORE the user-switch — setting it + // with the default NORMAL tag fires an UpdateSubscriptionOperation against the OLD + // user with their still-valid JWT, telling the backend that this device's push + // subscription is unsubscribing as the user logs out. + verifyOrder { + mockSubscriptionModelStore.get(pushSubId) + mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = true) + } + + // The flag is set on the model (verified via the underlying property). + pushSubModel.isDisabledInternally shouldBe true + } + + test("switchUserIv returns false when IV behavior is inactive") { + // Given - Phase 3: new code path on, IV behavior off + val mockSubscriptionModelStore = mockk(relaxed = true) + val mockUserSwitcher = mockk(relaxed = true) + val mockConfigModel = mockk(relaxed = true) + + // When + val handled = switchUserIv(mockUserSwitcher, mockSubscriptionModelStore, mockConfigModel, ivBehaviorActive = false) + + // Then - falls through to legacy logout flow; no IV-specific calls. + handled shouldBe false + verify(exactly = 0) { mockUserSwitcher.createAndSwitchToNewUser(suppressBackendOperation = any()) } + verify(exactly = 0) { mockSubscriptionModelStore.get(any()) } + } }) 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 008536023..14121ca0c 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 @@ -79,7 +79,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -124,7 +124,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -153,7 +153,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, AndroidMockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf( LoginUserOperation(appId, localOneSignalId, null, null), @@ -181,7 +181,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) // When @@ -219,7 +219,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null)) @@ -248,7 +248,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -284,7 +284,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -320,7 +320,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -358,7 +358,7 @@ class LoginUserOperationExecutorTests : FunSpec({ val mockSubscriptionsModelStore = mockk() val loginUserOperationExecutor = - LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService()) + LoginUserOperationExecutor(mockIdentityOperationExecutor, MockHelper.applicationService(), MockHelper.deviceService(), mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true)) val operations = listOf(LoginUserOperation(appId, localOneSignalId, "externalId", "existingOneSignalId")) // When @@ -409,7 +409,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -514,7 +514,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -603,7 +603,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -678,7 +678,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val operations = listOf( @@ -744,7 +744,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) // anonymous Login request val operations = listOf(LoginUserOperation(appId, localOneSignalId, null, null)) @@ -791,7 +791,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, MockHelper.configModelStore(), MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) // send PUSH then EMAIL (local IDs 1,2) — order differs from backend response @@ -856,7 +856,7 @@ class LoginUserOperationExecutorTests : FunSpec({ mockSubscriptionsModelStore, configModelStore, MockHelper.languageContext(), - getJwtTokenStore(), getIdentityVerificationService(), + getJwtTokenStore(), getIdentityVerificationService(), mockk(relaxed = true), ) val ops = @@ -915,6 +915,7 @@ class LoginUserOperationExecutorTests : FunSpec({ MockHelper.languageContext(), getJwtTokenStore(), getIdentityVerificationService(newCodePathsRun = true, ivBehaviorActive = true), + mockk(relaxed = true), ) // LoginUserOperation has existingOnesignalId AND externalId — the input shape that 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 b4994a0ae..cc42e2371 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 @@ -18,6 +18,7 @@ import com.onesignal.core.internal.application.IApplicationLifecycleHandler import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.config.impl.IdentityVerificationService import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime @@ -48,6 +49,8 @@ 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.jwt.IJwtUpdateListener +import com.onesignal.user.internal.jwt.JwtTokenStore import com.onesignal.user.internal.subscriptions.ISubscriptionChangedHandler import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -76,6 +79,8 @@ internal class InAppMessagesManager( private val _languageContext: ILanguageContext, private val _time: ITime, private val _consistencyManager: IConsistencyManager, + private val _jwtTokenStore: JwtTokenStore, + private val _identityVerificationService: IdentityVerificationService, ) : IInAppMessagesManager, IStartableService, ISubscriptionChangedHandler, @@ -83,7 +88,8 @@ internal class InAppMessagesManager( IInAppLifecycleEventHandler, ITriggerHandler, ISessionLifecycleHandler, - IApplicationLifecycleHandler { + IApplicationLifecycleHandler, + IJwtUpdateListener { private val lifecycleCallback = EventProducer() private val messageClickCallback = EventProducer() @@ -114,8 +120,19 @@ internal class InAppMessagesManager( private val redisplayedInAppMessages: MutableList = mutableListOf() private val fetchIAMMutex = Mutex() + + @Volatile private var lastTimeFetchedIAMs: Long? = null + // Pending JWT-retry state under IV. When the IAM fetch returns 401/403 we save + // the externalId we were trying to fetch for + the rywData; on the next JwtTokenStore + // update for the same externalId, IAMs are re-fetched. Cleared on user-switch. + @Volatile + private var pendingJwtRetryExternalId: String? = null + + @Volatile + private var pendingJwtRetryRywData: RywData? = null + // Tracks whether the first IAM fetch has completed since this cold start private var hasCompletedFirstFetch: Boolean = false @@ -127,7 +144,12 @@ internal class InAppMessagesManager( override fun onModelReplaced( model: IdentityModel, tag: String, - ) { } + ) { + // User-switch (login or logout): drop any pending JWT retry — the externalId + // we were waiting on isn't the current user anymore. + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + } override fun onModelUpdated( args: ModelChangedArgs, @@ -190,6 +212,10 @@ internal class InAppMessagesManager( _sessionService.subscribe(this) _applicationService.addApplicationLifecycleHandler(this) _identityModelStore.subscribe(identityModelChangeHandler) + // Subscribe to JwtTokenStore so a JWT refresh (developer responding to a previous 401) + // can drive a deferred IAM re-fetch under IV. Subscription is ungated; the listener + // body checks for a pending retry, which is only set on the IV fetch path. + _jwtTokenStore.addInternalUpdateListener(this) suspendifyOnIO { _repository.cleanCachedInAppMessages() @@ -310,7 +336,12 @@ internal class InAppMessagesManager( // 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 = + if (_identityVerificationService.newCodePathsRun) { + fetchIvOrSaveRetry(appId, subscriptionId, rywData, sessionDurationProvider) + } else { + _backend.listInAppMessages(appId, subscriptionId, rywData, sessionDurationProvider) + } if (newMessages != null) { this.messages = newMessages as MutableList @@ -338,6 +369,65 @@ internal class InAppMessagesManager( } } + /** + * IV-aware IAM fetch. Resolves alias + JWT based on `ivBehaviorActive`, calls the + * alias-based backend endpoint, and on 401/403 saves retry state so [onJwtUpdated] + * can re-fetch once the developer supplies a refreshed JWT. + * + * Phase 3 path (newCodePathsRun=true, ivBehaviorActive=false): uses onesignal_id alias + * with no JWT — exercises the new endpoint structurally without IV-specific behavior. + */ + private suspend fun fetchIvOrSaveRetry( + appId: String, + subscriptionId: String, + rywData: RywData, + sessionDurationProvider: () -> Long, + ): List? { + val ivBehaviorActive = _identityVerificationService.ivBehaviorActive + val externalId = _identityModelStore.model.externalId + val onesignalId = _identityModelStore.model.onesignalId + + val (aliasLabel, aliasValue, jwt) = + if (ivBehaviorActive && externalId != null) { + Triple(IdentityConstants.EXTERNAL_ID, externalId, _jwtTokenStore.getJwt(externalId)) + } else { + Triple(IdentityConstants.ONESIGNAL_ID, onesignalId, null) + } + + // Set pending state before the call so a mid-flight onJwtUpdated finds it; clear on success. + if (ivBehaviorActive && externalId != null) { + pendingJwtRetryExternalId = externalId + pendingJwtRetryRywData = rywData + } + return try { + val result = _backend.listInAppMessagesIv(appId, aliasLabel, aliasValue, subscriptionId, rywData, sessionDurationProvider, jwt) + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + result + } catch (ex: BackendException) { + if (ivBehaviorActive && externalId != null) { + Logging.info("InAppMessagesManager: IAM fetch returned ${ex.statusCode}, awaiting JWT refresh for $externalId") + // Reset the rate-limiter so the retry isn't throttled. + lastTimeFetchedIAMs = null + } else { + Logging.warn("InAppMessagesManager: IAM fetch returned ${ex.statusCode}: ${ex.response}") + } + null + } + } + + // IJwtUpdateListener — fires when JwtTokenStore.putJwt or invalidateJwt runs for an externalId. + override fun onJwtUpdated(externalId: String) { + val pending = pendingJwtRetryExternalId + val pendingRyw = pendingJwtRetryRywData + if (pending == null || pending != externalId || pendingRyw == null) return + // Clear before retry so concurrent fires don't re-enter. + pendingJwtRetryExternalId = null + pendingJwtRetryRywData = null + Logging.info("InAppMessagesManager: JWT refreshed for $externalId, retrying IAM fetch") + suspendifyOnIO { fetchMessages(pendingRyw) } + } + /** * Iterate through the messages and determine if they should be shown to the user. */ 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 6755b6eb5..563413f66 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 @@ -26,6 +26,29 @@ internal interface IInAppBackendService { sessionDurationProvider: () -> Long, ): List? + /** + * IV-aware list of in-app messages. Hits the alias-based endpoint + * (`apps/{appId}/users/by/{aliasLabel}/{aliasValue}/subscriptions/{subscriptionId}/iams`) + * and attaches a JWT bearer token when supplied. Used only when + * `IdentityVerificationService.newCodePathsRun` is true; Phase 1 callers continue to use + * [listInAppMessages]. + * + * Throws [BackendException] on 401/403 so the caller can save retry state and re-fetch + * once the JWT is refreshed (via `IUserJwtInvalidatedListener` → `updateUserJwt`). + * + * [rywData] may be null when this is a fallback retry after the RYW-aware path exhausted + * its retry budget — in which case the request is sent without the RYW token. + */ + suspend fun listInAppMessagesIv( + appId: String, + aliasLabel: String, + aliasValue: String, + subscriptionId: String, + rywData: RywData?, + sessionDurationProvider: () -> Long, + jwt: String?, + ): List? + /** * Retrieve the data for a specific In App Message. * 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 9bbd738d5..7e381f3f6 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 @@ -34,7 +34,29 @@ internal class InAppBackendService( 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 = null) + } + + override suspend fun listInAppMessagesIv( + appId: String, + aliasLabel: String, + aliasValue: String, + subscriptionId: String, + rywData: RywData?, + sessionDurationProvider: () -> Long, + jwt: String?, + ): List? { + val baseUrl = "apps/$appId/users/by/$aliasLabel/$aliasValue/subscriptions/$subscriptionId/iams" + + // Fallback path: caller exhausted RYW-aware retries and is asking for a no-RYW fetch + // (e.g. after stale RYW token). Skip the RYW-token header; let the request through. + if (rywData == null) { + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider, jwt) + } + + val rywDelay = rywData.rywDelay ?: DEFAULT_RYW_DELAY_MS + delay(rywDelay) + return attemptFetchWithRetries(baseUrl, rywData, sessionDurationProvider, jwt) } override suspend fun getIAMData( @@ -209,6 +231,7 @@ internal class InAppBackendService( baseUrl: String, rywData: RywData, sessionDurationProvider: () -> Long, + jwt: String?, ): List? { var attempts = 0 var retryLimit: Int = 0 // retry limit is remote defined & set dynamically below @@ -220,6 +243,7 @@ internal class InAppBackendService( rywToken = rywData.rywToken, sessionDuration = sessionDurationProvider(), retryCount = retryCount, + jwt = jwt, ) val response = _httpClient.get(baseUrl, values) @@ -234,6 +258,10 @@ internal class InAppBackendService( response.retryAfterSeconds?.let { delay(it * 1_000L) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + // 401/403 — caller (InAppMessagesManager IV path) needs to surface this so it can + // save retry state and re-fetch once the JWT is refreshed. + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else if (response.statusCode in 500..599) { return null } else { @@ -244,24 +272,28 @@ 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, ), ) if (response.isSuccess) { val jsonResponse = response.payload?.let { JSONObject(it) } return jsonResponse?.let { hydrateInAppMessages(it) } + } else if (NetworkUtils.getResponseStatusType(response.statusCode) == NetworkUtils.ResponseStatusType.UNAUTHORIZED) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } else { return null } 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 418cce53c..3f9525bf1 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 @@ -89,6 +89,15 @@ private class Mocks { coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred } + val jwtTokenStore = mockk(relaxed = true) { + every { getJwt(any()) } returns null + } + + val identityVerificationService = mockk(relaxed = true) { + every { newCodePathsRun } returns false + every { ivBehaviorActive } returns false + } + val subscriptionManager = mockk(relaxed = true) { every { subscriptions } returns mockk { every { push } returns pushSubscription @@ -187,6 +196,8 @@ private class Mocks { languageContext, time, consistencyManager, + jwtTokenStore, + identityVerificationService, ) } 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 8982aefc8..09a65333d 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 70696e54f..774b03fd9 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() @@ -236,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/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt index 804566498..f4dcb99ba 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 82dfc0118..6d0ccfb10 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()) @@ -80,6 +81,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) } @@ -159,8 +161,11 @@ fun MainScreen(viewModel: MainViewModel) { // === USER SECTION === UserSection( externalUserId = externalUserId, + useIdentityVerification = useIdentityVerification, + onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, onLoginClick = { showLoginDialog = true }, - onLogoutClick = { viewModel.logoutUser() } + onLogoutClick = { viewModel.logoutUser() }, + onUpdateJwtClick = { showUpdateJwtDialog = true } ) // === PUSH SECTION === @@ -284,13 +289,26 @@ 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( title = "Add Alias", 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 e65af736d..46a7d4f50 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() @@ -74,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 @@ -99,6 +105,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"}") } @@ -127,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 @@ -145,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() @@ -217,12 +243,13 @@ 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) + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) _externalUserId.value = externalUserId showToast("Logged in as: $externalUserId") aliasesList.clear() @@ -235,7 +262,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshTriggers() loadExistingTags() refreshPushSubscription() - // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it + _isLoading.value = false + } + } + } + + fun updateUserJwt(externalUserId: String, jwtToken: String) { + viewModelScope.launch(Dispatchers.IO) { + repository.updateUserJwt(externalUserId, jwtToken) + withContext(Dispatchers.Main) { + SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) + showToast("Updated JWT for: $externalUserId") } } } @@ -262,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) @@ -619,8 +662,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 f672d322c..7cc769d83 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,12 +137,22 @@ fun AppSection( @Composable fun UserSection( externalUserId: String?, + useIdentityVerification: Boolean, + onUseIdentityVerificationChange: (Boolean) -> Unit, onLoginClick: () -> Unit, - onLogoutClick: () -> Unit + onLogoutClick: () -> Unit, + onUpdateJwtClick: () -> Unit ) { 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 @@ -200,6 +210,11 @@ fun UserSection( onClick = onLogoutClick ) } + + OutlineButton( + text = "UPDATE USER JWT", + onClick = onUpdateJwtClick + ) } // === PUSH SECTION === 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 f3b93dfb0..1cef40b59 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() + } }