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 }, )