Skip to content

Commit ec92684

Browse files
abdulraqeeb33AR Abdul Azeezcursoragent
authored
feat: [SDK-5623] let composite login set email and SMS subscription status (#2822)
Co-authored-by: AR Abdul Azeez <abdul@onesignal.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 96c5d30 commit ec92684

14 files changed

Lines changed: 788 additions & 43 deletions

File tree

‎OneSignalSDK/detekt/detekt-baseline-core.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
<ID>LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, )</ID>
219219
<ID>LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, jwt: String? = null, )</ID>
220220
<ID>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, )</ID>
221+
<ID>LongParameterList:OneSignalUserProfile.kt$OneSignalUserProfile.Companion$( email: String? = null, sms: String? = null, tags: Map&lt;String, String> = emptyMap(), aliases: Map&lt;String, String> = emptyMap(), emailEnabled: Boolean? = null, smsEnabled: Boolean? = null, )</ID>
221222
<ID>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, )</ID>
222223
<ID>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, )</ID>
223224
<ID>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, )</ID>

‎OneSignalSDK/onesignal/core/api/core.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ public final class com/onesignal/OneSignalUserProfile {
196196
public final fun getEmail ()Ljava/lang/String;
197197
public final fun getEmailEnabled ()Ljava/lang/Boolean;
198198
public final fun getSms ()Ljava/lang/String;
199+
public final fun getSmsEnabled ()Ljava/lang/Boolean;
199200
public final fun getTags ()Ljava/util/Map;
200201
public fun hashCode ()I
201202
public fun toString ()Ljava/lang/String;
@@ -208,6 +209,7 @@ public final class com/onesignal/OneSignalUserProfile$Builder {
208209
public final fun setEmail (Ljava/lang/String;)Lcom/onesignal/OneSignalUserProfile$Builder;
209210
public final fun setEmailEnabled (Z)Lcom/onesignal/OneSignalUserProfile$Builder;
210211
public final fun setSms (Ljava/lang/String;)Lcom/onesignal/OneSignalUserProfile$Builder;
212+
public final fun setSmsEnabled (Z)Lcom/onesignal/OneSignalUserProfile$Builder;
211213
public final fun setTags (Ljava/util/Map;)Lcom/onesignal/OneSignalUserProfile$Builder;
212214
}
213215

‎OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignalUserProfile.kt‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ class OneSignalUserProfile private constructor(builder: Builder) {
1313
val email: String? = builder.email?.takeIf { it.isNotBlank() }
1414
val sms: String? = builder.sms?.takeIf { it.isNotBlank() }
1515

16-
/** False stores the email unsubscribed. True is not sent. Null omits status. */
16+
/** Subscription status for [email]. Null sends no status. See [Builder.setEmailEnabled]. */
1717
val emailEnabled: Boolean? = builder.emailEnabled
18+
19+
/** Subscription status for [sms]. Null sends no status. See [Builder.setSmsEnabled]. */
20+
val smsEnabled: Boolean? = builder.smsEnabled
1821
val tags: Map<String, String> = Collections.unmodifiableMap(builder.tags.toMap())
1922
val aliases: Map<String, String> = Collections.unmodifiableMap(builder.aliases.toMap())
2023

@@ -43,6 +46,7 @@ class OneSignalUserProfile private constructor(builder: Builder) {
4346
return email == other.email &&
4447
sms == other.sms &&
4548
emailEnabled == other.emailEnabled &&
49+
smsEnabled == other.smsEnabled &&
4650
tags == other.tags &&
4751
aliases == other.aliases
4852
}
@@ -51,13 +55,15 @@ class OneSignalUserProfile private constructor(builder: Builder) {
5155
var result = email?.hashCode() ?: 0
5256
result = 31 * result + (sms?.hashCode() ?: 0)
5357
result = 31 * result + (emailEnabled?.hashCode() ?: 0)
58+
result = 31 * result + (smsEnabled?.hashCode() ?: 0)
5459
result = 31 * result + tags.hashCode()
5560
result = 31 * result + aliases.hashCode()
5661
return result
5762
}
5863

5964
override fun toString(): String =
60-
"OneSignalUserProfile(email=${redact(email)}, sms=${redact(sms)}, tags=${tags.size}, aliases=${aliases.size})"
65+
"OneSignalUserProfile(email=${redact(email)}, emailEnabled=$emailEnabled, sms=${redact(sms)}, " +
66+
"smsEnabled=$smsEnabled, tags=${tags.size}, aliases=${aliases.size})"
6167

6268
/** Builds a [OneSignalUserProfile]. Unset fields stay empty. */
6369
class Builder {
@@ -67,6 +73,8 @@ class OneSignalUserProfile private constructor(builder: Builder) {
6773
private set
6874
internal var emailEnabled: Boolean? = null
6975
private set
76+
internal var smsEnabled: Boolean? = null
77+
private set
7078
internal var tags: Map<String, String> = emptyMap()
7179
private set
7280
internal var aliases: Map<String, String> = emptyMap()
@@ -75,12 +83,25 @@ class OneSignalUserProfile private constructor(builder: Builder) {
7583
/** Email to create or transfer onto this user at login. */
7684
fun setEmail(email: String?) = apply { this.email = email }
7785

78-
/** Store the email unsubscribed when false. True is not sent on Create User. */
86+
/**
87+
* Subscription status for the email. Subscribes when true, unsubscribes when false.
88+
* Unset sends no status, so an address that already exists keeps the status it has,
89+
* unless a pending [com.onesignal.user.IUserManager.addEmail] for it sends one.
90+
* Ignored without [setEmail]. Cannot be returned to unset once called.
91+
*/
7992
fun setEmailEnabled(enabled: Boolean) = apply { this.emailEnabled = enabled }
8093

8194
/** SMS to create or transfer onto this user at login. Must be E.164 (e.g. +14155552671). */
8295
fun setSms(sms: String?) = apply { this.sms = sms }
8396

97+
/**
98+
* Subscription status for the SMS number. Subscribes when true, unsubscribes when false.
99+
* Unset sends no status, so a number that already exists keeps the status it has,
100+
* unless a pending [com.onesignal.user.IUserManager.addSms] for it sends one.
101+
* Ignored without [setSms]. Cannot be returned to unset once called.
102+
*/
103+
fun setSmsEnabled(enabled: Boolean) = apply { this.smsEnabled = enabled }
104+
84105
/** Sets tags to apply at login. The map is copied. */
85106
fun setTags(tags: Map<String, String>) = apply { this.tags = tags.toMap() }
86107

@@ -103,8 +124,10 @@ class OneSignalUserProfile private constructor(builder: Builder) {
103124
tags: Map<String, String> = emptyMap(),
104125
aliases: Map<String, String> = emptyMap(),
105126
emailEnabled: Boolean? = null,
127+
smsEnabled: Boolean? = null,
106128
) = Builder().setEmail(email).setSms(sms).setTags(tags).setAliases(aliases).apply {
107129
if (emailEnabled != null) setEmailEnabled(emailEnabled)
130+
if (smsEnabled != null) setSmsEnabled(smsEnabled)
108131
}.build()
109132

110133
private fun redact(value: String?): String = if (value == null) "null" else "<set>"

‎OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/LoginUserOperation.kt‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) {
6767
setOptBooleanProperty(PROFILE_EMAIL_ENABLED, value)
6868
}
6969

70+
internal var smsEnabled: Boolean?
71+
get() = getOptBooleanProperty(PROFILE_SMS_ENABLED)
72+
set(value) {
73+
setOptBooleanProperty(PROFILE_SMS_ENABLED, value)
74+
}
75+
7076
internal var tags: Map<String, String>
7177
get() = decodeMap(getOptStringProperty(PROFILE_TAGS))
7278
set(value) {
@@ -100,6 +106,7 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) {
100106
this.email = profile.email?.takeIf { it.isNotBlank() }
101107
this.sms = profile.sms?.takeIf { it.isNotBlank() }
102108
this.emailEnabled = profile.emailEnabled
109+
this.smsEnabled = profile.smsEnabled
103110
this.tags = profile.tags
104111
this.aliases = profile.aliases
105112
}
@@ -108,12 +115,25 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) {
108115
internal fun hasProfileFields(): Boolean =
109116
!email.isNullOrBlank() || !sms.isNullOrBlank() || tags.isNotEmpty() || aliases.isNotEmpty()
110117

118+
/**
119+
* A status belongs to the address it was stated for. Keeping it across a change would
120+
* apply an opt-out the caller never asked for on the new address.
121+
*/
122+
private fun mergedStatus(
123+
sameAddress: Boolean,
124+
current: Boolean?,
125+
incoming: Boolean?,
126+
): Boolean? = if (sameAddress) incoming ?: current else incoming
127+
111128
internal fun mergeProfileFrom(other: LoginUserOperation) {
112129
if (!other.email.isNullOrBlank()) {
130+
emailEnabled = mergedStatus(sameSubscriptionAddress(other.email, email, isEmail = true), emailEnabled, other.emailEnabled)
113131
email = other.email
114-
emailEnabled = other.emailEnabled
115132
}
116-
if (!other.sms.isNullOrBlank()) sms = other.sms
133+
if (!other.sms.isNullOrBlank()) {
134+
smsEnabled = mergedStatus(sameSubscriptionAddress(other.sms, sms, isEmail = false), smsEnabled, other.smsEnabled)
135+
sms = other.sms
136+
}
117137
if (other.tags.isNotEmpty()) tags = tags + other.tags
118138
if (other.aliases.isNotEmpty()) aliases = aliases + other.aliases
119139
}
@@ -134,12 +154,20 @@ class LoginUserOperation() : Operation(LoginUserOperationExecutor.LOGIN_USER) {
134154
}
135155
}
136156

157+
/** Mailboxes are case-insensitive, phone numbers are not. One rule so every caller dedupes alike. */
158+
internal fun sameSubscriptionAddress(
159+
left: String?,
160+
right: String?,
161+
isEmail: Boolean,
162+
): Boolean = if (isEmail) left.equals(right, ignoreCase = true) else left == right
163+
137164
internal fun reservedLoginAliasLabel(label: String): Boolean =
138165
label.isBlank() || label == IdentityConstants.ONESIGNAL_ID || label == IdentityConstants.EXTERNAL_ID
139166

140167
private const val PROFILE_EMAIL = "profileEmail"
141168
private const val PROFILE_EMAIL_ENABLED = "profileEmailEnabled"
142169
private const val PROFILE_SMS = "profileSms"
170+
private const val PROFILE_SMS_ENABLED = "profileSmsEnabled"
143171
private const val PROFILE_TAGS = "profileTags"
144172
private const val PROFILE_ALIASES = "profileAliases"
145173

‎OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginProfileApplier.kt‎

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.onesignal.user.internal.backend.SubscriptionObjectType
88
import com.onesignal.user.internal.identity.IdentityModelStore
99
import com.onesignal.user.internal.operations.LoginUserOperation
1010
import com.onesignal.user.internal.operations.reservedLoginAliasLabel
11+
import com.onesignal.user.internal.operations.sameSubscriptionAddress
1112
import com.onesignal.user.internal.properties.PropertiesModel
1213
import com.onesignal.user.internal.properties.PropertiesModelStore
1314
import com.onesignal.user.internal.subscriptions.SubscriptionModel
@@ -21,23 +22,53 @@ internal object LoginProfileApplier {
2122

2223
fun isProfileSubscriptionKey(key: String): Boolean = key == PROFILE_EMAIL_KEY || key == PROFILE_SMS_KEY
2324

25+
/**
26+
* [profileSubscription] receives the grouped entry for the same address when there is one, and
27+
* returns null to leave it as it is.
28+
*/
2429
fun addSubscriptions(
2530
op: LoginUserOperation,
2631
subscriptions: Map<String, SubscriptionObject>,
27-
buildProfileSubscription: (SubscriptionObjectType, String) -> SubscriptionObject,
32+
profileSubscription: (SubscriptionObjectType, String, SubscriptionObject?) -> SubscriptionObject?,
2833
): Map<String, SubscriptionObject> {
2934
val mutable = subscriptions.toMutableMap()
30-
val email = op.email?.takeIf { it.isNotBlank() }
31-
if (email != null && mutable.values.none { it.type == SubscriptionObjectType.EMAIL && it.token == email }) {
32-
mutable[PROFILE_EMAIL_KEY] = buildProfileSubscription(SubscriptionObjectType.EMAIL, email)
33-
}
34-
val sms = op.sms?.takeIf { it.isNotBlank() }
35-
if (sms != null && mutable.values.none { it.type == SubscriptionObjectType.SMS && it.token == sms }) {
36-
mutable[PROFILE_SMS_KEY] = buildProfileSubscription(SubscriptionObjectType.SMS, sms)
35+
for ((type, token, profileKey) in profileAddresses(op)) {
36+
val grouped =
37+
mutable.entries.firstOrNull {
38+
it.value.type == type &&
39+
sameSubscriptionAddress(it.value.token, token, isEmail = type == SubscriptionObjectType.EMAIL)
40+
}
41+
val subscription = profileSubscription(type, token, grouped?.value) ?: continue
42+
mutable[grouped?.key ?: profileKey] = subscription
3743
}
3844
return mutable
3945
}
4046

47+
private fun profileAddresses(op: LoginUserOperation): List<Triple<SubscriptionObjectType, String, String>> =
48+
buildList {
49+
op.email?.takeIf { it.isNotBlank() }?.let { add(Triple(SubscriptionObjectType.EMAIL, it, PROFILE_EMAIL_KEY)) }
50+
op.sms?.takeIf { it.isNotBlank() }?.let { add(Triple(SubscriptionObjectType.SMS, it, PROFILE_SMS_KEY)) }
51+
}
52+
53+
fun withStatus(
54+
subscription: SubscriptionObject,
55+
enabled: Boolean,
56+
): SubscriptionObject =
57+
SubscriptionObject(
58+
id = subscription.id,
59+
type = subscription.type,
60+
token = subscription.token,
61+
enabled = enabled,
62+
notificationTypes = if (enabled) SubscriptionStatus.SUBSCRIBED.value else SubscriptionStatus.UNSUBSCRIBE.value,
63+
sdk = subscription.sdk,
64+
deviceModel = subscription.deviceModel,
65+
deviceOS = subscription.deviceOS,
66+
rooted = subscription.rooted,
67+
netType = subscription.netType,
68+
carrier = subscription.carrier,
69+
appVersion = subscription.appVersion,
70+
)
71+
4172
/**
4273
* The backend wins where it spoke, the request fills the rest. Never deletes: Create User can
4374
* echo a partial body, and dropping keys it omitted would discard state we just sent.
@@ -70,11 +101,12 @@ internal object LoginProfileApplier {
70101
}
71102
}
72103

104+
/** [optedIn] null means the caller stated no status, so an existing record keeps the one it has. */
73105
fun persistSubscription(
74106
backend: SubscriptionObject,
75107
fallbackToken: String?,
76108
subscriptionsModelStore: SubscriptionModelStore,
77-
optedIn: Boolean = true,
109+
optedIn: Boolean?,
78110
) {
79111
val id = backend.id
80112
val token = backend.token ?: fallbackToken
@@ -89,17 +121,30 @@ internal object LoginProfileApplier {
89121
}
90122
if (existing != null) {
91123
existing.setStringProperty(SubscriptionModel::id.name, id, ModelChangeTags.HYDRATE)
124+
if (optedIn != null) applyStatus(existing, optedIn)
92125
return
93126
}
94127
val model = SubscriptionModel()
95128
model.id = id
96129
model.type = type
97130
model.address = token
98-
model.status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE
99-
model.optedIn = optedIn
131+
applyStatus(model, optedIn ?: true)
100132
subscriptionsModelStore.add(model, ModelChangeTags.HYDRATE)
101133
}
102134

135+
/**
136+
* HYDRATE, not NORMAL: this records what Create User already applied, and a NORMAL write on an
137+
* attached model would make [ModelStoreListener] enqueue a redundant subscription update.
138+
*/
139+
fun applyStatus(
140+
model: SubscriptionModel,
141+
optedIn: Boolean,
142+
) {
143+
val status = if (optedIn) SubscriptionStatus.SUBSCRIBED else SubscriptionStatus.UNSUBSCRIBE
144+
model.setEnumProperty(SubscriptionModel::status.name, status, ModelChangeTags.HYDRATE)
145+
model.setBooleanProperty(SubscriptionModel::optedIn.name, optedIn, ModelChangeTags.HYDRATE)
146+
}
147+
103148
fun subscriptionType(type: SubscriptionObjectType?): SubscriptionType? =
104149
when (type) {
105150
SubscriptionObjectType.EMAIL -> SubscriptionType.EMAIL

0 commit comments

Comments
 (0)