Skip to content

feat: [SDK-5024] add composite login(externalId, profile) as one Create User POST - #2743

Open
abdulraqeeb33 wants to merge 10 commits into
ar/sdk-4988from
ar/sdk-5024
Open

abdulraqeeb33 wants to merge 10 commits into
ar/sdk-4988from
ar/sdk-5024

Conversation

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Add login(externalId, profile) so email, SMS, tags, and aliases go out on a single Create User upsert, and return HTTP 4xx on the waiting OneSignalResult.

Details

Motivation

Identity-only login(externalId) cannot attach profile fields. Composite login needs those fields on one Create User POST (upsert by external_id) so a bad SMS/email cannot freeze the op repo, and the suspend caller gets the backend body instead of a generic "did not complete".

Stacked on #2710 (ar/sdk-4988). Parent: SDK-5023. Types/signature: SDK-5024. One POST: SDK-5025.

Scope

  • New OneSignalUserProfile (email, phoneNumber in E.164, tags, aliases) plus Java Builder. JWT stays a separate argument. externalId stays the login key.
  • Identity-only login / loginSuspend are unchanged.
  • Same externalId with no profile fields is still a no-op. Same externalId with profile fields upserts.
  • Composite 400/409 drop the op (FAIL_NORETRY) instead of pausing the repo. Identity-only 4xx still pause.
  • enqueueAndAwaitResult carries HTTP status/body so composite login returns OneSignalResult.failure(BACKEND_ERROR, message=body, backendCode=HTTP status). Catalog codes are not parsed yet.
  • Demo: LOGIN WITH PROFILE dialog.

Not in this PR: persistence (SDK-5026), telemetry (SDK-5027), a login callback, catalog backendCode.

Testing

Unit testing

Executor tests cover identity-only payload unchanged, profile aliases/email/SMS/tags on Create User, reserved alias skip, 400/409 FAIL_NORETRY with HTTP fields, 401, and hydration. OperationRepo wakes enqueueAndAwaitResult with status/body. LoginHelper, UserBackendService nested tags, and profile builder tests added.

Manual testing

Demo app on a physical device. Composite login with a non-E.164 SMS (4129089471) returns HTTP 400 Invalid token format for device type SMS, logs the body, returns OneSignalResult.failure, and does not pause the op repo. Retry with +14129089471 succeeds.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

Made with Cursor

AR Abdul Azeez and others added 2 commits September 9, 2026 14:14
…te User POST

Apply email, SMS, tags, and aliases in a single upsert so identity-only login stays unchanged, and surface HTTP 4xx on the waiting OneSignalResult instead of pausing the op repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team as a code owner September 9, 2026 19:14
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff 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

  • IOneSignal.kt: 2/2 touched executable lines (100.0%) (10 touched lines in diff)
  • OneSignal.kt: 0/3 touched executable lines (0.0%) (12 touched lines in diff)
    • 3 uncovered touched lines in this file
  • OneSignalResultData.kt: 8/9 touched executable lines (88.9%) (22 touched lines in diff)
  • OneSignalUserProfile.kt: 23/34 touched executable lines (67.6%) (74 touched lines in diff)
    • 11 uncovered touched lines in this file
  • IOperationExecutor.kt: 3/3 touched executable lines (100.0%) (3 touched lines in diff)
  • IOperationRepo.kt: 9/9 touched executable lines (100.0%) (17 touched lines in diff)
  • OperationMetadata.kt: 5/5 touched executable lines (100.0%) (10 touched lines in diff)
  • OperationRepo.kt: 56/58 touched executable lines (96.6%) (82 touched lines in diff)
  • OperationRepoIvExtensions.kt: 3/3 touched executable lines (100.0%) (5 touched lines in diff)
  • OperationWaitResultExtensions.kt: 1/1 touched executable lines (100.0%) (7 touched lines in diff)
  • OneSignalImp.kt: 3/20 touched executable lines (15.0%) (40 touched lines in diff)
    • 17 uncovered touched lines in this file
  • LoginHelper.kt: 37/37 touched executable lines (100.0%) (71 touched lines in diff)
  • UserBackendService.kt: 2/2 touched executable lines (100.0%) (3 touched lines in diff)
  • LoginUserOperation.kt: 47/49 touched executable lines (95.9%) (92 touched lines in diff)
  • LoginProfileApplier.kt: 41/45 touched executable lines (91.1%) (97 touched lines in diff)
  • LoginUserOperationExecutor.kt: 55/56 touched executable lines (98.2%) (86 touched lines in diff)

Overall (aggregate gate)

295/336 touched executable lines covered (87.8% — requires ≥ 80%)

Per-file detail (informational; gate is aggregate above):

  • OneSignal.kt: 0.0% (3 uncovered touched lines)

  • OneSignalUserProfile.kt: 67.6% (11 uncovered touched lines)

  • OneSignalImp.kt: 15.0% (17 uncovered touched lines)

📥 View workflow run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model review (Claude Opus 5, GPT 5.6 Sol, Cursor Grok 4.6) on the composite login(externalId, profile) change. Substantive diff is +1,331/−64 (1,333 after excluding generated core.api).

Act on

  1. Composite 400/409 drops the whole grouped batch (3/3). FAIL_NORETRYdropAndWake(ops) removes login and grouped push create/transfer ops. After switchUser has already replaced local identity, a bad email/SMS can strand the user on a local OneSignal ID with no push create. Fail the waiter without dropping followers, or revert the switch.
  2. Same-user composite login can return success before HTTP (3/3). Dedupe by onesignalId wakes a second waiter with OperationWaitResult(true) when the queued op already has a waiter. Combined with first-wins mergeProfileFrom on email/phone, a later login(id, profile) reports success while its address is discarded, and a later 400 never reaches that caller.
  3. Same-user context is captured off the login lock (2/3). contextForCurrentUser and loginDataFromStores read live identity/subscription stores. Concurrent login/logout can pair profile A with user B, or return {externalId: A, onesignalId: B}.

Consider

  • Full profile plus raw HTTP bodies are logged (3/3).
  • Reserved-only aliases still count as profile fields, so same-user login is no longer a no-op and 4xx uses FAIL_NORETRY (2/3).
  • Any profile field skips the anonymous→identified SetAlias merge (Opus).
  • persistProfileSubscription hardcodes SUBSCRIBED/optedIn=true; match-by-type can bind the wrong email/SMS (Opus/Grok).
  • backendCode is the HTTP status, not a catalog code; 404/410 still pauses the repo (Opus).

Noted / dismissed
OneSignalUserProfile as a public data class (copy/componentN API surface), constructor binary breaks on ExecutionResponse/LoginUserOperation, hydrateProfile writing the request not the response, and catalog backendCode parsing — last is explicitly out of scope.

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment on lines +290 to +294
NetworkUtils.ResponseStatusType.INVALID,
NetworkUtils.ResponseStatusType.CONFLICT,
->
if (createUserOperation.hasProfileFields()) {
backendExecutionResponse(ExecutionResult.FAIL_NORETRY, ex)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Act on (3/3): Composite 400/409 returns FAIL_NORETRY, and OperationRepo then dropAndWakes the entire grouped batch — not just this login.

LoginUserOperation, CreateSubscriptionOperation, and TransferSubscriptionOperation share createComparisonKey = "$appId.User.$onesignalId". After a new-user switchUser, the push create is in that group. A malformed SMS/email therefore drops the device subscription create while local identity already shows the new externalId and a still-local onesignalId.

A retry takes the same-user path and POSTs profile-only, so push never reattaches. Identity-only 4xx still pause and keep the ops; this path should fail the waiter without dropping grouped followers, or roll the local switch back.

…Repo

LoginData lookup lives on LoginHelper, profile emptiness on OneSignalUserProfile, nested properties use JSONUtils.mapToJson, and wait results are an ExecutionResponse extension.

Co-authored-by: Cursor <cursoragent@cursor.com>
)

OutlineButton(
text = "LOGIN WITH PROFILE",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be added to iOS and all the other wrappers

AR Abdul Azeez and others added 5 commits September 9, 2026 14:43
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…etadata

Failed composite login dropped the op after switching identity, so a retry
no-opped. Multi-waiter dedupe now wakes every waiter with the real result,
and LoginData comes from typed wait metadata instead of live stores.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The repo stores heterogeneous waiters, so T was erased and cast back. A sealed
payload keeps enqueue-and-wait reusable without a typed lie on IOperationRepo.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george

Copy link
Copy Markdown
Contributor

Potential issues:

  • Composite 4xx failures drop grouped push subscription operations, so retries may create users without device subscriptions.
  • Login can hang after the operation repo pauses because new waiters attach to an operation that will not execute.
  • Concurrent composite logins merge conflicting profiles and return unrelated subscription IDs.
  • Repeated identity-only login can issue a duplicate Create User request.
  • core.api is stale and needs regeneration.
  • Spotless currently fails on import ordering.

…, Any?>

createUser already only sent timezone, language, and tags. Use the same typed
properties object as updateUser so tags are not stuffed through Any?.

Co-authored-by: Cursor <cursoragent@cursor.com>
Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}")
}

suspend fun loginUserWithProfile(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe should be loginWithProfile? maybe could vote on it
not blocking

Composite FAIL_NORETRY drops only the login. Paused and in-flight
duplicates attach without a second Create User.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

On 3: leaving waiters on the shared result.

We want composite login to match the existing login methods (one Create User per onesignalId, attached waiters get that result) and not add a per-caller profile path.

@fadi-george

Copy link
Copy Markdown
Contributor

still a potential issue:
inFlightLogins remains populated during the 5-second post-create delay, but waiters are woken before that delay. A duplicate login during this window attaches too late and can hang forever. The new test only covers duplicates before execution completes.

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fadi's post-create point is still open, and I reproduced the 4xx case on a device.

  1. Second login hangs (Fadi's point).

  2. Profile silently dropped. A login(id, profile) that arrives while a plain login(id) for the same user is in flight reuses that result, so the email and phone are never sent.

  3. User stuck after a 400. The login is dropped, but the push subscription and anything added afterwards stay queued under a local id that is never resolved, so nothing for this user is sent again. Reproduced by calling login("1", profile).

  4. Anonymous user no longer merged. With a profile attached, a new external id from an anonymous user creates a second user and leaves the anonymous one behind with its tags. May need to be called in our docs and PR as behavior change.

  5. Repeat logins with the same email and phone. The backend omits giving ID for unchanged subscriptions, so a repeat login logs an error. It is common for a client to always login with the profile they have on hand for the user, which will trigger this error log often. Reproduced by calling login(user, existing email or sms)

*/
class OneSignalUserProfile @JvmOverloads constructor(
email: String? = null,
phoneNumber: String? = null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since our API is currently OneSignal.User.addSms(sms: String), we should be consistent and maybe do sms or smsNumber instead of phoneNumber?

Logging.error("LoginUserOperationExecutor.createUser response is missing a local subscription model for ${pair.first}")
}
} else {
Logging.error("LoginUserOperationExecutor.createUser response is missing subscription data for ${pair.first}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normally in the past this would fire for a genuinely missing push sub but now with the profile including email and/or sms regularly, this will log an error with __profile_email or __profile_sms if that email/sms already belonged on this user. Similar to SDK-5181

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants