Skip to content

feat: identity verification 5.8#2599

Open
nan-li wants to merge 45 commits intomainfrom
feat/identity_verification_5.8
Open

feat: identity verification 5.8#2599
nan-li wants to merge 45 commits intomainfrom
feat/identity_verification_5.8

Conversation

@nan-li
Copy link
Copy Markdown
Contributor

@nan-li nan-li commented Mar 30, 2026

Description

One Line Summary

Add Identity Verification (JWT-based) support to the Android SDK, gating API operations behind server-issued JWTs when enabled.

Details

Motivation

Identity Verification allows app developers to authenticate users with JWTs before the SDK sends operations to the OneSignal backend. This prevents unauthorized API calls by requiring a valid JWT for all user-scoped operations when the feature is enabled server-side via the jwt_required remote param.

Scope

Public API additions:

  • OneSignal.login(externalId, jwtBearerToken) — accepts an optional JWT on login
  • OneSignal.updateUserJwt(externalId, token) — supply a fresh JWT when the current one expires
  • OneSignal.addUserJwtInvalidatedListener(listener) / removeUserJwtInvalidatedListener(listener) — get notified when a JWT is invalidated (e.g. 401 from backend) so the app can provide a new one
  • IUserJwtInvalidatedListener / UserJwtInvalidatedEvent — listener interface and event class

Internal changes:

  • JwtTokenStore — persistent store mapping externalId → JWT, backed by SharedPreferences
  • IdentityVerificationService — reacts to the jwt_required remote param arriving via config HYDRATE; purges anonymous operations when IV is enabled; wires JWT invalidation callbacks
  • OperationRepo — gates operation execution on valid JWT when IV is enabled (hasValidJwtIfRequired); handles FAIL_UNAUTHORIZED by invalidating the JWT, re-queuing operations, and notifying the developer; stamps externalId synchronously at enqueue time to avoid race conditions
  • LogoutHelper — when IV is enabled, disables the push subscription internally and suppresses the backend login operation (the device-scoped user is local-only)
  • ConfigModelStoreListener / ParamsBackendService — reads jwt_required from remote params, defaults to false when absent
  • HTTP layer — attaches Authorization: Bearer <jwt> header to requests when IV is enabled
  • In-App Messaging — uses alias-based fetch endpoint and includes JWT in IAM backend calls
  • Operation executors — propagate externalId to result operations and attach JWT to backend requests

What is NOT affected:

  • Apps without Identity Verification enabled behave exactly as before — jwt_required defaults to false and no JWT gating occurs
  • Notification display, open handling, push processing, and confirm deliveries are unchanged

Testing

Unit testing

WIP Adding more testing

  • OperationRepoTests — added tests for FAIL_UNAUTHORIZED handling (identified user fires handler + retries; anonymous user drops operations), and synchronous externalId stamping before async dispatch
  • LoginUserOperationExecutorTests — updated for identity verification parameters
  • LogoutHelperTests — covers IV-enabled logout flow
  • LoginHelperTests — covers IV-enabled login flow with JWT

Manual testing

Tested with the demo app on an Android emulator (Pixel 7, API 35):

  • Login with valid JWT → operations execute normally
  • Login with expired JWT → 401 triggers IUserJwtInvalidatedListener, call updateUserJwt → operations resume
  • Logout with IV enabled → push subscription disabled, new anonymous user created locally
  • App without IV enabled → SDK operates normally, no regression

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

@nan-li nan-li changed the title Feat/identity verification 5.8 Feat: identity verification 5.8 Mar 30, 2026
@nan-li nan-li changed the title Feat: identity verification 5.8 feat: identity verification 5.8 Mar 30, 2026
@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from 4509a8f to 457b745 Compare March 30, 2026 09:35
@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch 4 times, most recently from 113e96b to 00d3799 Compare March 30, 2026 23:05
@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Mar 31, 2026

@claude re-review

@claude
Copy link
Copy Markdown

claude Bot commented Mar 31, 2026

Claude finished @nan-li's task in 3m 55s —— View job


Re-review: feat/identity_verification_5.8

  • Read current state of files related to previous review findings
  • Verify fixes for the 4 critical issues flagged
  • Look for any remaining or new issues
  • Post updated review

All four critical issues flagged in the previous review have been addressed. Here's a status breakdown:

Previously Critical — Now Fixed ✅

1. Runtime 401 not firing IUserJwtInvalidatedListener (OperationRepo.kt:292-297)

Fixed correctly. OperationRepo now holds a _jwtInvalidatedHandler: ((String) -> Unit)? that is set in IdentityVerificationService.start():

_operationRepo.setJwtInvalidatedHandler { externalId ->
    _userManager.fireJwtInvalidated(externalId)
}

And in the FAIL_UNAUTHORIZED branch, _jwtInvalidatedHandler?.invoke(externalId) is called after _jwtTokenStore.invalidateJwt(externalId). The queue correctly re-enqueues the operations, and the developer receives the callback to provide a fresh JWT. When updateUserJwt is called, forceExecuteOperations() wakes the queue — the full round trip is wired.

2. Logout race condition — UpdateSubscriptionOperation stamped with null externalId (OperationRepo.kt:165-168)

Fixed correctly. stampExternalId(operation) is now called synchronously in enqueue() before scope.launch { internalEnqueue(...) }. Since SubscriptionModelStoreListener.getUpdateOperation() fires synchronously when isDisabledInternally = true is set — and createAndSwitchToNewUser() has not yet run at that point — _identityModelStore.model.externalId is still the old user's ID when it's captured.

3. JwtTokenStore.ensureLoaded() throws uncaught JSONException (JwtTokenStore.kt:32-42)

Fixed correctly. The JSON parsing is wrapped in try { } catch (e: Exception) and isLoaded = true is placed unconditionally at the end of ensureLoaded(). Corrupted SharedPreferences data is now logged and discarded gracefully instead of causing an infinite throw loop.

4. useIdentityVerification stays null for non-IV apps, permanently blocking the queue (ParamsBackendService.kt:87)

Fixed correctly. The field now defaults to false when jwt_required is absent from the backend response:

useIdentityVerification = responseJson.safeBool("jwt_required") ?: false

Non-IV apps get false after HYDRATE, getNextOps() proceeds, and IdentityVerificationService.onModelReplaced() still calls forceExecuteOperations() to wake the queue.


Remaining Observations (non-blocking)

A. _jwtInvalidatedHandler is not @Volatile (OperationRepo.kt:47)

private var _jwtInvalidatedHandler: ((String) -> Unit)? = null

This field is written from IdentityVerificationService.start() (likely the main thread) and read from executeOperations() running on OSOperationRepoScope. In practice this is safe because start() runs before any operations execute, but the JMM doesn't guarantee visibility without @Volatile or a synchronisation point. Worth annotating for correctness. Fix this →

B. Expired JWT at logout time blocks the unsubscribe operation (LogoutHelper.kt)

When IV=true and a 401 has already invalidated the JWT before the user calls logout(), the UpdateSubscriptionOperation (to disable the push subscription) gets a null result from _jwtTokenStore.getJwt(externalId) and is permanently held by hasValidJwtIfRequired. The developer would need to call updateUserJwt with a fresh token to unblock it — but if they're logging out, they likely won't. The backend push subscription stays enabled for the logged-out user until the next cold start (when pruneToExternalIds cleans up the orphaned operation).

This is an edge case (JWT expired + user logs out before providing a refresh) and the overall security posture of the IV feature prevents worse outcomes, but it may be worth documenting or handling explicitly — for example, by skipping the JWT gate for the unsubscribe-on-logout operation. Not blocking the PR.


The implementation is solid. All critical issues are resolved and the approach is well-structured. The IdentityVerificationService / OperationRepo / JwtTokenStore responsibilities are cleanly separated.

@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Mar 31, 2026

@claude re-review

@claude
Copy link
Copy Markdown

claude Bot commented Mar 31, 2026

Claude finished @nan-li's task in 2m 39s —— View job


Re-review: feat/identity_verification_5.8

  • Read current state of key files
  • Verify fixes for the 2 observations from previous review
  • Look for any remaining or new issues
  • Post updated review

Both remaining observations from the previous re-review have been addressed. The PR is clean.

Previously Non-blocking — Now Fixed ✅

A. _jwtInvalidatedHandler not @Volatile (OperationRepo.kt:47)

Fixed in commit 76dd958. The field is now correctly annotated:

@Volatile
private var _jwtInvalidatedHandler: ((String) -> Unit)? = null

B. Expired JWT at logout blocks the push subscription disable (UpdateSubscriptionOperation.kt:89)

Fixed in commit 130aa16. The first approach (skip in LogoutHelper) was tried and reverted; the final solution is cleaner — it adds Operation.requiresJwt: Boolean = true as an open property on the base class and overrides it in UpdateSubscriptionOperation:

// Operation.kt:69
open val requiresJwt: Boolean get() = true

// UpdateSubscriptionOperation.kt:89
override val requiresJwt: Boolean get() = false

OperationRepo.hasValidJwtIfRequired() short-circuits on !op.requiresJwt, so subscription updates always proceed regardless of JWT state. This is backend-aligned — the subscription endpoint doesn't enforce JWT auth — and the executor still passes the JWT opportunistically when one is available (val jwt = lastOperation.externalId?.let { _jwtTokenStore.getJwt(it) }).


Minor Observation (no action needed)

The requiresJwt = false behavior on UpdateSubscriptionOperation has no dedicated test in OperationRepoTests. The behavior is implicitly covered by LogoutHelperTests, but a unit test verifying that an operation with requiresJwt = false executes when hasValidJwtIfRequired would otherwise block could add confidence. Given the simplicity of the change, this is not blocking.


The implementation is solid. All six issues identified across both review passes are resolved. The architecture is clean: IdentityVerificationService / OperationRepo / JwtTokenStore responsibilities are well-separated, the requiresJwt flag gives per-operation control over JWT gating, and the developer-facing callback round-trip is fully wired. Good to merge.

@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from 972eace to f6e4227 Compare March 31, 2026 08:49
@nan-li nan-li requested a review from a team March 31, 2026 16:01
Comment thread OneSignalSDK/detekt/detekt-baseline-core.xml
<CurrentIssues>
<ID>ComplexCondition:InAppMessagesManager.kt$InAppMessagesManager$!message.isTriggerChanged &amp;&amp; isMessageDisplayed &amp;&amp; (isTriggerOnMessage || isNewTriggerAdded &amp;&amp; isOnlyDynamicTriggers)</ID>
<ID>ComplexMethod:TriggerController.kt$TriggerController$private fun evaluateTrigger(trigger: Trigger): Boolean</ID>
<ID>ComplexMethod:InAppMessagesManager.kt$InAppMessagesManager$private suspend fun fetchMessages(rywData: RywData)</ID>
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.

we should break it down if its a complex method.

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.

Yes, I undid the detekt changes and then added them back in the last few commits with reasoning. I actually didn't end up making changes to in-app-messages in detekt.

Comment thread OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt Outdated
* @param externalId The external ID of the user whose token is being updated.
* @param token The new JWT bearer token.
*/
fun updateUserJwt(
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.

like i mentioned elsewhere, we need suspend methods for these

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.

added

@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch 3 times, most recently from ca64cdf to 258c8e6 Compare April 3, 2026 03:46
@nan-li nan-li requested a review from abdulraqeeb33 April 3, 2026 06:57
Copy link
Copy Markdown
Contributor

@abdulraqeeb33 abdulraqeeb33 left a comment

Choose a reason for hiding this comment

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

Lets try and do a bug bash on this today/tomorrow?

@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Apr 13, 2026

@claude review

@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from b445a01 to 17fc13b Compare April 14, 2026 00:55
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

The two bugs I flagged in the last pass (IAM rate-limit poisoning and JWT exposure in login() logs) have been fixed in the latest commit — thanks for addressing them. Given the security-sensitive nature of this feature (JWT-based identity gating across 86 files), a final human look before merge is warranted.

Extended reasoning...

Overview

This PR adds Identity Verification (JWT-based authentication gating) to the Android SDK, spanning 86 files including new public API, a new JwtTokenStore, OperationRepo changes, IAM fetch gating, and HttpClient JWT header injection.

Prior Review Rounds

  • Rounds 1–2 (late March): 6 bugs found and fixed.
  • Round 3 (2026-04-14): 4 new bugs filed as inline comments. The latest commit (82678a5 "Address Claude review bot feedback on IV PR") addresses all of them:
    • ✅ IAM rate-limit poisoning: Guard now checks both and , returning before is set.
    • ✅ JWT logging in / : Both now use .
    • ✅ FAIL_UNAUTHORIZED loop when IV=OFF: Gated on .
    • ✅ JWT in HttpClient logs: Authorization header filtered from logged request properties.
    • 403/401 conflation and stale ops at logout explicitly deferred by author (out of scope / by design).

Security Risks

The PR touches auth-critical paths: JWT storage/retrieval, JWT injection into HTTP headers, FAIL_UNAUTHORIZED error routing, and the public-facing / APIs. No new vulnerabilities found in this pass, but the breadth of the change means a human reviewer should give final sign-off.

Level of Scrutiny

This is production-facing security code that gates API calls behind developer-issued JWTs. The implementation looks correct across the key paths reviewed, but the sheer scope (86 files, new public API surface, threading concerns) means automated review alone is insufficient.

Other Factors

  • The LoginHelper pre-HYDRATE logic is sound: the null case falls through to IV=OFF behavior, and IdentityVerificationService cleans up on HYDRATE.
  • intentionally omits UNAUTHORIZED handling (requiresJwt=false by design; JWT is passed opportunistically).
  • Test infrastructure () correctly gates test suites from the IV hold path.

@nan-li nan-li force-pushed the feat/identity_verification_5.8 branch from 16f9650 to fbc53f2 Compare April 15, 2026 02:56
nan-li added 2 commits April 14, 2026 20:03
…ication nullable, OptionalHeaders.jwt

Foundational models and infrastructure for identity verification:

- Create JwtTokenStore: persistent Map<externalId, JWT> backed by SharedPreferences, supporting multi-user JWT storage with getJwt/putJwt/invalidateJwt/pruneToExternalIds
- Add var externalId to Operation base class so OperationRepo can stamp and gate operations per-user; remove redundant externalId from LoginUserOperation and TrackCustomEventOperation (same Model data-map key, no migration needed)
- Change ConfigModel.useIdentityVerification from Boolean to Boolean? (null = unknown, false = off, true = on) to eliminate race between operation processing and remote params
- Add jwt field to OptionalHeaders for passing Bearer tokens through HTTP layer
- Add PREFS_OS_JWT_TOKENS key to PreferenceOneSignalKeys

Made-with: Cursor
nan-li added a commit that referenced this pull request May 6, 2026
Same-user-id login with a fresh JWT (the documented post-401 refresh
path) was storing the new token but never calling forceExecuteOperations,
so ops parked by hasValidJwtIfRequired stayed deferred until something
else woke the queue. updateUserJwt already does this correctly; reference
#2599 LoginHelper does too. Match that design — drop redundant
if (jwtBearerToken != null) guards (putJwt no-ops on null) and call
forceExecuteOperations unconditionally on the same-id branch so the
queue drains as soon as the developer supplies a fresh token.

Extend the existing same-id+JWT test to verify forceExecuteOperations
fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Three review-driven fixes on the IV public-API surface:

1. OneSignal.kt: add @JvmStatic wrappers for updateUserJwt,
   addUserJwtInvalidatedListener, removeUserJwtInvalidatedListener, and
   updateUserJwtSuspend. Without these, the four new IOneSignal methods
   were unreachable from app code (the OneSignal object is the documented
   Java/Kotlin entry point and "implements IOneSignal in spirit"). Mirrors
   the convention from reference branch #2599.

2. OneSignalImp.kt: mask jwtBearerToken in login()/loginSuspend() DEBUG
   logs (...${jwtBearerToken?.takeLast(8)}). Pre-PR the parameter was a
   no-op (LoginHelper had a TODO) so the leak was theoretical; this PR
   wires the token through to JwtTokenStore.putJwt, so a live bearer
   credential now flows through Logging.log at DEBUG. updateUserJwt
   already masks; mirror that.

3. UserManager.kt: restore Logging.warn(msg, ex) form on the two new
   runCatching.onFailure handlers in fireJwtInvalidated and
   addJwtInvalidatedListener. Interpolating ${ex.message} drops the
   stack trace; Logging.warn already accepts a Throwable second arg
   that propagates through to log listeners and Otel — same pattern
   restored in JwtTokenStore via e76fb60.

Also refreshes detekt baseline for the new MagicNumber (takeLast(8)),
ConstructorParameterNaming (_jwtTokenStore on UserManager), TooManyFunctions
(UserManager now also implements IJwtUpdateListener), and UseCheckOrError
on the 4 new IllegalStateException throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
When jwt_required hydrates true and removeOperationsWithoutExternalId
drops the anon ops, surviving LoginUserOperations may still carry
existingOnesignalId pointing at the just-dropped anon user (the
merge-anon-into-identified link set by LoginHelper). Under IV that
link is permanently unresolvable — anon user creation needs a JWT-less
call the backend rejects — so canStartExecute=false sticks forever and
deadlocks the queue (no other op can dispatch since they all wait on
the login to resolve the local onesignal_id).

Clear existingOnesignalId on every surviving LoginUserOperation so the
executor takes the createUser (upsert) path. Matches the same fix in
reference branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Revert the order swap from dc93995 — that commit reordered switchUserIv
to switch users first and then set isDisabledInternally on the new push
sub with NO_PROPOGATE, on the bot review's claim that firing an
UpdateSubscriptionOperation against the OLD user was a bug. It is not a
bug — it is the intentional behavior in reference branches #2599 and
#2613: setting the flag on the CURRENT push sub with the default NORMAL
tag fires an UpdateSubscriptionOperation that tells the backend "this
device is unsubscribing as the user logs out", dispatched with the OLD
user's still-valid JWT before the switch. Without this, logout under IV
silently leaves the just-departed user's push sub subscribed
server-side.

The new (anonymous) user's model not carrying isDisabledInternally is
fine: anon ops are filtered by hasValidJwtIfRequired (externalId=null
under IV-active), so they accumulate but never dispatch.

Update the test to verify the corrected order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId
when the prior user was anonymous (currentExternalId == null). Under
IV-required that anon user was never created server-side — no JWT — so
its onesignalId stays a local id forever. Carrying it as
existingOnesignalId on the new LoginUserOperation makes
canStartExecute=false stick, deadlocking the queue across logout→login
cycles.

Skip the merge-link entirely when useIdentityVerification == REQUIRED so
the executor takes the createUser (upsert) path. Matches reference
branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Add shouldSuppressAnonymousOp from reference branch #2599: any non-
LoginUserOperation enqueued without an externalId is dropped at the
enqueue boundary when useIdentityVerification == REQUIRED, since it
can't authenticate and would otherwise sit in the queue forever blocked
by hasValidJwtIfRequired. LoginUserOperation is exempt — it's enqueued
intentionally during logout and purged later by
removeOperationsWithoutExternalId if needed.

Outer-gated on _identityVerificationService.newCodePathsRun so Phase 1
customers stay byte-for-byte on the legacy enqueue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Previously the loginUser block left the loading overlay on under the
expectation that onUserStateChange → fetchUserDataFromApi() would
dismiss it. Under IV, login can fail (bad JWT, anon-purge, etc.) and
onUserStateChange may never fire, leaving the overlay up indefinitely.
Match #2599 commit 0f1ad82 — dismiss the loader as soon as the SDK
call returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
…s iOS

The buffer-and-consume design (pendingJwtInvalidatedExternalId +
jwtInvalidatedLock + consume-on-first-subscribe replay) was added to
handle cold-start 401s when no listener was subscribed yet. iOS doesn't
do this — it fires only to currently-subscribed listeners, late
subscribers miss earlier events. Match iOS: drop the buffer entirely.

Reverts the structure introduced by d137481 ("align jwt-invalidated
replay with #2613 buffer-and-consume"). The simpler design here matches
reference branch #2599.

Side effects:
- onModelReplaced becomes a no-op (no buffer to clear). Kept the
  override since ISingletonModelStoreChangeHandler requires it.
- fireJwtInvalidated now uses OneSignalDispatchers.launchOnDefault per
  maintainer request (#3184062053) instead of a custom
  CoroutineScope(SupervisorJob() + Dispatchers.Default).
- KDoc on IOneSignal.addUserJwtInvalidatedListener and the OneSignal.kt
  facade now spell out the pure-pub/sub semantics: "Subscribe early
  (e.g. in Application.onCreate) to avoid missing cold-start 401s."

Test changes: drop the 4 buffer/replay-specific tests
(listener-replay-buffered-event, consume-on-first-subscribe,
fire-with-subscribers-no-buffer, onModelReplaced-clears-buffer).
Replace with one test that confirms late subscribers don't receive
earlier events.
nan-li added a commit that referenced this pull request May 6, 2026
…Store

JwtTokenStore is the source of truth for invalidation events (its
invalidateJwt method is what fires both internal and developer-facing
notifications). It already implements IEventNotifier<IJwtUpdateListener>
and dispatches to internal listeners. Adding a second EventProducer for
the developer-facing IUserJwtInvalidatedListener is consistent with the
class's existing notifier shape — just a different audience.

This eliminates the bridge that lived in UserManager:
- UserManager loses IJwtUpdateListener, _jwtTokenStore ctor param,
  jwtInvalidatedNotifier, addJwtInvalidatedListener,
  removeJwtInvalidatedListener, fireJwtInvalidated, onJwtInvalidated.
- OneSignalImp.{add,remove}UserJwtInvalidatedListener now route directly
  to JwtTokenStore.

Side benefits:
- No eager-construction problem: JwtTokenStore is already pulled in by
  OperationRepo (an IStartableService), so the bridge is live before
  any 401 can dispatch — no IBootstrapService registration needed for
  any class.
- UserManager goes back to being purely about user state.
- Improves on reference branches #2599 / #2613 which kept the bridge in
  UserManager and accepted an eager-construction-via-IAM dependency.

Tests:
- Move the 3 JWT-listener tests from UserManagerTests to
  JwtTokenStoreTests (invalidation fires listener, late subscriber
  doesn't get replay, removeListener stops notifications).
- Drop _jwtTokenStore param from UserManager construction sites.
nan-li added a commit that referenced this pull request May 6, 2026
When jwt_required hydrates true and removeOperationsWithoutExternalId
drops the anon ops, surviving LoginUserOperations may still carry
existingOnesignalId pointing at the just-dropped anon user (the
merge-anon-into-identified link set by LoginHelper). Under IV that
link is permanently unresolvable — anon user creation needs a JWT-less
call the backend rejects — so canStartExecute=false sticks forever and
deadlocks the queue (no other op can dispatch since they all wait on
the login to resolve the local onesignal_id).

Clear existingOnesignalId on every surviving LoginUserOperation so the
executor takes the createUser (upsert) path. Matches the same fix in
reference branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Revert the order swap from dc93995 — that commit reordered switchUserIv
to switch users first and then set isDisabledInternally on the new push
sub with NO_PROPOGATE, on the bot review's claim that firing an
UpdateSubscriptionOperation against the OLD user was a bug. It is not a
bug — it is the intentional behavior in reference branches #2599 and
#2613: setting the flag on the CURRENT push sub with the default NORMAL
tag fires an UpdateSubscriptionOperation that tells the backend "this
device is unsubscribing as the user logs out", dispatched with the OLD
user's still-valid JWT before the switch. Without this, logout under IV
silently leaves the just-departed user's push sub subscribed
server-side.

The new (anonymous) user's model not carrying isDisabledInternally is
fine: anon ops are filtered by hasValidJwtIfRequired (externalId=null
under IV-active), so they accumulate but never dispatch.

Update the test to verify the corrected order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId
when the prior user was anonymous (currentExternalId == null). Under
IV-required that anon user was never created server-side — no JWT — so
its onesignalId stays a local id forever. Carrying it as
existingOnesignalId on the new LoginUserOperation makes
canStartExecute=false stick, deadlocking the queue across logout→login
cycles.

Skip the merge-link entirely when useIdentityVerification == REQUIRED so
the executor takes the createUser (upsert) path. Matches reference
branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Add shouldSuppressAnonymousOp from reference branch #2599: any non-
LoginUserOperation enqueued without an externalId is dropped at the
enqueue boundary when useIdentityVerification == REQUIRED, since it
can't authenticate and would otherwise sit in the queue forever blocked
by hasValidJwtIfRequired. LoginUserOperation is exempt — it's enqueued
intentionally during logout and purged later by
removeOperationsWithoutExternalId if needed.

Outer-gated on _identityVerificationService.newCodePathsRun so Phase 1
customers stay byte-for-byte on the legacy enqueue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 6, 2026
Previously the loginUser block left the loading overlay on under the
expectation that onUserStateChange → fetchUserDataFromApi() would
dismiss it. Under IV, login can fail (bad JWT, anon-purge, etc.) and
onUserStateChange may never fire, leaving the overlay up indefinitely.
Match #2599 commit 0f1ad82 — dismiss the loader as soon as the SDK
call returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
…Store

JwtTokenStore is the source of truth for invalidation events (its
invalidateJwt method is what fires both internal and developer-facing
notifications). It already implements IEventNotifier<IJwtUpdateListener>
and dispatches to internal listeners. Adding a second EventProducer for
the developer-facing IUserJwtInvalidatedListener is consistent with the
class's existing notifier shape — just a different audience.

This eliminates the bridge that lived in UserManager:
- UserManager loses IJwtUpdateListener, _jwtTokenStore ctor param,
  jwtInvalidatedNotifier, addJwtInvalidatedListener,
  removeJwtInvalidatedListener, fireJwtInvalidated, onJwtInvalidated.
- OneSignalImp.{add,remove}UserJwtInvalidatedListener now route directly
  to JwtTokenStore.

Side benefits:
- No eager-construction problem: JwtTokenStore is already pulled in by
  OperationRepo (an IStartableService), so the bridge is live before
  any 401 can dispatch — no IBootstrapService registration needed for
  any class.
- UserManager goes back to being purely about user state.
- Improves on reference branches #2599 / #2613 which kept the bridge in
  UserManager and accepted an eager-construction-via-IAM dependency.

Tests:
- Move the 3 JWT-listener tests from UserManagerTests to
  JwtTokenStoreTests (invalidation fires listener, late subscriber
  doesn't get replay, removeListener stops notifications).
- Drop _jwtTokenStore param from UserManager construction sites.
nan-li added a commit that referenced this pull request May 7, 2026
When jwt_required hydrates true and removeOperationsWithoutExternalId
drops the anon ops, surviving LoginUserOperations may still carry
existingOnesignalId pointing at the just-dropped anon user (the
merge-anon-into-identified link set by LoginHelper). Under IV that
link is permanently unresolvable — anon user creation needs a JWT-less
call the backend rejects — so canStartExecute=false sticks forever and
deadlocks the queue (no other op can dispatch since they all wait on
the login to resolve the local onesignal_id).

Clear existingOnesignalId on every surviving LoginUserOperation so the
executor takes the createUser (upsert) path. Matches the same fix in
reference branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Revert the order swap from dc93995 — that commit reordered switchUserIv
to switch users first and then set isDisabledInternally on the new push
sub with NO_PROPOGATE, on the bot review's claim that firing an
UpdateSubscriptionOperation against the OLD user was a bug. It is not a
bug — it is the intentional behavior in reference branches #2599 and
#2613: setting the flag on the CURRENT push sub with the default NORMAL
tag fires an UpdateSubscriptionOperation that tells the backend "this
device is unsubscribing as the user logs out", dispatched with the OLD
user's still-valid JWT before the switch. Without this, logout under IV
silently leaves the just-departed user's push sub subscribed
server-side.

The new (anonymous) user's model not carrying isDisabledInternally is
fine: anon ops are filtered by hasValidJwtIfRequired (externalId=null
under IV-active), so they accumulate but never dispatch.

Update the test to verify the corrected order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId
when the prior user was anonymous (currentExternalId == null). Under
IV-required that anon user was never created server-side — no JWT — so
its onesignalId stays a local id forever. Carrying it as
existingOnesignalId on the new LoginUserOperation makes
canStartExecute=false stick, deadlocking the queue across logout→login
cycles.

Skip the merge-link entirely when useIdentityVerification == REQUIRED so
the executor takes the createUser (upsert) path. Matches reference
branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Add shouldSuppressAnonymousOp from reference branch #2599: any non-
LoginUserOperation enqueued without an externalId is dropped at the
enqueue boundary when useIdentityVerification == REQUIRED, since it
can't authenticate and would otherwise sit in the queue forever blocked
by hasValidJwtIfRequired. LoginUserOperation is exempt — it's enqueued
intentionally during logout and purged later by
removeOperationsWithoutExternalId if needed.

Outer-gated on _identityVerificationService.newCodePathsRun so Phase 1
customers stay byte-for-byte on the legacy enqueue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Previously the loginUser block left the loading overlay on under the
expectation that onUserStateChange → fetchUserDataFromApi() would
dismiss it. Under IV, login can fail (bad JWT, anon-purge, etc.) and
onUserStateChange may never fire, leaving the overlay up indefinitely.
Match #2599 commit 0f1ad82 — dismiss the loader as soon as the SDK
call returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Rewrite the listener replay to match the reference design from #2613
commit 89cca43:
- Lock-protected pendingJwtInvalidatedExternalId (consume-on-first-subscribe)
  replaces the @volatile lastJwtInvalidatedExternalId (always-replay)
- fireJwtInvalidated buffers ONLY when no subscribers exist; otherwise
  schedules an async fire. Closes the double-fire race.
- onModelReplaced clears the buffer on IdentityModel replacement (login or
  logout switch via UserSwitcher.createAndSwitchToNewUser → replace).
- Drop the explicit clearLastJwtInvalidated() method and its calls in
  OneSignalImp.logout() / logoutSuspend() — clear is now automatic.

Match #2599 conventions:
- Drop the isInitialized throw in addUserJwtInvalidatedListener /
  removeUserJwtInvalidatedListener.
- Add updateUserJwtSuspend.

Update IUserJwtInvalidatedListener docstring to clarify replay is
synchronous on the caller's thread; regular fire is async.

Tests rewritten for the new semantics: first-subscriber-replay,
consume-on-first, fire-with-subscribers-doesn't-buffer, onModelReplaced
clears.
nan-li added a commit that referenced this pull request May 7, 2026
Same-user-id login with a fresh JWT (the documented post-401 refresh
path) was storing the new token but never calling forceExecuteOperations,
so ops parked by hasValidJwtIfRequired stayed deferred until something
else woke the queue. updateUserJwt already does this correctly; reference
#2599 LoginHelper does too. Match that design — drop redundant
if (jwtBearerToken != null) guards (putJwt no-ops on null) and call
forceExecuteOperations unconditionally on the same-id branch so the
queue drains as soon as the developer supplies a fresh token.

Extend the existing same-id+JWT test to verify forceExecuteOperations
fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Three review-driven fixes on the IV public-API surface:

1. OneSignal.kt: add @JvmStatic wrappers for updateUserJwt,
   addUserJwtInvalidatedListener, removeUserJwtInvalidatedListener, and
   updateUserJwtSuspend. Without these, the four new IOneSignal methods
   were unreachable from app code (the OneSignal object is the documented
   Java/Kotlin entry point and "implements IOneSignal in spirit"). Mirrors
   the convention from reference branch #2599.

2. OneSignalImp.kt: mask jwtBearerToken in login()/loginSuspend() DEBUG
   logs (...${jwtBearerToken?.takeLast(8)}). Pre-PR the parameter was a
   no-op (LoginHelper had a TODO) so the leak was theoretical; this PR
   wires the token through to JwtTokenStore.putJwt, so a live bearer
   credential now flows through Logging.log at DEBUG. updateUserJwt
   already masks; mirror that.

3. UserManager.kt: restore Logging.warn(msg, ex) form on the two new
   runCatching.onFailure handlers in fireJwtInvalidated and
   addJwtInvalidatedListener. Interpolating ${ex.message} drops the
   stack trace; Logging.warn already accepts a Throwable second arg
   that propagates through to log listeners and Otel — same pattern
   restored in JwtTokenStore via e76fb60.

Also refreshes detekt baseline for the new MagicNumber (takeLast(8)),
ConstructorParameterNaming (_jwtTokenStore on UserManager), TooManyFunctions
(UserManager now also implements IJwtUpdateListener), and UseCheckOrError
on the 4 new IllegalStateException throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
…s iOS

The buffer-and-consume design (pendingJwtInvalidatedExternalId +
jwtInvalidatedLock + consume-on-first-subscribe replay) was added to
handle cold-start 401s when no listener was subscribed yet. iOS doesn't
do this — it fires only to currently-subscribed listeners, late
subscribers miss earlier events. Match iOS: drop the buffer entirely.

Reverts the structure introduced by d137481 ("align jwt-invalidated
replay with #2613 buffer-and-consume"). The simpler design here matches
reference branch #2599.

Side effects:
- onModelReplaced becomes a no-op (no buffer to clear). Kept the
  override since ISingletonModelStoreChangeHandler requires it.
- fireJwtInvalidated now uses OneSignalDispatchers.launchOnDefault per
  maintainer request (#3184062053) instead of a custom
  CoroutineScope(SupervisorJob() + Dispatchers.Default).
- KDoc on IOneSignal.addUserJwtInvalidatedListener and the OneSignal.kt
  facade now spell out the pure-pub/sub semantics: "Subscribe early
  (e.g. in Application.onCreate) to avoid missing cold-start 401s."

Test changes: drop the 4 buffer/replay-specific tests
(listener-replay-buffered-event, consume-on-first-subscribe,
fire-with-subscribers-no-buffer, onModelReplaced-clears-buffer).
Replace with one test that confirms late subscribers don't receive
earlier events.
nan-li added a commit that referenced this pull request May 7, 2026
…Store

JwtTokenStore is the source of truth for invalidation events (its
invalidateJwt method is what fires both internal and developer-facing
notifications). It already implements IEventNotifier<IJwtUpdateListener>
and dispatches to internal listeners. Adding a second EventProducer for
the developer-facing IUserJwtInvalidatedListener is consistent with the
class's existing notifier shape — just a different audience.

This eliminates the bridge that lived in UserManager:
- UserManager loses IJwtUpdateListener, _jwtTokenStore ctor param,
  jwtInvalidatedNotifier, addJwtInvalidatedListener,
  removeJwtInvalidatedListener, fireJwtInvalidated, onJwtInvalidated.
- OneSignalImp.{add,remove}UserJwtInvalidatedListener now route directly
  to JwtTokenStore.

Side benefits:
- No eager-construction problem: JwtTokenStore is already pulled in by
  OperationRepo (an IStartableService), so the bridge is live before
  any 401 can dispatch — no IBootstrapService registration needed for
  any class.
- UserManager goes back to being purely about user state.
- Improves on reference branches #2599 / #2613 which kept the bridge in
  UserManager and accepted an eager-construction-via-IAM dependency.

Tests:
- Move the 3 JWT-listener tests from UserManagerTests to
  JwtTokenStoreTests (invalidation fires listener, late subscriber
  doesn't get replay, removeListener stops notifications).
- Drop _jwtTokenStore param from UserManager construction sites.
nan-li added a commit that referenced this pull request May 7, 2026
When jwt_required hydrates true and removeOperationsWithoutExternalId
drops the anon ops, surviving LoginUserOperations may still carry
existingOnesignalId pointing at the just-dropped anon user (the
merge-anon-into-identified link set by LoginHelper). Under IV that
link is permanently unresolvable — anon user creation needs a JWT-less
call the backend rejects — so canStartExecute=false sticks forever and
deadlocks the queue (no other op can dispatch since they all wait on
the login to resolve the local onesignal_id).

Clear existingOnesignalId on every surviving LoginUserOperation so the
executor takes the createUser (upsert) path. Matches the same fix in
reference branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Revert the order swap from dc93995 — that commit reordered switchUserIv
to switch users first and then set isDisabledInternally on the new push
sub with NO_PROPOGATE, on the bot review's claim that firing an
UpdateSubscriptionOperation against the OLD user was a bug. It is not a
bug — it is the intentional behavior in reference branches #2599 and
#2613: setting the flag on the CURRENT push sub with the default NORMAL
tag fires an UpdateSubscriptionOperation that tells the backend "this
device is unsubscribing as the user logs out", dispatched with the OLD
user's still-valid JWT before the switch. Without this, logout under IV
silently leaves the just-departed user's push sub subscribed
server-side.

The new (anonymous) user's model not carrying isDisabledInternally is
fine: anon ops are filtered by hasValidJwtIfRequired (externalId=null
under IV-active), so they accumulate but never dispatch.

Update the test to verify the corrected order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
LoginHelper.switchUser was setting existingOnesignalId = currentOneSignalId
when the prior user was anonymous (currentExternalId == null). Under
IV-required that anon user was never created server-side — no JWT — so
its onesignalId stays a local id forever. Carrying it as
existingOnesignalId on the new LoginUserOperation makes
canStartExecute=false stick, deadlocking the queue across logout→login
cycles.

Skip the merge-link entirely when useIdentityVerification == REQUIRED so
the executor takes the createUser (upsert) path. Matches reference
branches #2599 and #2613.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Add shouldSuppressAnonymousOp from reference branch #2599: any non-
LoginUserOperation enqueued without an externalId is dropped at the
enqueue boundary when useIdentityVerification == REQUIRED, since it
can't authenticate and would otherwise sit in the queue forever blocked
by hasValidJwtIfRequired. LoginUserOperation is exempt — it's enqueued
intentionally during logout and purged later by
removeOperationsWithoutExternalId if needed.

Outer-gated on _identityVerificationService.newCodePathsRun so Phase 1
customers stay byte-for-byte on the legacy enqueue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nan-li added a commit that referenced this pull request May 7, 2026
Previously the loginUser block left the loading overlay on under the
expectation that onUserStateChange → fetchUserDataFromApi() would
dismiss it. Under IV, login can fail (bad JWT, anon-purge, etc.) and
onUserStateChange may never fire, leaving the overlay up indefinitely.
Match #2599 commit 0f1ad82 — dismiss the loader as soon as the SDK
call returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants