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..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,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 sends no status. See [Builder.setEmailEnabled]. */
val emailEnabled: Boolean? = builder.emailEnabled
+
+ /** 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())
@@ -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,13 +55,15 @@ 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
}
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 {
@@ -67,6 +73,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 +83,25 @@ 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 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 }
/** 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 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 }
+
/** Sets tags to apply at login. The map is copied. */
fun setTags(tags: Map) = apply { this.tags = tags.toMap() }
@@ -103,8 +124,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..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
@@ -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
}
@@ -108,12 +115,25 @@ 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) {
if (!other.email.isNullOrBlank()) {
+ emailEnabled = mergedStatus(sameSubscriptionAddress(other.email, email, isEmail = true), emailEnabled, other.emailEnabled)
email = other.email
- emailEnabled = other.emailEnabled
}
- if (!other.sms.isNullOrBlank()) sms = other.sms
+ if (!other.sms.isNullOrBlank()) {
+ smsEnabled = mergedStatus(sameSubscriptionAddress(other.sms, sms, isEmail = false), smsEnabled, other.smsEnabled)
+ sms = other.sms
+ }
if (other.tags.isNotEmpty()) tags = tags + other.tags
if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases
}
@@ -134,12 +154,20 @@ 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
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/LoginProfileApplier.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt
index db68bdec9..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
@@ -21,23 +22,53 @@ 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 &&
+ sameSubscriptionAddress(it.value.token, token, isEmail = type == SubscriptionObjectType.EMAIL)
+ }
+ 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 withStatus(
+ subscription: SubscriptionObject,
+ enabled: Boolean,
+ ): SubscriptionObject =
+ SubscriptionObject(
+ id = subscription.id,
+ type = subscription.type,
+ token = subscription.token,
+ enabled = enabled,
+ notificationTypes = if (enabled) SubscriptionStatus.SUBSCRIBED.value else SubscriptionStatus.UNSUBSCRIBE.value,
+ sdk = subscription.sdk,
+ deviceModel = subscription.deviceModel,
+ deviceOS = subscription.deviceOS,
+ rooted = subscription.rooted,
+ netType = subscription.netType,
+ carrier = subscription.carrier,
+ appVersion = subscription.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 +101,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?,
) {
val id = backend.id
val token = backend.token ?: fallbackToken
@@ -89,17 +121,30 @@ 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
- model.status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE
- model.optedIn = optedIn
+ applyStatus(model, optedIn ?: true)
subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE)
}
+ /**
+ * 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,
+ ) {
+ 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? =
when (type) {
SubscriptionObjectType.EMAIL -> SubscriptionType.EMAIL
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..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
@@ -211,8 +212,8 @@ internal class LoginUserOperationExecutor(
}
}
subscriptions =
- LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token ->
- profileSubscription(type, token, createUserOperation.emailEnabled)
+ LoginProfileApplier.addSubscriptions(createUserOperation, subscriptions) { type, token, grouped ->
+ profileSubscription(type, token, profileEnabled(type, createUserOperation), grouped)
}
try {
@@ -258,13 +259,17 @@ 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(
backendSubscription,
LoginProfileApplier.fallbackToken(backendSubscription.type, createUserOperation),
_subscriptionsModelStore,
- optedIn = !emailStoredUnsubscribed(backendSubscription.type, createUserOperation),
+ optedIn = profileEnabled(backendSubscription.type, createUserOperation),
)
}
when (LoginProfileApplier.subscriptionType(backendSubscription.type)) {
@@ -398,27 +403,59 @@ 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
+ enabled: Boolean?,
+ 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) {
+ if (enabled == null) return null
+ Logging.debug("LoginUserOperationExecutor: profile status $enabled overrides a grouped $type subscription")
+ return LoginProfileApplier.withStatus(grouped, enabled)
+ }
return 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
+ }
+
+ /** 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 = sameSubscriptionAddress(profileAddress, address, isEmail = type == SubscriptionObjectType.EMAIL)
+ return if (matches) profileEnabled(type, operation) 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..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({
@@ -1288,7 +1291,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 +1312,290 @@ 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 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 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 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
+ 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
+ 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") {
@@ -1820,6 +2102,239 @@ 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 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))
+ val second =
+ LoginUserOperation(appId, localOneSignalId, "externalId", null, OneSignalUserProfile(sms = "+15555550100", smsEnabled = true))
+
+ first.mergeProfileFrom(second)
+
+ 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
+ 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(
@@ -1833,6 +2348,7 @@ class LoginUserOperationExecutorTests : FunSpec({
tags = mapOf("plan" to "pro"),
aliases = mapOf("facebook" to "bob"),
emailEnabled = false,
+ smsEnabled = true,
),
)
@@ -1841,6 +2357,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")
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..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
@@ -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,34 @@ fun CompositeLoginDialog(
colors = dialogTextFieldColors()
)
Spacer(modifier = Modifier.height(12.dp))
+ OutlinedTextField(
+ 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"),
+ 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") },
+ isError = smsEnabled.isStatusTypo(),
+ supportingText = { if (smsEnabled.isStatusTypo()) Text("Type true, false, or leave 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,9 +580,11 @@ fun CompositeLoginDialog(
email.ifBlank { null },
sms.ifBlank { null },
jwtToken.ifBlank { null },
+ emailEnabled.toOptionalBoolean(),
+ smsEnabled.toOptionalBoolean(),
)
},
- enabled = externalId.isNotBlank(),
+ enabled = externalId.isNotBlank() && !emailEnabled.isStatusTypo() && !smsEnabled.isStatusTypo(),
modifier = Modifier.testTag("composite_login_confirm_button"),
colors = dialogTextButtonColors(),
) {
@@ -561,6 +600,12 @@ 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.
*/
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
},
)