Repository navigation
feat: [SDK-5623] let composite login set email and SMS subscription status - #2822
Conversation
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)73/76 touched executable lines covered (96.1% — requires ≥ 80%) |
The caller states subscription intent per channel. Sending enabled true was dropped before, so an address that had opted out could not be resubscribed. Omitting still leaves an existing address untouched. Co-authored-by: Cursor <cursoragent@cursor.com>
Blank states no intent, so the login payload omits the status. Co-authored-by: Cursor <cursoragent@cursor.com>
ffb217e to
24777a5
Compare
|
Title it a fix? I'd imagine we need to make 5.11.1 with this. |
|
hmm, thats a good point. i thought the release merge was pending. also its not a bug fix, its more like a new minor feature we are allowing the user to pass the flag value. |
|
Or will need to go back to old pr, update pr notes. |
that would be ideal. for now, please review the PR. i will work on merging and updating the notes etc |
|
I feel we should just make loginWithProfile private if we keep finding stuff but could do that if this keeps going till Friday. Still reviewing. |
A batched addEmail or addSms already carries subscribed, and the deduping in addSubscriptions meant the profile status never reached the wire for that address. Also stop a null status from clearing an earlier one on merge, and apply the status to a local record that already exists. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks, one more finding:
|
Preserving an earlier status across a merge carried it onto a different address, which would unsubscribe an address the caller never set a status for. Keep it only when the address is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Good catch, and that one was caused by my fix for your earlier merge finding. Reproduced it as a failing test for both channels before changing anything. Fixed in 51c27ac, using the shape you suggested, pulled into a helper since both channels need it: private fun mergedStatus(sameAddress: Boolean, current: Boolean?, incoming: Boolean?): Boolean? =
if (sameAddress) incoming ?: current else incomingThe status comparison runs before the address is reassigned, otherwise Five merge tests now: same address keeps the earlier status, same address takes an explicit later one, a changed address drops the earlier status for email and for SMS, and a changed address still takes an explicit status. One note, the address compare is exact. A merge from |
|
Changes look good. We should retest:
Currently unsubscribed, enabled not set → stays unsubscribed
enabled omitted → subscribed |
Writing status on an attached model with the default tag made the store listener enqueue an Update Subscription for a status Create User had already applied. Also follow that status on the local record of a grouped operation, matched by address so a sibling of the same channel is untouched. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Non-blocking: this matches email case-insensitively, but addSubscriptions (LoginProfileApplier.kt:35) still matches the grouped entry with ==. With addEmail("A@b.com") plus profile "a@b.com" and emailEnabled = false, both entries go out (one true, one false) and then this marks A@b.com's local row unsubscribed even though the wire sent true for it. Could addSubscriptions use the same ignoreCase rule for email? |
…does An email differing only by case missed the grouped match, so Create User carried two objects for one mailbox with opposite statuses. One shared comparison now backs the grouped lookup, the merge, and hydration. Also qualify the KDoc, since a pending addEmail still sends a status of its own, and log the points where a stated status is overridden. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Retested all nine on I paired each case with the matching SMS case, so this covers 18 outcomes, nine per channel. That also closes the "SMS never exercised on device" gap. 1. The address already exists in the app Seeded before each login, then transferred in by composite login. Verified the seed users end up holding nothing, so these really were existing-address cases and not new ones.
2. The address is created inside the login method
Every row is identical for email and SMS. One thing to flag on 2.1. The end state is subscribed as specified, but External IDs are |
|
Thanks I also ran a test matrix on an emulator against this build (1f5d1fd), driving the Login With Profile dialog with Maestro. For each row I checked the email/SMS entry in the POST /users payload from logcat and the stored state from the API.
|
Description
One Line Summary
Composite login lets the caller set subscription status per channel, so the SDK stops deciding it for them.
Details
Fixes SDK-5623. Follow-up to #2815 and #2816, both merged.
Motivation
The SDK gives customers no way to set email or SMS status. Today the outcome depends on whether
addEmail()happened to batch withlogin(), which depends on network timing and queue state. I reproduced both sides on an emulator with two addresses seeded identically unsubscribed and the sameaddEmail()payload:POST /usersPOST /users/by/onesignal_id/.../subscriptionsenabled: true,notification_types: 1enabled: true,notification_types: 1true/1, resubscribedfalse/-99, unchangedSame method, same body, two outcomes. Jon F sized the exposure at 92.4M unsubscribed email addresses and 12.5M unsubscribed phone numbers across apps using mobile push with external IDs. Rather than pick whichever side of that race wins more often, the caller states intent. Decision thread.
Scope
OneSignalUserProfilegainssmsEnabledalongsideemailEnabled, both on the Kotlin properties and the Java builder. The demo app gains matching inputs on the Login With Profile dialog. Each flag maps straight to the wire for its own channel:enablednotification_typestruetrue1falsefalse-2Both flags are persisted on
LoginUserOperationand carried throughmergeProfileFrom, so a merged login does not lose them. A status is scoped to the address it was stated for: when two composite logins merge, a null status on the later one keeps the earlier status for the same address, and never carries it onto a different address. LocaloptedInhydration now reads the flag for whichever channel the backend returned instead of email only, and is applied to a local record that already exists rather than only to a new one.A batched
addEmailoraddSmsfor the same address already carries a subscribed status, andaddSubscriptionsdedupes on type plus token, so the profile entry was skipped entirely in that case. An explicit profile status now overrides the grouped operation's status for that address, on the wire and on the local record, matched by address so a sibling address of the same channel is untouched. An unset profile status leaves the grouped operation untouched.Status is written to local models with
ModelChangeTags.HYDRATE. It records what Create User already applied, so aNORMALwrite on an attached model would makeModelStoreListenerenqueue a redundant Update Subscription.Address matching goes through one shared
sameSubscriptionAddress, case-insensitive for email and exact for SMS, used by the grouped lookup, the merge, and hydration. Before that the grouped lookup compared exactly, soaddEmail("Bob@Example.com")batched with a profile forbob@example.comput two objects for one mailbox in the same request with opposite statuses.Identity-only
login(externalId)and the granularaddEmail/addSmsmethods are unchanged.The detekt baseline gains one
LongParameterListentry, because the internal Kotlin shorthand for building a profile in tests now takes six parameters. I added only that entry rather than regenerating the baseline, which would have pulled in unrelated drift.Why
-2and not-99-2isUNSUBSCRIBE. The subscriptions docs define it as user opted out, set byoptOut()in the SDK or byenabled: falsevia the API.-99is reserved for never asked, and it is also what a brand-new subscribed address gets, so it cannot express opt-out intent.Omitting
notification_typeson anenabled: falseis worse than wrong: turbine then assigns-31(DISABLED_FROM_REST_API) itself, which misattributes an SDK opt-out to the REST API. Measured:enabled: falseonlyfalse/-99enabled: falseonlyfalse/-31enabled: false,notification_types: -2false/-2Requested behavior vs. what is coded
Every row was confirmed against the API on a test app. The SDK sends the payload; the backend produces the outcome.
The address already exists in the app
enablednot set, stays unsubscribedfalse/-99enabled: true, becomes subscribedtrue,1true/1enabled: false, stays unsubscribedfalse,-2false/-2enablednot set, stays subscribedtrue/-99enabled: true, stays subscribedtrue,1true/1enabled: false, becomes unsubscribedfalse,-2false/-2The address is created inside the login call
enabledomitted, subscribedtrue/-99, subscribedenabled: true, subscribedtrue,1true/1enabled: false, unsubscribedfalse,-2false/-2Before this PR, 1.2 was the broken case:
truewas dropped on the wire, so an opted-out address could not be resubscribed. 1.5 and 2.2 landed on the right end state by accident. None of the SMS cases existed, because there was nosmsEnabled.One caveat on 2.1. The end state is subscribed as requested, but
notification_typesis-99rather than1. Perseus treats those differently, since-99is never asked and1emitsoptin.first_accepts. That needs the turbine change to default a new address with no status to subscribed, and is tracked separately.Testing
Unit testing
LoginUserOperationExecutorTestspins the six payload shapes, three flag states across two channels:enabledand nonotification_types, for email and SMS together.truesendsenabled: truewithnotification_types: 1, for email and for SMS.falsesendsenabled: falsewithnotification_types: -2, for email and for SMS, and hydrates the local subscription as opted out.LoginUserOperationJSON round trip.CreateSubscriptionOperationfor the same address.mergeProfileFromkeeps an earlier status when the later login states none, and takes the later one when it does.mergeProfileFromdrops the earlier status when the address changes, for email and SMS, and still takes an explicit status for a new address.SubscriptionModelStoreListenerover a mock operation repo asserts that hydrating a status enqueues nothing.trueresubscribes an unsubscribed local record, and an SMS status with no SMS address is ignored.OneSignalUserProfileTestscoverssmsEnabledbeing independent ofemailEnabledand the Java builder setting each channel.Unit tests can only assert what the SDK sends. The nine outcomes above are backend behavior and were confirmed by API call, not by unit test.
I reverted each production change and confirmed the matching tests go red: both "enabled is true" tests, the grouped-subscription override, the
mergeProfileFrompreservation, and the existing-local-record unsubscribe.Manual testing
The nine rows above were run against a test app through the API.
The demo app now has
Email statusandSMS statusinputs on the Login With Profile dialog, where blank means no intent. All nine cases were then run end to end on an emulator against this build (onesignal/android/051101), each paired with the matching SMS case, so 18 outcomes, nine per channel. Every row matched, and email and SMS behaved identically.For group 1 the address was seeded first and transferred in by the login. The seed users were confirmed to hold nothing afterwards, so those were genuinely existing-address cases.
false/-2false/-2truefalse/-2true,1true/1falsefalse/-2false,-2false/-2true/1true/1truetrue/1true,1true/1falsetrue/1false,-2false/-2true/-99truetrue,1true/1falsefalse,-2false/-2External IDs
dev11throughdev16anddev21throughdev23on app77e32082. Logs in the retest comment on this PR.The one row worth noting is 2.1: subscribed as specified, but at
notification_types: -99rather than1, since nothing is sent and the backend defaults a new address to "never asked". That needs the turbine default change and cannot be fixed by the SDK sending a status it was not given.The batched versus separate reproduction in the Motivation section was run on an emulator against a local build of the merged #2815 and #2816.
Affected code checklist
Checklist
Overview
Testing
Final pass