From 5ce23276516523fbbbe9b4aa7745eda175e913f0 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 28 Oct 2026 14:04:59 -0500 Subject: [PATCH 1/6] feat: [SDK-5623] let composite login set email and SMS status The caller states subscription intent per channel. Sending enabled true was dropped before, so an address that had opted out could not be resubscribed. Omitting still leaves an existing address untouched. Co-authored-by: Cursor --- OneSignalSDK/detekt/detekt-baseline-core.xml | 1 + OneSignalSDK/onesignal/core/api/core.api | 2 + .../com/onesignal/OneSignalUserProfile.kt | 22 +++- .../internal/operations/LoginUserOperation.kt | 13 ++- .../executors/LoginUserOperationExecutor.kt | 35 +++--- .../onesignal/OneSignalUserProfileTests.kt | 22 ++++ .../LoginUserOperationExecutorTests.kt | 106 +++++++++++++++++- 7 files changed, 183 insertions(+), 18 deletions(-) diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 40db555e4..5498e652a 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -218,6 +218,7 @@ 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, private val _consistencyManager: IConsistencyManager, ) + LongParameterList:OneSignalUserProfile.kt$OneSignalUserProfile.Companion$( email: String? = null, sms: String? = null, tags: Map<String, String> = emptyMap(), aliases: Map<String, String> = emptyMap(), emailEnabled: Boolean? = null, smsEnabled: Boolean? = null, ) 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, ) diff --git a/OneSignalSDK/onesignal/core/api/core.api b/OneSignalSDK/onesignal/core/api/core.api index ed1e590f4..2429992bd 100644 --- a/OneSignalSDK/onesignal/core/api/core.api +++ b/OneSignalSDK/onesignal/core/api/core.api @@ -196,6 +196,7 @@ public final class com/onesignal/OneSignalUserProfile { public final fun getEmail ()Ljava/lang/String; public final fun getEmailEnabled ()Ljava/lang/Boolean; public final fun getSms ()Ljava/lang/String; + public final fun getSmsEnabled ()Ljava/lang/Boolean; public final fun getTags ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -208,6 +209,7 @@ public final class com/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 setSmsEnabled (Z)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 f773f9892..77455f2ba 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt @@ -13,8 +13,11 @@ 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. */ + /** Subscription status for [email]. Null leaves an existing address as it is. */ val emailEnabled: Boolean? = builder.emailEnabled + + /** Subscription status for [sms]. Null leaves an existing address as it is. */ + val smsEnabled: Boolean? = builder.smsEnabled val tags: Map = Collections.unmodifiableMap(builder.tags.toMap()) val aliases: Map = Collections.unmodifiableMap(builder.aliases.toMap()) @@ -43,6 +46,7 @@ class OneSignalUserProfile private constructor(builder: Builder) { return email == other.email && sms == other.sms && emailEnabled == other.emailEnabled && + smsEnabled == other.smsEnabled && tags == other.tags && aliases == other.aliases } @@ -51,6 +55,7 @@ class OneSignalUserProfile private constructor(builder: Builder) { var result = email?.hashCode() ?: 0 result = 31 * result + (sms?.hashCode() ?: 0) result = 31 * result + (emailEnabled?.hashCode() ?: 0) + result = 31 * result + (smsEnabled?.hashCode() ?: 0) result = 31 * result + tags.hashCode() result = 31 * result + aliases.hashCode() return result @@ -67,6 +72,8 @@ class OneSignalUserProfile private constructor(builder: Builder) { private set internal var emailEnabled: Boolean? = null private set + internal var smsEnabled: Boolean? = null + private set internal var tags: Map = emptyMap() private set internal var aliases: Map = emptyMap() @@ -75,12 +82,21 @@ 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. */ + /** + * Subscription status for the email. Subscribes when true, unsubscribes when false. + * Unset leaves an address that already exists as it is. Ignored without [setEmail]. + */ 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 } + /** + * Subscription status for the SMS number. Subscribes when true, unsubscribes when false. + * Unset leaves a number that already exists as it is. Ignored without [setSms]. + */ + fun setSmsEnabled(enabled: Boolean) = apply { this.smsEnabled = enabled } + /** Sets tags to apply at login. The map is copied. */ fun setTags(tags: Map) = apply { this.tags = tags.toMap() } @@ -103,8 +119,10 @@ class OneSignalUserProfile private constructor(builder: Builder) { tags: Map = emptyMap(), aliases: Map = emptyMap(), emailEnabled: Boolean? = null, + smsEnabled: Boolean? = null, ) = Builder().setEmail(email).setSms(sms).setTags(tags).setAliases(aliases).apply { if (emailEnabled != null) setEmailEnabled(emailEnabled) + if (smsEnabled != null) setSmsEnabled(smsEnabled) }.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 a2086d0a2..998ca9538 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 @@ -67,6 +67,12 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { setOptBooleanProperty(PROFILE_EMAIL_ENABLED, value) } + internal var smsEnabled: Boolean? + get() = getOptBooleanProperty(PROFILE_SMS_ENABLED) + set(value) { + setOptBooleanProperty(PROFILE_SMS_ENABLED, value) + } + internal var tags: Map get() = decodeMap(getOptStringProperty(PROFILE_TAGS)) set(value) { @@ -100,6 +106,7 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { this.email = profile.email?.takeIf { it.isNotBlank() } this.sms = profile.sms?.takeIf { it.isNotBlank() } this.emailEnabled = profile.emailEnabled + this.smsEnabled = profile.smsEnabled this.tags = profile.tags this.aliases = profile.aliases } @@ -113,7 +120,10 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { email = other.email emailEnabled = other.emailEnabled } - if (!other.sms.isNullOrBlank()) sms = other.sms + if (!other.sms.isNullOrBlank()) { + sms = other.sms + smsEnabled = other.smsEnabled + } if (other.tags.isNotEmpty()) tags = tags + other.tags if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases } @@ -140,6 +150,7 @@ internal fun reservedLoginAliasLabel(label: String): Boolean = private const val PROFILE_EMAIL = "profileEmail" private const val PROFILE_EMAIL_ENABLED = "profileEmailEnabled" private const val PROFILE_SMS = "profileSms" +private const val PROFILE_SMS_ENABLED = "profileSmsEnabled" 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/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt index 9a39143f7..0c86c2e35 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 @@ -212,7 +212,7 @@ internal class LoginUserOperationExecutor( } subscriptions = LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token -> - profileSubscription(type, token, createUserOperation.emailEnabled) + profileSubscription(type, token, profileEnabled(type, createUserOperation)) } try { @@ -264,7 +264,7 @@ internal class LoginUserOperationExecutor( backendSubscription, LoginProfileApplier.fallbackToken(backendSubscription.type, createUserOperation), _subscriptionsModelStore, - optedIn = !emailStoredUnsubscribed(backendSubscription.type, createUserOperation), + optedIn = profileEnabled(backendSubscription.type, createUserOperation) != false, ) } when (LoginProfileApplier.subscriptionType(backendSubscription.type)) { @@ -398,27 +398,34 @@ 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. + // Create User applies a status only when one is sent, so omitting leaves an address + // that already exists alone. The caller states intent; the SDK never assumes it. private fun profileSubscription( type: SubscriptionObjectType, token: String, - emailEnabled: Boolean?, - ): SubscriptionObject { - val unsubscribed = type == SubscriptionObjectType.EMAIL && emailEnabled == false - return subscriptionWithDevice( + enabled: Boolean?, + ): SubscriptionObject = + subscriptionWithDevice( type, token, - enabled = if (unsubscribed) false else null, - notificationTypes = if (unsubscribed) SubscriptionStatus.UNSUBSCRIBE.value else null, + enabled = enabled, + notificationTypes = + when (enabled) { + true -> SubscriptionStatus.SUBSCRIBED.value + false -> SubscriptionStatus.UNSUBSCRIBE.value + null -> null + }, ) - } - private fun emailStoredUnsubscribed( + private fun profileEnabled( type: SubscriptionObjectType?, operation: LoginUserOperation, - ): Boolean = LoginProfileApplier.subscriptionType(type) == SubscriptionType.EMAIL && operation.emailEnabled == false + ): Boolean? = + when (LoginProfileApplier.subscriptionType(type)) { + SubscriptionType.EMAIL -> operation.emailEnabled + SubscriptionType.SMS -> operation.smsEnabled + else -> null + } private fun subscriptionWithDevice( type: SubscriptionObjectType, 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 271976832..94f09a73c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalUserProfileTests.kt @@ -13,6 +13,7 @@ class OneSignalUserProfileTests : FunSpec({ profile.email.shouldBeNull() profile.sms.shouldBeNull() profile.emailEnabled.shouldBeNull() + profile.smsEnabled.shouldBeNull() profile.tags shouldBe emptyMap() profile.aliases shouldBe emptyMap() profile.hasFields shouldBe false @@ -31,6 +32,27 @@ class OneSignalUserProfileTests : FunSpec({ (unsubscribed == subscribed) shouldBe false } + test("smsEnabled is independent of emailEnabled") { + val both = OneSignalUserProfile(email = "bob@example.com", emailEnabled = false, sms = "+15555550100", smsEnabled = true) + + both.emailEnabled shouldBe false + both.smsEnabled shouldBe true + (both == OneSignalUserProfile(email = "bob@example.com", emailEnabled = false, sms = "+15555550100")) shouldBe false + } + + test("Java builder can set each channel status") { + val profile = + OneSignalUserProfile.builder() + .setEmail("bob@example.com") + .setEmailEnabled(true) + .setSms("+15555550100") + .setSmsEnabled(false) + .build() + + profile.emailEnabled shouldBe true + profile.smsEnabled shouldBe false + } + test("Java builder can store an email unsubscribed") { val profile = OneSignalUserProfile.builder() 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 3805b5e8f..c63178d14 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 @@ -1288,7 +1288,7 @@ class LoginUserOperationExecutorTests : FunSpec({ } } - test("composite login omits email status when enabled is true") { + test("composite login sends an email subscribed when enabled is true") { val mockUserBackendService = mockk() coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns CreateUserResponse( @@ -1309,11 +1309,113 @@ class LoginUserOperationExecutorTests : FunSpec({ ), ) + 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 = true, + notificationTypes = SubscriptionStatus.SUBSCRIBED.value, + ) + } + + test("composite login sends an SMS subscribed 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 = "sms-sub", type = SubscriptionObjectType.SMS, token = "+15555550100")), + ) + + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(sms = "+15555550100", smsEnabled = 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.SMS }, + "+15555550100", + enabled = true, + notificationTypes = SubscriptionStatus.SUBSCRIBED.value, + ) + } + + test("composite login stores an SMS 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 = "sms-sub", type = SubscriptionObjectType.SMS, token = "+15555550100")), + ) + val identityStore = MockHelper.identityModelStore { it.onesignalId = localOneSignalId } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(sms = "+15555550100", smsEnabled = false), + ), + ), + ) + + 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.SMS }, + "+15555550100", + enabled = false, + notificationTypes = SubscriptionStatus.UNSUBSCRIBE.value, + ) + subscriptions.list().single { it.type == SubscriptionType.SMS }.let { + it.optedIn shouldBe false + it.status shouldBe SubscriptionStatus.UNSUBSCRIBE + } + } + + test("composite login sends no status for an email or SMS when the flag is unset") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", sms = "+15555550100"), + ), + ), + ) + 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") + assertProfileSubscription(subs.captured.single { it.type == SubscriptionObjectType.SMS }, "+15555550100") } test("composite login with phone only sends an SMS subscription") { @@ -1833,6 +1935,7 @@ class LoginUserOperationExecutorTests : FunSpec({ tags = mapOf("plan" to "pro"), aliases = mapOf("facebook" to "bob"), emailEnabled = false, + smsEnabled = true, ), ) @@ -1841,6 +1944,7 @@ class LoginUserOperationExecutorTests : FunSpec({ restored.email shouldBe "a@b.com" restored.emailEnabled shouldBe false + restored.smsEnabled shouldBe true restored.sms shouldBe "+15555550100" restored.tags shouldBe mapOf("plan" to "pro") restored.aliases shouldBe mapOf("facebook" to "bob") From 24777a5e23585ac50411179979e044018f5aade9 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 28 Oct 2026 14:18:52 -0500 Subject: [PATCH 2/6] feat: [SDK-5623] add email and SMS status inputs to the demo Blank states no intent, so the login payload omits the status. Co-authored-by: Cursor --- .../data/model/CompositeLoginExample.java | 23 +++++++++-- .../data/repository/OneSignalRepository.kt | 9 ++++- .../example/ui/components/Dialogs.kt | 40 ++++++++++++++++++- .../onesignal/example/ui/main/MainScreen.kt | 4 +- .../example/ui/main/MainViewModel.kt | 5 ++- .../com/onesignal/example/ui/main/Sections.kt | 6 +-- 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java b/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java index 56d719b9d..938210413 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/model/CompositeLoginExample.java @@ -17,11 +17,28 @@ public static OneSignalUserProfile profile( String sms, Map tags, Map aliases) { - return new OneSignalUserProfile.Builder() + return profile(email, sms, tags, aliases, null, null); + } + + public static OneSignalUserProfile profile( + String email, + String sms, + Map tags, + Map aliases, + Boolean emailEnabled, + Boolean smsEnabled) { + OneSignalUserProfile.Builder builder = new OneSignalUserProfile.Builder() .setEmail(email) .setSms(sms) .setTags(tags) - .setAliases(aliases) - .build(); + .setAliases(aliases); + // Leaving a status unset keeps an address that already exists as it is. + if (emailEnabled != null) { + builder.setEmailEnabled(emailEnabled); + } + if (smsEnabled != null) { + builder.setSmsEnabled(smsEnabled); + } + return builder.build(); } } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt index 0f607c7d2..23bf1fad5 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt @@ -27,9 +27,14 @@ class OneSignalRepository { email: String?, sms: String?, jwtToken: String? = null, + emailEnabled: Boolean? = null, + smsEnabled: Boolean? = null, ) = withContext(Dispatchers.IO) { - DemoLog.d("Composite login externalUserId: $externalUserId email=$email sms=$sms") - val profile = CompositeLoginExample.profile(email, sms) + DemoLog.d( + "Composite login externalUserId: $externalUserId email=$email sms=$sms " + + "emailEnabled=$emailEnabled smsEnabled=$smsEnabled", + ) + val profile = CompositeLoginExample.profile(email, sms, emptyMap(), emptyMap(), emailEnabled, smsEnabled) val result = OneSignal.login(externalUserId, profile, jwtToken) DemoLog.d("Composite login result: $result") result diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt index 3ae57714b..c1eeec868 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt @@ -468,12 +468,21 @@ fun LoginDialog( @Composable fun CompositeLoginDialog( onDismiss: () -> Unit, - onConfirm: (externalId: String, email: String?, sms: String?, jwt: String?) -> Unit, + onConfirm: ( + externalId: String, + email: String?, + sms: String?, + jwt: String?, + emailEnabled: Boolean?, + smsEnabled: Boolean?, + ) -> Unit, ) { var externalId by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } var sms by remember { mutableStateOf("") } var jwtToken by remember { mutableStateOf("") } + var emailEnabled by remember { mutableStateOf("") } + var smsEnabled by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, @@ -522,6 +531,30 @@ fun CompositeLoginDialog( colors = dialogTextFieldColors() ) Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = emailEnabled, + onValueChange = { emailEnabled = it }, + label = { Text("Email status: true / false / blank") }, + modifier = Modifier + .fillMaxWidth() + .testTag("composite_login_email_enabled_input"), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = smsEnabled, + onValueChange = { smsEnabled = it }, + label = { Text("SMS status: true / false / blank") }, + modifier = Modifier + .fillMaxWidth() + .testTag("composite_login_sms_enabled_input"), + singleLine = true, + shape = TextFieldShape, + colors = dialogTextFieldColors() + ) + Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( value = jwtToken, onValueChange = { jwtToken = it }, @@ -543,6 +576,8 @@ fun CompositeLoginDialog( email.ifBlank { null }, sms.ifBlank { null }, jwtToken.ifBlank { null }, + emailEnabled.toOptionalBoolean(), + smsEnabled.toOptionalBoolean(), ) }, enabled = externalId.isNotBlank(), @@ -561,6 +596,9 @@ fun CompositeLoginDialog( ) } +/** Blank means the caller is not stating a status, which the SDK sends as omitted. */ +private fun String.toOptionalBoolean(): Boolean? = trim().lowercase().toBooleanStrictOrNull() + /** * Dialog for outcome selection and input. */ diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt index 958af4649..76a47f214 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt @@ -151,8 +151,8 @@ fun MainScreen(viewModel: MainViewModel) { onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, onLanguageChange = { viewModel.setLanguage(it) }, onLogin = { userId, jwt -> viewModel.loginUser(userId, jwt) }, - onLoginWithProfile = { userId, email, sms, jwt -> - viewModel.loginUserWithProfile(userId, email, sms, jwt) + onLoginWithProfile = { userId, email, sms, jwt, emailEnabled, smsEnabled -> + viewModel.loginUserWithProfile(userId, email, sms, jwt, emailEnabled, smsEnabled) }, onLogout = { viewModel.logoutUser() }, onUpdateJwt = { externalId, token -> viewModel.updateUserJwt(externalId, token) }, diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt index b5fdc1453..9c966103b 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt @@ -307,10 +307,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I email: String?, sms: String?, jwtToken: String? = null, + emailEnabled: Boolean? = null, + smsEnabled: Boolean? = null, ) { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { - val result = repository.loginUserWithProfile(externalUserId, email, sms, jwtToken) + val result = + repository.loginUserWithProfile(externalUserId, email, sms, jwtToken, emailEnabled, smsEnabled) withContext(Dispatchers.Main) { if (result.isSuccess) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt index d428f9338..850475fd5 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt @@ -185,7 +185,7 @@ fun UserSection( onUseIdentityVerificationChange: (Boolean) -> Unit, onLanguageChange: (String) -> Unit, onLogin: (String, String?) -> Unit, - onLoginWithProfile: (String, String?, String?, String?) -> Unit, + onLoginWithProfile: (String, String?, String?, String?, Boolean?, Boolean?) -> Unit, onLogout: () -> Unit, onUpdateJwt: (String, String) -> Unit, isLoading: Boolean = false, @@ -274,8 +274,8 @@ fun UserSection( if (compositeLoginOpen) { CompositeLoginDialog( onDismiss = { compositeLoginOpen = false }, - onConfirm = { userId, email, sms, jwt -> - onLoginWithProfile(userId, email, sms, jwt) + onConfirm = { userId, email, sms, jwt, emailEnabled, smsEnabled -> + onLoginWithProfile(userId, email, sms, jwt, emailEnabled, smsEnabled) compositeLoginOpen = false }, ) From e12591d0b0f536194937d8497f3303fa8c8bbc98 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 28 Oct 2026 15:08:30 -0500 Subject: [PATCH 3/6] fix: [SDK-5623] let a profile status override a grouped subscription A batched addEmail or addSms already carries subscribed, and the deduping in addSubscriptions meant the profile status never reached the wire for that address. Also stop a null status from clearing an earlier one on merge, and apply the status to a local record that already exists. Co-authored-by: Cursor --- .../com/onesignal/OneSignalUserProfile.kt | 3 +- .../internal/operations/LoginUserOperation.kt | 5 +- .../impl/executors/LoginProfileApplier.kt | 52 ++++++++-- .../executors/LoginUserOperationExecutor.kt | 17 +++- .../LoginUserOperationExecutorTests.kt | 99 +++++++++++++++++++ .../example/ui/components/Dialogs.kt | 9 +- 6 files changed, 166 insertions(+), 19 deletions(-) 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 77455f2ba..994f0eec1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt @@ -62,7 +62,8 @@ class OneSignalUserProfile private constructor(builder: Builder) { } override fun toString(): String = - "OneSignalUserProfile(email=${redact(email)}, sms=${redact(sms)}, tags=${tags.size}, aliases=${aliases.size})" + "OneSignalUserProfile(email=${redact(email)}, emailEnabled=$emailEnabled, sms=${redact(sms)}, " + + "smsEnabled=$smsEnabled, tags=${tags.size}, aliases=${aliases.size})" /** Builds a [OneSignalUserProfile]. Unset fields stay empty. */ class Builder { 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 998ca9538..b8847eb21 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 @@ -116,13 +116,14 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { !email.isNullOrBlank() || !sms.isNullOrBlank() || tags.isNotEmpty() || aliases.isNotEmpty() internal fun mergeProfileFrom(other: LoginUserOperation) { + // A null status is the later call stating no intent, so it must not clear an earlier one. if (!other.email.isNullOrBlank()) { email = other.email - emailEnabled = other.emailEnabled + other.emailEnabled?.let { emailEnabled = it } } if (!other.sms.isNullOrBlank()) { sms = other.sms - smsEnabled = other.smsEnabled + other.smsEnabled?.let { smsEnabled = it } } if (other.tags.isNotEmpty()) tags = tags + other.tags if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases 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 db68bdec9..d7df4316a 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 @@ -21,23 +21,46 @@ internal object LoginProfileApplier { fun isProfileSubscriptionKey(key: String): Boolean = key == PROFILE_EMAIL_KEY || key == PROFILE_SMS_KEY + /** + * [profileSubscription] receives the grouped entry for the same address when there is one, and + * returns null to leave it as it is. + */ fun addSubscriptions( op: LoginUserOperation, subscriptions: Map, - buildProfileSubscription: (SubscriptionObjectType, String) -> SubscriptionObject, + profileSubscription: (SubscriptionObjectType, String, SubscriptionObject?) -> 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] = 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] = buildProfileSubscription(SubscriptionObjectType.SMS, sms) + for ((type, token, profileKey) in profileAddresses(op)) { + val grouped = mutable.entries.firstOrNull { it.value.type == type && it.value.token == token } + val subscription = profileSubscription(type, token, grouped?.value) ?: continue + mutable[grouped?.key ?: profileKey] = subscription } return mutable } + private fun profileAddresses(op: LoginUserOperation): List> = + buildList { + op.email?.takeIf { it.isNotBlank() }?.let { add(Triple(SubscriptionObjectType.EMAIL, it, PROFILE_EMAIL_KEY)) } + op.sms?.takeIf { it.isNotBlank() }?.let { add(Triple(SubscriptionObjectType.SMS, it, PROFILE_SMS_KEY)) } + } + + fun SubscriptionObject.withStatus(enabled: Boolean): SubscriptionObject = + SubscriptionObject( + id = id, + type = type, + token = token, + enabled = enabled, + notificationTypes = if (enabled) SubscriptionStatus.SUBSCRIBED.value else SubscriptionStatus.UNSUBSCRIBE.value, + sdk = sdk, + deviceModel = deviceModel, + deviceOS = deviceOS, + rooted = rooted, + netType = netType, + carrier = carrier, + appVersion = appVersion, + ) + /** * The backend wins where it spoke, the request fills the rest. Never deletes: Create User can * echo a partial body, and dropping keys it omitted would discard state we just sent. @@ -70,11 +93,12 @@ internal object LoginProfileApplier { } } + /** [optedIn] null means the caller stated no status, so an existing record keeps the one it has. */ fun persistSubscription( backend: SubscriptionObject, fallbackToken: String?, subscriptionsModelStore: SubscriptionModelStore, - optedIn: Boolean = true, + optedIn: Boolean? = null, ) { val id = backend.id val token = backend.token ?: fallbackToken @@ -89,15 +113,23 @@ internal object LoginProfileApplier { } if (existing != null) { existing.setStringProperty(SubscriptionModel::id.name, id, ModelChangeTags.HYDRATE) + if (optedIn != null) applyStatus(existing, optedIn) return } val model = SubscriptionModel() model.id = id model.type = type model.address = token + applyStatus(model, optedIn ?: true) + subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE) + } + + private fun applyStatus( + model: SubscriptionModel, + optedIn: Boolean, + ) { model.status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE model.optedIn = optedIn - subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE) } fun subscriptionType(type: SubscriptionObjectType?): SubscriptionType? = 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 0c86c2e35..034d53320 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 @@ -211,8 +211,8 @@ internal class LoginUserOperationExecutor( } } subscriptions = - LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token -> - profileSubscription(type, token, profileEnabled(type, createUserOperation)) + LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token, grouped -> + profileSubscription(type, token, profileEnabled(type, createUserOperation), grouped) } try { @@ -264,7 +264,7 @@ internal class LoginUserOperationExecutor( backendSubscription, LoginProfileApplier.fallbackToken(backendSubscription.type, createUserOperation), _subscriptionsModelStore, - optedIn = profileEnabled(backendSubscription.type, createUserOperation) != false, + optedIn = profileEnabled(backendSubscription.type, createUserOperation), ) } when (LoginProfileApplier.subscriptionType(backendSubscription.type)) { @@ -404,8 +404,14 @@ internal class LoginUserOperationExecutor( type: SubscriptionObjectType, token: String, enabled: Boolean?, - ): SubscriptionObject = - subscriptionWithDevice( + grouped: SubscriptionObject?, + ): SubscriptionObject? { + // A grouped addEmail/addSms already carries a subscribed status. Only an explicit + // profile status may override it, otherwise that operation is left untouched. + if (grouped != null) { + return enabled?.let { with(LoginProfileApplier) { grouped.withStatus(it) } } + } + return subscriptionWithDevice( type, token, enabled = enabled, @@ -416,6 +422,7 @@ internal class LoginUserOperationExecutor( null -> null }, ) + } private fun profileEnabled( type: SubscriptionObjectType?, 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 c63178d14..2370fb149 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 @@ -1393,6 +1393,44 @@ class LoginUserOperationExecutorTests : FunSpec({ } } + test("composite login applies the profile status to a grouped subscription for the same address") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = false), + ), + CreateSubscriptionOperation( + appId, + localOneSignalId, + "externalId", + IDManager.createLocalId(), + SubscriptionType.EMAIL, + true, + "a@b.com", + SubscriptionStatus.SUBSCRIBED, + ), + ), + ) + + val subs = slot>() + coVerify(exactly = 1) { + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) + } + subs.captured.single { it.type == SubscriptionObjectType.EMAIL }.let { + it.token shouldBe "a@b.com" + it.enabled shouldBe false + it.notificationTypes shouldBe SubscriptionStatus.UNSUBSCRIBE.value + } + } + test("composite login sends no status for an email or SMS when the flag is unset") { val mockUserBackendService = mockk() coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns @@ -1922,6 +1960,67 @@ class LoginUserOperationExecutorTests : FunSpec({ coVerify(exactly = 1) { identityExecutor.execute(any()) } } + test("mergeProfileFrom keeps an earlier status when the later login states none") { + val first = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com", emailEnabled = false)) + val second = LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com")) + + first.mergeProfileFrom(second) + + first.email shouldBe "a@b.com" + first.emailEnabled shouldBe false + } + + test("mergeProfileFrom takes a later status when the later login states one") { + val first = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550100", smsEnabled = false)) + val second = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550100", smsEnabled = true)) + + first.mergeProfileFrom(second) + + first.smsEnabled shouldBe true + } + + test("composite login applies an unsubscribe to an email already in the local store") { + 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()) + subscriptions.add( + SubscriptionModel().apply { + id = IDManager.createLocalId() + type = SubscriptionType.EMAIL + address = "a@b.com" + optedIn = true + status = SubscriptionStatus.SUBSCRIBED + }, + ) + + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = false), + ), + ), + ) + + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.let { + it.id shouldBe "email-sub" + it.optedIn shouldBe false + it.status shouldBe SubscriptionStatus.UNSUBSCRIBE + } + } + test("LoginUserOperation profile fields survive JSON round trip") { val original = LoginUserOperation( diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt index c1eeec868..0df189728 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt @@ -535,6 +535,8 @@ fun CompositeLoginDialog( value = emailEnabled, onValueChange = { emailEnabled = it }, label = { Text("Email status: true / false / blank") }, + isError = emailEnabled.isStatusTypo(), + supportingText = { if (emailEnabled.isStatusTypo()) Text("Type true, false, or leave blank") }, modifier = Modifier .fillMaxWidth() .testTag("composite_login_email_enabled_input"), @@ -547,6 +549,8 @@ fun CompositeLoginDialog( value = smsEnabled, onValueChange = { smsEnabled = it }, label = { Text("SMS status: true / false / blank") }, + isError = smsEnabled.isStatusTypo(), + supportingText = { if (smsEnabled.isStatusTypo()) Text("Type true, false, or leave blank") }, modifier = Modifier .fillMaxWidth() .testTag("composite_login_sms_enabled_input"), @@ -580,7 +584,7 @@ fun CompositeLoginDialog( smsEnabled.toOptionalBoolean(), ) }, - enabled = externalId.isNotBlank(), + enabled = externalId.isNotBlank() && !emailEnabled.isStatusTypo() && !smsEnabled.isStatusTypo(), modifier = Modifier.testTag("composite_login_confirm_button"), colors = dialogTextButtonColors(), ) { @@ -599,6 +603,9 @@ fun CompositeLoginDialog( /** Blank means the caller is not stating a status, which the SDK sends as omitted. */ private fun String.toOptionalBoolean(): Boolean? = trim().lowercase().toBooleanStrictOrNull() +/** Text that is neither blank nor a boolean, so it would silently read as "no status". */ +private fun String.isStatusTypo(): Boolean = isNotBlank() && toOptionalBoolean() == null + /** * Dialog for outcome selection and input. */ From 51c27acf20a25c6ebb6d62301286c8de878ee1b1 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 28 Oct 2026 15:22:48 -0500 Subject: [PATCH 4/6] fix: [SDK-5623] scope a merged profile status to its own address Preserving an earlier status across a merge carried it onto a different address, which would unsubscribe an address the caller never set a status for. Keep it only when the address is unchanged. Co-authored-by: Cursor --- .../internal/operations/LoginUserOperation.kt | 15 ++++++-- .../LoginUserOperationExecutorTests.kt | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) 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 b8847eb21..ac84f74b3 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 @@ -115,15 +115,24 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { internal fun hasProfileFields(): Boolean = !email.isNullOrBlank() || !sms.isNullOrBlank() || tags.isNotEmpty() || aliases.isNotEmpty() + /** + * A status belongs to the address it was stated for. Keeping it across a change would + * apply an opt-out the caller never asked for on the new address. + */ + private fun mergedStatus( + sameAddress: Boolean, + current: Boolean?, + incoming: Boolean?, + ): Boolean? = if (sameAddress) incoming ?: current else incoming + internal fun mergeProfileFrom(other: LoginUserOperation) { - // A null status is the later call stating no intent, so it must not clear an earlier one. if (!other.email.isNullOrBlank()) { + emailEnabled = mergedStatus(other.email == email, emailEnabled, other.emailEnabled) email = other.email - other.emailEnabled?.let { emailEnabled = it } } if (!other.sms.isNullOrBlank()) { + smsEnabled = mergedStatus(other.sms == sms, smsEnabled, other.smsEnabled) sms = other.sms - other.smsEnabled?.let { smsEnabled = it } } if (other.tags.isNotEmpty()) tags = tags + other.tags if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases 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 2370fb149..3ee22dbba 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 @@ -1971,6 +1971,40 @@ class LoginUserOperationExecutorTests : FunSpec({ first.emailEnabled shouldBe false } + test("mergeProfileFrom does not carry a status onto a different address") { + val first = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com", emailEnabled = false)) + val second = LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "c@d.com")) + + first.mergeProfileFrom(second) + + first.email shouldBe "c@d.com" + first.emailEnabled shouldBe null + } + + test("mergeProfileFrom does not carry an SMS status onto a different number") { + val first = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550100", smsEnabled = false)) + val second = LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550199")) + + first.mergeProfileFrom(second) + + first.sms shouldBe "+15555550199" + first.smsEnabled shouldBe null + } + + test("mergeProfileFrom takes an explicit status for a different address") { + val first = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com", emailEnabled = false)) + val second = + LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "c@d.com", emailEnabled = true)) + + first.mergeProfileFrom(second) + + first.email shouldBe "c@d.com" + first.emailEnabled shouldBe true + } + test("mergeProfileFrom takes a later status when the later login states one") { val first = LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550100", smsEnabled = false)) From e625909b11d81a8796436ab18ad319465e1470ca Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 28 Oct 2026 15:30:13 -0500 Subject: [PATCH 5/6] fix: [SDK-5623] hydrate status without enqueueing a redundant update Writing status on an attached model with the default tag made the store listener enqueue an Update Subscription for a status Create User had already applied. Also follow that status on the local record of a grouped operation, matched by address so a sibling of the same channel is untouched. Co-authored-by: Cursor --- .../impl/executors/LoginProfileApplier.kt | 11 +- .../executors/LoginUserOperationExecutor.kt | 20 ++++ .../LoginUserOperationExecutorTests.kt | 102 ++++++++++++++++++ 3 files changed, 130 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 d7df4316a..1619c6901 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 @@ -124,12 +124,17 @@ internal object LoginProfileApplier { subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE) } - private fun applyStatus( + /** + * HYDRATE, not NORMAL: this records what Create User already applied, and a NORMAL write on an + * attached model would make [ModelStoreListener] enqueue a redundant subscription update. + */ + fun applyStatus( model: SubscriptionModel, optedIn: Boolean, ) { - model.status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE - model.optedIn = optedIn + val status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE + model.setEnumProperty(SubscriptionModel::status.name, status, ModelChangeTags.HYDRATE) + model.setBooleanProperty(SubscriptionModel::optedIn.name, optedIn, ModelChangeTags.HYDRATE) } fun subscriptionType(type: SubscriptionObjectType?): SubscriptionType? = 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 034d53320..99f5924c6 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 @@ -258,6 +258,10 @@ internal class LoginUserOperationExecutor( val subscriptionModel = _subscriptionsModelStore.get(pair.first) if (subscriptionModel != null) { subscriptionModel.setStringProperty(SubscriptionModel::id.name, backendSubscription.id!!, ModelChangeTags.HYDRATE) + // The profile status overrode this grouped operation on the wire, so the local record follows. + profileStatusForAddress(backendSubscription.type, backendSubscription.token, createUserOperation)?.let { + LoginProfileApplier.applyStatus(subscriptionModel, it) + } } else if (LoginProfileApplier.isProfileSubscriptionKey(pair.first)) { if (_identityModelStore.model.onesignalId == backendOneSignalId) { LoginProfileApplier.persistSubscription( @@ -434,6 +438,22 @@ internal class LoginUserOperationExecutor( else -> null } + /** A status only applies to the address the caller stated it for, never a sibling of the same type. */ + private fun profileStatusForAddress( + type: SubscriptionObjectType?, + address: String?, + operation: LoginUserOperation, + ): Boolean? { + val profileAddress = + when (LoginProfileApplier.subscriptionType(type)) { + SubscriptionType.EMAIL -> operation.email + SubscriptionType.SMS -> operation.sms + else -> null + } ?: return null + val matches = profileAddress.equals(address, ignoreCase = type == SubscriptionObjectType.EMAIL) + return if (matches) profileEnabled(type, operation) else null + } + private fun subscriptionWithDevice( type: SubscriptionObjectType, token: String, 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 3ee22dbba..cd8cd9b9c 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 @@ -1431,6 +1431,108 @@ class LoginUserOperationExecutorTests : FunSpec({ } } + test("composite login applies the profile status to the local record of a grouped subscription") { + 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 groupedId = IDManager.createLocalId() + subscriptions.add( + SubscriptionModel().apply { + id = groupedId + type = SubscriptionType.EMAIL + address = "a@b.com" + optedIn = true + status = SubscriptionStatus.SUBSCRIBED + }, + ) + + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = false), + ), + CreateSubscriptionOperation( + appId, + localOneSignalId, + "externalId", + groupedId, + SubscriptionType.EMAIL, + true, + "a@b.com", + SubscriptionStatus.SUBSCRIBED, + ), + ), + ) + + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.let { + it.id shouldBe "email-sub" + it.optedIn shouldBe false + it.status shouldBe SubscriptionStatus.UNSUBSCRIBE + } + } + + test("composite login leaves a grouped subscription for a different address alone") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf( + SubscriptionObject(id = "other-sub", type = SubscriptionObjectType.EMAIL, token = "other@b.com"), + SubscriptionObject(id = "profile-sub", type = SubscriptionObjectType.EMAIL, token = "a@b.com"), + ), + ) + val identityStore = MockHelper.identityModelStore { it.onesignalId = localOneSignalId } + val subscriptions = SubscriptionModelStore(MockPreferencesService()) + val otherId = IDManager.createLocalId() + subscriptions.add( + SubscriptionModel().apply { + id = otherId + type = SubscriptionType.EMAIL + address = "other@b.com" + optedIn = true + status = SubscriptionStatus.SUBSCRIBED + }, + ) + + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = false), + ), + CreateSubscriptionOperation( + appId, + localOneSignalId, + "externalId", + otherId, + SubscriptionType.EMAIL, + true, + "other@b.com", + SubscriptionStatus.SUBSCRIBED, + ), + ), + ) + + subscriptions.list().single { it.address == "other@b.com" }.let { + it.optedIn shouldBe true + it.status shouldBe SubscriptionStatus.SUBSCRIBED + } + } + test("composite login sends no status for an email or SMS when the flag is unset") { val mockUserBackendService = mockk() coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns From 1f5d1fde43cfbae0b9d0603e2a8b7631e7de369c Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 28 Oct 2026 15:37:13 -0500 Subject: [PATCH 6/6] fix: [SDK-5623] dedupe a profile address the way the rest of the SDK does An email differing only by case missed the grouped match, so Create User carried two objects for one mailbox with opposite statuses. One shared comparison now backs the grouped lookup, the merge, and hydration. Also qualify the KDoc, since a pending addEmail still sends a status of its own, and log the points where a stated status is overridden. Co-authored-by: Cursor --- .../com/onesignal/OneSignalUserProfile.kt | 12 +- .../internal/operations/LoginUserOperation.kt | 11 +- .../impl/executors/LoginProfileApplier.kt | 34 ++-- .../executors/LoginUserOperationExecutor.kt | 7 +- .../LoginUserOperationExecutorTests.kt | 178 ++++++++++++++++++ 5 files changed, 221 insertions(+), 21 deletions(-) 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 994f0eec1..25dcfb51d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt @@ -13,10 +13,10 @@ class OneSignalUserProfile private constructor(builder: Builder) { val email: String? = builder.email?.takeIf { it.isNotBlank() } val sms: String? = builder.sms?.takeIf { it.isNotBlank() } - /** Subscription status for [email]. Null leaves an existing address as it is. */ + /** Subscription status for [email]. Null sends no status. See [Builder.setEmailEnabled]. */ val emailEnabled: Boolean? = builder.emailEnabled - /** Subscription status for [sms]. Null leaves an existing address as it is. */ + /** Subscription status for [sms]. Null sends no status. See [Builder.setSmsEnabled]. */ val smsEnabled: Boolean? = builder.smsEnabled val tags: Map = Collections.unmodifiableMap(builder.tags.toMap()) val aliases: Map = Collections.unmodifiableMap(builder.aliases.toMap()) @@ -85,7 +85,9 @@ class OneSignalUserProfile private constructor(builder: Builder) { /** * Subscription status for the email. Subscribes when true, unsubscribes when false. - * Unset leaves an address that already exists as it is. Ignored without [setEmail]. + * Unset sends no status, so an address that already exists keeps the status it has, + * unless a pending [com.onesignal.user.IUserManager.addEmail] for it sends one. + * Ignored without [setEmail]. Cannot be returned to unset once called. */ fun setEmailEnabled(enabled: Boolean) = apply { this.emailEnabled = enabled } @@ -94,7 +96,9 @@ class OneSignalUserProfile private constructor(builder: Builder) { /** * Subscription status for the SMS number. Subscribes when true, unsubscribes when false. - * Unset leaves a number that already exists as it is. Ignored without [setSms]. + * Unset sends no status, so a number that already exists keeps the status it has, + * unless a pending [com.onesignal.user.IUserManager.addSms] for it sends one. + * Ignored without [setSms]. Cannot be returned to unset once called. */ fun setSmsEnabled(enabled: Boolean) = apply { this.smsEnabled = enabled } 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 ac84f74b3..830366e96 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 @@ -127,11 +127,11 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { internal fun mergeProfileFrom(other: LoginUserOperation) { if (!other.email.isNullOrBlank()) { - emailEnabled = mergedStatus(other.email == email, emailEnabled, other.emailEnabled) + emailEnabled = mergedStatus(sameSubscriptionAddress(other.email, email, isEmail = true), emailEnabled, other.emailEnabled) email = other.email } if (!other.sms.isNullOrBlank()) { - smsEnabled = mergedStatus(other.sms == sms, smsEnabled, other.smsEnabled) + smsEnabled = mergedStatus(sameSubscriptionAddress(other.sms, sms, isEmail = false), smsEnabled, other.smsEnabled) sms = other.sms } if (other.tags.isNotEmpty()) tags = tags + other.tags @@ -154,6 +154,13 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) { } } +/** Mailboxes are case-insensitive, phone numbers are not. One rule so every caller dedupes alike. */ +internal fun sameSubscriptionAddress( + left: String?, + right: String?, + isEmail: Boolean, +): Boolean = if (isEmail) left.equals(right, ignoreCase = true) else left == right + internal fun reservedLoginAliasLabel(label: String): Boolean = label.isBlank() || label == IdentityConstants.ONESIGNAL_ID || label == IdentityConstants.EXTERNAL_ID 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 1619c6901..112bbc560 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 @@ -8,6 +8,7 @@ import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.operations.LoginUserOperation import com.onesignal.user.internal.operations.reservedLoginAliasLabel +import com.onesignal.user.internal.operations.sameSubscriptionAddress import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -32,7 +33,11 @@ internal object LoginProfileApplier { ): Map { val mutable = subscriptions.toMutableMap() for ((type, token, profileKey) in profileAddresses(op)) { - val grouped = mutable.entries.firstOrNull { it.value.type == type && it.value.token == token } + val grouped = + mutable.entries.firstOrNull { + it.value.type == type && + sameSubscriptionAddress(it.value.token, token, isEmail = type == SubscriptionObjectType.EMAIL) + } val subscription = profileSubscription(type, token, grouped?.value) ?: continue mutable[grouped?.key ?: profileKey] = subscription } @@ -45,20 +50,23 @@ internal object LoginProfileApplier { op.sms?.takeIf { it.isNotBlank() }?.let { add(Triple(SubscriptionObjectType.SMS, it, PROFILE_SMS_KEY)) } } - fun SubscriptionObject.withStatus(enabled: Boolean): SubscriptionObject = + fun withStatus( + subscription: SubscriptionObject, + enabled: Boolean, + ): SubscriptionObject = SubscriptionObject( - id = id, - type = type, - token = token, + id = subscription.id, + type = subscription.type, + token = subscription.token, enabled = enabled, notificationTypes = if (enabled) SubscriptionStatus.SUBSCRIBED.value else SubscriptionStatus.UNSUBSCRIBE.value, - sdk = sdk, - deviceModel = deviceModel, - deviceOS = deviceOS, - rooted = rooted, - netType = netType, - carrier = carrier, - appVersion = appVersion, + sdk = subscription.sdk, + deviceModel = subscription.deviceModel, + deviceOS = subscription.deviceOS, + rooted = subscription.rooted, + netType = subscription.netType, + carrier = subscription.carrier, + appVersion = subscription.appVersion, ) /** @@ -98,7 +106,7 @@ internal object LoginProfileApplier { backend: SubscriptionObject, fallbackToken: String?, subscriptionsModelStore: SubscriptionModelStore, - optedIn: Boolean? = null, + optedIn: Boolean?, ) { val id = backend.id val token = backend.token ?: fallbackToken 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 99f5924c6..62432fbae 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 @@ -41,6 +41,7 @@ import com.onesignal.user.internal.operations.SetAliasOperation import com.onesignal.user.internal.operations.TransferSubscriptionOperation import com.onesignal.user.internal.operations.UpdateSubscriptionOperation import com.onesignal.user.internal.operations.reservedLoginAliasLabel +import com.onesignal.user.internal.operations.sameSubscriptionAddress import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -413,7 +414,9 @@ internal class LoginUserOperationExecutor( // A grouped addEmail/addSms already carries a subscribed status. Only an explicit // profile status may override it, otherwise that operation is left untouched. if (grouped != null) { - return enabled?.let { with(LoginProfileApplier) { grouped.withStatus(it) } } + if (enabled == null) return null + Logging.debug("LoginUserOperationExecutor: profile status $enabled overrides a grouped $type subscription") + return LoginProfileApplier.withStatus(grouped, enabled) } return subscriptionWithDevice( type, @@ -450,7 +453,7 @@ internal class LoginUserOperationExecutor( SubscriptionType.SMS -> operation.sms else -> null } ?: return null - val matches = profileAddress.equals(address, ignoreCase = type == SubscriptionObjectType.EMAIL) + val matches = sameSubscriptionAddress(profileAddress, address, isEmail = type == SubscriptionObjectType.EMAIL) return if (matches) profileEnabled(type, operation) else null } 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 cd8cd9b9c..1b44bb52c 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 @@ -13,6 +13,7 @@ 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 +import com.onesignal.core.internal.operations.IOperationRepo import com.onesignal.core.internal.operations.LoginWaitMetadata import com.onesignal.core.internal.operations.Operation import com.onesignal.mocks.AndroidMockHelper @@ -31,6 +32,7 @@ import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtToke import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor import com.onesignal.user.internal.operations.impl.executors.matchingBackendSubscription +import com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener import com.onesignal.user.internal.properties.PropertiesModel import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.subscriptions.SubscriptionModel @@ -47,6 +49,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify @RobolectricTest class LoginUserOperationExecutorTests : FunSpec({ @@ -1481,6 +1484,43 @@ class LoginUserOperationExecutorTests : FunSpec({ } } + test("composite login overrides a grouped email that differs only by case") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "bob@example.com", emailEnabled = false), + ), + CreateSubscriptionOperation( + appId, + localOneSignalId, + "externalId", + IDManager.createLocalId(), + SubscriptionType.EMAIL, + true, + "Bob@Example.com", + SubscriptionStatus.SUBSCRIBED, + ), + ), + ) + + val subs = slot>() + coVerify(exactly = 1) { + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) + } + subs.captured.single { it.type == SubscriptionObjectType.EMAIL }.let { + it.enabled shouldBe false + it.notificationTypes shouldBe SubscriptionStatus.UNSUBSCRIBE.value + } + } + test("composite login leaves a grouped subscription for a different address alone") { val mockUserBackendService = mockk() coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns @@ -2118,6 +2158,144 @@ class LoginUserOperationExecutorTests : FunSpec({ first.smsEnabled shouldBe true } + test("composite login hydrating a status does not enqueue a subscription operation") { + 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()) + subscriptions.add( + SubscriptionModel().apply { + id = IDManager.createLocalId() + type = SubscriptionType.EMAIL + address = "a@b.com" + optedIn = true + status = SubscriptionStatus.SUBSCRIBED + }, + ) + // Real listener over a mock repo: a HYDRATE write must not become a backend operation. + val mockOpRepo = mockk(relaxed = true) + val listener = + SubscriptionModelStoreListener(subscriptions, mockOpRepo, identityStore, MockHelper.configModelStore()) + listener.bootstrap() + + try { + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = false), + ), + ), + ) + } finally { + listener.close() + } + + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.optedIn shouldBe false + verify(exactly = 0) { mockOpRepo.enqueue(any(), any()) } + } + + test("composite login resubscribes an email that is unsubscribed in the local store") { + 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()) + subscriptions.add( + SubscriptionModel().apply { + id = IDManager.createLocalId() + type = SubscriptionType.EMAIL + address = "a@b.com" + optedIn = false + status = SubscriptionStatus.UNSUBSCRIBE + }, + ) + + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", emailEnabled = true), + ), + ), + ) + + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.let { + it.optedIn shouldBe true + it.status shouldBe SubscriptionStatus.SUBSCRIBED + } + } + + test("composite login leaves a local email status alone when the flag is unset") { + 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()) + subscriptions.add( + SubscriptionModel().apply { + id = IDManager.createLocalId() + type = SubscriptionType.EMAIL + address = "a@b.com" + optedIn = false + status = SubscriptionStatus.UNSUBSCRIBE + }, + ) + + profileExecutor(mockUserBackendService, subscriptions, identityStore).execute( + listOf(LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(email = "a@b.com"))), + ) + + subscriptions.list().single { it.type == SubscriptionType.EMAIL }.let { + it.optedIn shouldBe false + it.status shouldBe SubscriptionStatus.UNSUBSCRIBE + } + } + + test("composite login ignores an SMS status when no SMS was given") { + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns + CreateUserResponse(mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), PropertiesObject(), listOf()) + + profileExecutor(mockUserBackendService).execute( + listOf( + LoginUserOperation( + appId, + localOneSignalId, + "externalId", + null, + OneSignalUserProfile(email = "a@b.com", smsEnabled = false), + ), + ), + ) + + val subs = slot>() + coVerify(exactly = 1) { + mockUserBackendService.createUser(appId, mapOf(IdentityConstants.EXTERNAL_ID to "externalId"), capture(subs), any()) + } + subs.captured.none { it.type == SubscriptionObjectType.SMS } shouldBe true + assertProfileSubscription(subs.captured.single { it.type == SubscriptionObjectType.EMAIL }, "a@b.com") + } + test("composite login applies an unsubscribe to an email already in the local store") { val mockUserBackendService = mockk() coEvery { mockUserBackendService.createUser(any(), any(), any(), any()) } returns