From 48d347f487d7ab266266cc903df9971cebd67666 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 26 Oct 2026 14:19:27 -0500 Subject: [PATCH 1/4] fix: [SDK-5606] send full email and SMS fields on composite login Profile subscriptions were only type and token, so the backend stored notification_types -99. Match CreateSubscriptionOperation and drop the identity-verification warning. Co-authored-by: Cursor --- .../impl/executors/LoginProfileApplier.kt | 5 +- .../executors/LoginUserOperationExecutor.kt | 46 +++++++++++-------- .../LoginUserOperationExecutorTests.kt | 27 ++++++++++- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt index e668ba6ff..97b1d945a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt @@ -24,15 +24,16 @@ internal object LoginProfileApplier { fun addSubscriptions( op: LoginUserOperation, subscriptions: Map, + subscription: (SubscriptionObjectType, String) -> SubscriptionObject, ): Map { val mutable = subscriptions.toMutableMap() val email = op.email?.takeIf { it.isNotBlank() } if (email != null && mutable.values.none { it.type == SubscriptionObjectType.EMAIL && it.token == email }) { - mutable[PROFILE_EMAIL_KEY] = SubscriptionObject(type = SubscriptionObjectType.EMAIL, token = email) + mutable[PROFILE_EMAIL_KEY] = subscription(SubscriptionObjectType.EMAIL, email) } val sms = op.sms?.takeIf { it.isNotBlank() } if (sms != null && mutable.values.none { it.type == SubscriptionObjectType.SMS && it.token == sms }) { - mutable[PROFILE_SMS_KEY] = SubscriptionObject(type = SubscriptionObjectType.SMS, token = sms) + mutable[PROFILE_SMS_KEY] = subscription(SubscriptionObjectType.SMS, sms) } return mutable } 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 200baaf3d..09889f40e 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 @@ -44,6 +44,7 @@ import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionStatus import com.onesignal.user.internal.subscriptions.SubscriptionType internal class LoginUserOperationExecutor( @@ -208,7 +209,10 @@ internal class LoginUserOperationExecutor( else -> throw Exception("Unrecognized operation: $operation") } } - subscriptions = LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) + subscriptions = + LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token -> + subscriptionWithDevice(type, token, enabled = true, notificationTypes = SubscriptionStatus.SUBSCRIBED.value) + } try { val subscriptionList = subscriptions.toList() @@ -293,14 +297,6 @@ internal class LoginUserOperationExecutor( Logging.warn("LoginUserOperationExecutor: skipped profile hydration because the current identity is not the created user") } - if (!_identityVerificationService.ivBehaviorActive && - (!createUserOperation.email.isNullOrBlank() || !createUserOperation.sms.isNullOrBlank()) - ) { - Logging.warn( - "LoginUserOperationExecutor: email or SMS sent without identity verification. If that address belonged to another user it was transferred.", - ) - } - val wasPossiblyAnUpsert = identities.isNotEmpty() val followUpOperations = if (wasPossiblyAnUpsert) { @@ -377,24 +373,38 @@ internal class LoginUserOperationExecutor( } val subId = if (!IDManager.isLocalId(operation.subscriptionId)) operation.subscriptionId else null mutableSubscriptions[operation.subscriptionId] = - SubscriptionObject( - id = subId, + subscriptionWithDevice( subscriptionType, operation.address, operation.enabled, operation.status.value, - OneSignalUtils.sdkVersion, - Build.MODEL, - Build.VERSION.RELEASE, - RootToolsInternalMethods.isRooted, - DeviceUtils.getNetType(_application.appContext), - DeviceUtils.getCarrierName(_application.appContext), - AndroidUtils.getAppVersion(_application.appContext), + subId, ) return mutableSubscriptions } + private fun subscriptionWithDevice( + type: SubscriptionObjectType, + token: String, + enabled: Boolean, + notificationTypes: Int, + id: String? = null, + ) = SubscriptionObject( + id = id, + type = type, + token = token, + enabled = enabled, + notificationTypes = notificationTypes, + sdk = OneSignalUtils.sdkVersion, + deviceModel = Build.MODEL, + deviceOS = Build.VERSION.RELEASE, + rooted = RootToolsInternalMethods.isRooted, + netType = DeviceUtils.getNetType(_application.appContext), + carrier = DeviceUtils.getCarrierName(_application.appContext), + appVersion = AndroidUtils.getAppVersion(_application.appContext), + ) + private fun createSubscriptionsFromOperation( operation: UpdateSubscriptionOperation, subscriptions: Map, 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 908c39bc4..ff8a0ac38 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 @@ -1,8 +1,14 @@ package com.onesignal.user.internal.operations +import android.os.Build +import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.OneSignalUserProfile +import com.onesignal.common.AndroidUtils +import com.onesignal.common.DeviceUtils +import com.onesignal.common.OneSignalUtils import com.onesignal.common.PIIHasher +import com.onesignal.common.RootToolsInternalMethods import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult @@ -1076,6 +1082,23 @@ class LoginUserOperationExecutorTests : FunSpec({ mockk(relaxed = true), ) + fun assertProfileSubscription( + sub: SubscriptionObject, + token: String, + ) { + val context = ApplicationProvider.getApplicationContext() + sub.token shouldBe token + sub.enabled shouldBe true + sub.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + sub.sdk shouldBe OneSignalUtils.sdkVersion + sub.deviceModel shouldBe Build.MODEL + sub.deviceOS shouldBe Build.VERSION.RELEASE + sub.rooted shouldBe RootToolsInternalMethods.isRooted + sub.netType shouldBe DeviceUtils.getNetType(context) + sub.carrier shouldBe DeviceUtils.getCarrierName(context) + sub.appVersion shouldBe AndroidUtils.getAppVersion(context) + } + test("matchingBackendSubscription matches by id even when another sub has the token") { val remaining = setOf( @@ -1209,7 +1232,7 @@ class LoginUserOperationExecutorTests : FunSpec({ appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), withArg { subs -> - subs.single { it.type == SubscriptionObjectType.EMAIL }.token shouldBe "a@b.com" + assertProfileSubscription(subs.single { it.type == SubscriptionObjectType.EMAIL }, "a@b.com") subs.none { it.type == SubscriptionObjectType.SMS } shouldBe true }, any(), @@ -1237,7 +1260,7 @@ class LoginUserOperationExecutorTests : FunSpec({ appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), withArg { subs -> - subs.single { it.type == SubscriptionObjectType.SMS }.token shouldBe "+15555550100" + assertProfileSubscription(subs.single { it.type == SubscriptionObjectType.SMS }, "+15555550100") subs.none { it.type == SubscriptionObjectType.EMAIL } shouldBe true }, any(), From ae3ad26df1a7ec0a2e68e2f87d17b942af4705e1 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 26 Oct 2026 16:24:16 -0500 Subject: [PATCH 2/4] fix: [SDK-5606] omit subscription status on composite login email and SMS Create User applies enabled/notification_types to an existing address, which would re-subscribe a user who unsubscribed. Device fields are still sent. Also moves the test assertions out of withArg so they don't call a mocked AndroidUtils during verification, and suppresses TooManyFunctions. Co-authored-by: Cursor --- .../executors/LoginUserOperationExecutor.kt | 10 +++-- .../LoginUserOperationExecutorTests.kt | 39 +++++++++---------- 2 files changed, 25 insertions(+), 24 deletions(-) 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 09889f40e..c590a0417 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 @@ -44,9 +44,9 @@ import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore -import com.onesignal.user.internal.subscriptions.SubscriptionStatus import com.onesignal.user.internal.subscriptions.SubscriptionType +@Suppress("TooManyFunctions") internal class LoginUserOperationExecutor( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, @@ -209,9 +209,11 @@ internal class LoginUserOperationExecutor( else -> throw Exception("Unrecognized operation: $operation") } } + // No enabled/notification_types: Create User applies them to an address that already + // exists, which would re-subscribe a user who unsubscribed. subscriptions = LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token -> - subscriptionWithDevice(type, token, enabled = true, notificationTypes = SubscriptionStatus.SUBSCRIBED.value) + subscriptionWithDevice(type, token) } try { @@ -387,8 +389,8 @@ internal class LoginUserOperationExecutor( private fun subscriptionWithDevice( type: SubscriptionObjectType, token: String, - enabled: Boolean, - notificationTypes: Int, + enabled: Boolean? = null, + notificationTypes: Int? = null, id: String? = null, ) = SubscriptionObject( id = id, 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 ff8a0ac38..6727653fe 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 @@ -39,11 +39,13 @@ import com.onesignal.user.internal.subscriptions.SubscriptionType import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldBeOneOf import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot @RobolectricTest class LoginUserOperationExecutorTests : FunSpec({ @@ -1088,14 +1090,21 @@ class LoginUserOperationExecutorTests : FunSpec({ ) { val context = ApplicationProvider.getApplicationContext() sub.token shouldBe token - sub.enabled shouldBe true - sub.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + sub.enabled shouldBe null + sub.notificationTypes shouldBe null + sub.sdk shouldNotBe null sub.sdk shouldBe OneSignalUtils.sdkVersion + sub.deviceModel shouldNotBe null sub.deviceModel shouldBe Build.MODEL + sub.deviceOS shouldNotBe null sub.deviceOS shouldBe Build.VERSION.RELEASE + sub.rooted shouldNotBe null sub.rooted shouldBe RootToolsInternalMethods.isRooted + sub.netType shouldNotBe null sub.netType shouldBe DeviceUtils.getNetType(context) + // Robolectric has no carrier, so only equality is checked. sub.carrier shouldBe DeviceUtils.getCarrierName(context) + sub.appVersion shouldNotBe null sub.appVersion shouldBe AndroidUtils.getAppVersion(context) } @@ -1227,17 +1236,12 @@ class LoginUserOperationExecutorTests : FunSpec({ response.result shouldBe ExecutionResult.SUCCESS response.metadata shouldBe LoginWaitMetadata(remoteOneSignalId, "email-sub") + val subs = slot>() coVerify(exactly = 1) { - mockUserBackendService.createUser( - appId, - mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), - withArg { subs -> - assertProfileSubscription(subs.single { it.type == SubscriptionObjectType.EMAIL }, "a@b.com") - subs.none { it.type == SubscriptionObjectType.SMS } shouldBe true - }, - any(), - ) + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) } + assertProfileSubscription(subs.captured.single { it.type == SubscriptionObjectType.EMAIL }, "a@b.com") + subs.captured.none { it.type == SubscriptionObjectType.SMS } shouldBe true } test("composite login with phone only sends an SMS subscription") { @@ -1255,17 +1259,12 @@ class LoginUserOperationExecutorTests : FunSpec({ ) response.result shouldBe ExecutionResult.SUCCESS + val subs = slot>() coVerify(exactly = 1) { - mockUserBackendService.createUser( - appId, - mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), - withArg { subs -> - assertProfileSubscription(subs.single { it.type == SubscriptionObjectType.SMS }, "+15555550100") - subs.none { it.type == SubscriptionObjectType.EMAIL } shouldBe true - }, - any(), - ) + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) } + assertProfileSubscription(subs.captured.single { it.type == SubscriptionObjectType.SMS }, "+15555550100") + subs.captured.none { it.type == SubscriptionObjectType.EMAIL } shouldBe true } test("composite login with tags only forwards them on createUser") { From 2772003f44101d28a94a30478097e9e1ebb1c45d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 27 Oct 2026 12:51:33 -0500 Subject: [PATCH 3/4] fix: [SDK-5606] let composite login store an email unsubscribed Create User applies a subscribed status to an address that already opted out, so status stays omitted unless the customer sets enabled false. Co-authored-by: Cursor --- OneSignalSDK/onesignal/core/api/core.api | 2 + .../com/onesignal/OneSignalUserProfile.kt | 15 +++- .../internal/operations/LoginUserOperation.kt | 13 +++- .../impl/executors/LoginProfileApplier.kt | 5 +- .../executors/LoginUserOperationExecutor.kt | 28 ++++++- .../onesignal/OneSignalUserProfileTests.kt | 25 ++++++ .../LoginUserOperationExecutorTests.kt | 77 ++++++++++++++++++- 7 files changed, 156 insertions(+), 9 deletions(-) diff --git a/OneSignalSDK/onesignal/core/api/core.api b/OneSignalSDK/onesignal/core/api/core.api index 0a26d1da1..ed1e590f4 100644 --- a/OneSignalSDK/onesignal/core/api/core.api +++ b/OneSignalSDK/onesignal/core/api/core.api @@ -194,6 +194,7 @@ public final class com/onesignal/OneSignalUserProfile { public fun equals (Ljava/lang/Object;)Z public final fun getAliases ()Ljava/util/Map; public final fun getEmail ()Ljava/lang/String; + public final fun getEmailEnabled ()Ljava/lang/Boolean; public final fun getSms ()Ljava/lang/String; public final fun getTags ()Ljava/util/Map; public fun hashCode ()I @@ -205,6 +206,7 @@ public final class com/onesignal/OneSignalUserProfile$Builder { public final fun build ()Lcom/onesignal/OneSignalUserProfile; public final fun setAliases (Ljava/util/Map;)Lcom/onesignal/OneSignalUserProfile$Builder; public final fun setEmail (Ljava/lang/String;)Lcom/onesignal/OneSignalUserProfile$Builder; + public final fun setEmailEnabled (Z)Lcom/onesignal/OneSignalUserProfile$Builder; public final fun setSms (Ljava/lang/String;)Lcom/onesignal/OneSignalUserProfile$Builder; public final fun setTags (Ljava/util/Map;)Lcom/onesignal/OneSignalUserProfile$Builder; } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt index f35b058a7..f773f9892 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt @@ -12,6 +12,9 @@ import java.util.Collections class OneSignalUserProfile private constructor(builder: Builder) { val email: String? = builder.email?.takeIf { it.isNotBlank() } val sms: String? = builder.sms?.takeIf { it.isNotBlank() } + + /** False stores the email unsubscribed. True is not sent. Null omits status. */ + val emailEnabled: Boolean? = builder.emailEnabled val tags: Map = Collections.unmodifiableMap(builder.tags.toMap()) val aliases: Map = Collections.unmodifiableMap(builder.aliases.toMap()) @@ -39,6 +42,7 @@ class OneSignalUserProfile private constructor(builder: Builder) { if (other !is OneSignalUserProfile) return false return email == other.email && sms == other.sms && + emailEnabled == other.emailEnabled && tags == other.tags && aliases == other.aliases } @@ -46,6 +50,7 @@ class OneSignalUserProfile private constructor(builder: Builder) { override fun hashCode(): Int { var result = email?.hashCode() ?: 0 result = 31 * result + (sms?.hashCode() ?: 0) + result = 31 * result + (emailEnabled?.hashCode() ?: 0) result = 31 * result + tags.hashCode() result = 31 * result + aliases.hashCode() return result @@ -60,6 +65,8 @@ class OneSignalUserProfile private constructor(builder: Builder) { private set internal var sms: String? = null private set + internal var emailEnabled: Boolean? = null + private set internal var tags: Map = emptyMap() private set internal var aliases: Map = emptyMap() @@ -68,6 +75,9 @@ class OneSignalUserProfile private constructor(builder: Builder) { /** Email to create or transfer onto this user at login. */ fun setEmail(email: String?) = apply { this.email = email } + /** Store the email unsubscribed when false. True is not sent on Create User. */ + fun setEmailEnabled(enabled: Boolean) = apply { this.emailEnabled = enabled } + /** SMS to create or transfer onto this user at login. Must be E.164 (e.g. +14155552671). */ fun setSms(sms: String?) = apply { this.sms = sms } @@ -92,7 +102,10 @@ class OneSignalUserProfile private constructor(builder: Builder) { sms: String? = null, tags: Map = emptyMap(), aliases: Map = emptyMap(), - ) = Builder().setEmail(email).setSms(sms).setTags(tags).setAliases(aliases).build() + emailEnabled: Boolean? = null, + ) = Builder().setEmail(email).setSms(sms).setTags(tags).setAliases(aliases).apply { + if (emailEnabled != null) setEmailEnabled(emailEnabled) + }.build() private fun redact(value: String?): String = if (value == null) "null" else "" } 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 08822cc9f..a2086d0a2 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 @@ -61,6 +61,12 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { setOptStringProperty(PROFILE_SMS, value) } + internal var emailEnabled: Boolean? + get() = getOptBooleanProperty(PROFILE_EMAIL_ENABLED) + set(value) { + setOptBooleanProperty(PROFILE_EMAIL_ENABLED, value) + } + internal var tags: Map get() = decodeMap(getOptStringProperty(PROFILE_TAGS)) set(value) { @@ -93,6 +99,7 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { if (profile != null) { this.email = profile.email?.takeIf { it.isNotBlank() } this.sms = profile.sms?.takeIf { it.isNotBlank() } + this.emailEnabled = profile.emailEnabled this.tags = profile.tags this.aliases = profile.aliases } @@ -102,7 +109,10 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { !email.isNullOrBlank() || !sms.isNullOrBlank() || tags.isNotEmpty() || aliases.isNotEmpty() internal fun mergeProfileFrom(other: LoginUserOperation) { - if (!other.email.isNullOrBlank()) email = other.email + if (!other.email.isNullOrBlank()) { + email = other.email + emailEnabled = other.emailEnabled + } if (!other.sms.isNullOrBlank()) sms = other.sms if (other.tags.isNotEmpty()) tags = tags + other.tags if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases @@ -128,6 +138,7 @@ internal fun reservedLoginAliasLabel(label: String): Boolean = label.isBlank() || label == IdentityConstants.ONESIGNAL_ID || label == IdentityConstants.EXTERNAL_ID private const val PROFILE_EMAIL = "profileEmail" +private const val PROFILE_EMAIL_ENABLED = "profileEmailEnabled" private const val PROFILE_SMS = "profileSms" private const val PROFILE_TAGS = "profileTags" private const val PROFILE_ALIASES = "profileAliases" diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt index 97b1d945a..5d6124d91 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt @@ -74,6 +74,7 @@ internal object LoginProfileApplier { backend: SubscriptionObject, fallbackToken: String?, subscriptionsModelStore: SubscriptionModelStore, + optedIn: Boolean = true, ) { val id = backend.id val token = backend.token ?: fallbackToken @@ -94,8 +95,8 @@ internal object LoginProfileApplier { model.id = id model.type = type model.address = token - model.status = SubscriptionStatus.SUBSCRIBED - model.optedIn = true + model.status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE + model.optedIn = optedIn subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE) } 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 c590a0417..a325fd2a5 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 @@ -44,6 +44,7 @@ import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import com.onesignal.user.internal.subscriptions.SubscriptionStatus import com.onesignal.user.internal.subscriptions.SubscriptionType @Suppress("TooManyFunctions") @@ -209,11 +210,9 @@ internal class LoginUserOperationExecutor( else -> throw Exception("Unrecognized operation: $operation") } } - // No enabled/notification_types: Create User applies them to an address that already - // exists, which would re-subscribe a user who unsubscribed. subscriptions = LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token -> - subscriptionWithDevice(type, token) + profileSubscription(type, token, createUserOperation.emailEnabled) } try { @@ -265,6 +264,7 @@ internal class LoginUserOperationExecutor( backendSubscription, LoginProfileApplier.fallbackToken(backendSubscription.type, createUserOperation), _subscriptionsModelStore, + optedIn = !emailStoredUnsubscribed(backendSubscription.type, createUserOperation), ) } when (LoginProfileApplier.subscriptionType(backendSubscription.type)) { @@ -386,6 +386,28 @@ internal class LoginUserOperationExecutor( return mutableSubscriptions } + // Create User applies a subscribed status to an address that already opted out. + // Send enabled false only when the customer asked, so they can collect the email + // without marketing consent. Anything else stays omitted. + private fun profileSubscription( + type: SubscriptionObjectType, + token: String, + emailEnabled: Boolean?, + ): SubscriptionObject { + val unsubscribed = type == SubscriptionObjectType.EMAIL && emailEnabled == false + return subscriptionWithDevice( + type, + token, + enabled = if (unsubscribed) false else null, + notificationTypes = if (unsubscribed) SubscriptionStatus.UNSUBSCRIBE.value else null, + ) + } + + private fun emailStoredUnsubscribed( + type: SubscriptionObjectType?, + operation: LoginUserOperation, + ): Boolean = LoginProfileApplier.subscriptionType(type) == SubscriptionType.EMAIL && operation.emailEnabled == false + private fun subscriptionWithDevice( type: SubscriptionObjectType, token: String, diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt index 9c6805f10..271976832 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt @@ -12,11 +12,36 @@ class OneSignalUserProfileTests : FunSpec({ profile.email.shouldBeNull() profile.sms.shouldBeNull() + profile.emailEnabled.shouldBeNull() profile.tags shouldBe emptyMap() profile.aliases shouldBe emptyMap() profile.hasFields shouldBe false } + test("emailEnabled false is kept and true is distinct from unset") { + val unsubscribed = OneSignalUserProfile(email = "bob@example.com", emailEnabled = false) + val subscribed = OneSignalUserProfile(email = "bob@example.com", emailEnabled = true) + val unset = OneSignalUserProfile(email = "bob@example.com") + + unsubscribed.emailEnabled shouldBe false + subscribed.emailEnabled shouldBe true + unset.emailEnabled.shouldBeNull() + unsubscribed shouldBe OneSignalUserProfile(email = "bob@example.com", emailEnabled = false) + (unsubscribed == unset) shouldBe false + (unsubscribed == subscribed) shouldBe false + } + + test("Java builder can store an email unsubscribed") { + val profile = + OneSignalUserProfile.builder() + .setEmail("bob@example.com") + .setEmailEnabled(false) + .build() + + profile.email shouldBe "bob@example.com" + profile.emailEnabled shouldBe false + } + test("Kotlin named args set only the fields that were passed") { val profile = OneSignalUserProfile( 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 6727653fe..63bf4ed71 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 @@ -1087,11 +1087,13 @@ class LoginUserOperationExecutorTests : FunSpec({ fun assertProfileSubscription( sub: SubscriptionObject, token: String, + enabled: Boolean? = null, + notificationTypes: Int? = null, ) { val context = ApplicationProvider.getApplicationContext() sub.token shouldBe token - sub.enabled shouldBe null - sub.notificationTypes shouldBe null + sub.enabled shouldBe enabled + sub.notificationTypes shouldBe notificationTypes sub.sdk shouldNotBe null sub.sdk shouldBe OneSignalUtils.sdkVersion sub.deviceModel shouldNotBe null @@ -1244,6 +1246,75 @@ class LoginUserOperationExecutorTests : FunSpec({ subs.captured.none { it.type == SubscriptionObjectType.SMS } shouldBe true } + test("composite login stores an email unsubscribed when enabled is false") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com")), + ) + val identityStore = MockHelper.identityModelStore { it.onesignalId = localOneSignalId } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + + val response = + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = false), + ), + ), + ) + + response.result shouldBe ExecutionResult.SUCCESS + val subs = slot>() + coVerify(exactly = 1) { + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) + } + assertProfileSubscription( + subs.captured.single { it.type == SubscriptionObjectType.EMAIL }, + "a@b.com", + enabled = false, + notificationTypes = SubscriptionStatus.UNSUBSCRIBE.value, + ) + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.let { + it.optedIn shouldBe false + it.status shouldBe SubscriptionStatus.UNSUBSCRIBE + } + } + + test("composite login omits email status when enabled is true") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(SubscriptionObject(id = "email-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com")), + ) + + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = true), + ), + ), + ) + + val subs = slot>() + coVerify(exactly = 1) { + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) + } + assertProfileSubscription(subs.captured.single { it.type == SubscriptionObjectType.EMAIL }, "a@b.com") + } + test("composite login with phone only sends an SMS subscription") { val mockUserBackendService = mockk() coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns @@ -1606,6 +1677,7 @@ class LoginUserOperationExecutorTests : FunSpec({ sms = "+15555550100", tags = mapOf("plan" to "pro"), aliases = mapOf("facebook" to "bob"), + emailEnabled = false, ), ) @@ -1613,6 +1685,7 @@ class LoginUserOperationExecutorTests : FunSpec({ restored.initializeFromJson(original.toJSON()) restored.email shouldBe "a@b.com" + restored.emailEnabled shouldBe false restored.sms shouldBe "+15555550100" restored.tags shouldBe mapOf("plan" to "pro") restored.aliases shouldBe mapOf("facebook" to "bob") From 1429bbce024a2393571eb94ca11eb0e0c259bace Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 27 Oct 2026 15:56:22 -0500 Subject: [PATCH 4/4] fix: [SDK-5606] name the profile subscription builder The parameter sat next to the subscriptions map and read as another collection. Co-authored-by: Cursor --- .../operations/impl/executors/LoginProfileApplier.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt index 5d6124d91..db68bdec9 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt @@ -24,16 +24,16 @@ internal object LoginProfileApplier { fun addSubscriptions( op: LoginUserOperation, subscriptions: Map, - subscription: (SubscriptionObjectType, String) -> SubscriptionObject, + buildProfileSubscription: (SubscriptionObjectType, String) -> SubscriptionObject, ): Map { val mutable = subscriptions.toMutableMap() val email = op.email?.takeIf { it.isNotBlank() } if (email != null && mutable.values.none { it.type == SubscriptionObjectType.EMAIL && it.token == email }) { - mutable[PROFILE_EMAIL_KEY] = subscription(SubscriptionObjectType.EMAIL, email) + mutable[PROFILE_EMAIL_KEY] = buildProfileSubscription(SubscriptionObjectType.EMAIL, email) } val sms = op.sms?.takeIf { it.isNotBlank() } if (sms != null && mutable.values.none { it.type == SubscriptionObjectType.SMS && it.token == sms }) { - mutable[PROFILE_SMS_KEY] = subscription(SubscriptionObjectType.SMS, sms) + mutable[PROFILE_SMS_KEY] = buildProfileSubscription(SubscriptionObjectType.SMS, sms) } return mutable }