Skip to content

fix: [SDK-5099] respect REST API-disabled push subscriptions - #1731

Open
nan-li wants to merge 6 commits into
mainfrom
nan/sdk-5099
Open

fix: [SDK-5099] respect REST API-disabled push subscriptions#1731
nan-li wants to merge 6 commits into
mainfrom
nan/sdk-5099

Conversation

@nan-li

@nan-li nan-li commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Stop re-enabling push subscriptions that the app owner disabled remotely, from the dashboard (notification_types -22) or through the REST API (-31).

Details

Motivation

Customers who suppress users by disabling subscriptions via the REST API see them come back subscribed. The SDK never hydrated the server's disable onto an existing push subscription (parseFetchUserResponse is gated on subscriptionId == nil), and every Create User and PATCH body recomputed enabled from device state, so a login or a routine device-metadata update silently re-enabled the subscription. A dashboard unsubscribe writes a different code and was undone the same way. Tracked internally as SDK-5099.

Scope

Push subscriptions only; email/SMS hydration is unchanged. The model gains a server-owned remoteDisabledReason field that mirrors the server's notification_types, and OSRemoteDisable names the two codes the app owner sets remotely, -22 for a dashboard unsubscribe and -31 for a REST API disable. A response reporting either one records it verbatim, so the outgoing payload echoes back the code the server sent rather than collapsing both into one. Any other reported value clears it, and it also clears when the subscription ID resets (the record it describes is gone) or on an explicit optIn(), whose clear outranks stale in-flight hydration until the server confirms. That opt-in guard arms on every optIn(), not only when a disable was already recorded, because a fetch issued before the opt-in can still land the customer's first disable and undo it. While a code is recorded, Create User and PATCH payloads send enabled: false with that code instead of device values; both builders share one snapshot-based body (updateParams() / outboundNotificationTypes). Recording and clearing log at DEBUG.

Public pushSubscription.optedIn now reports false while a disable is recorded, and its doc comment says so. No signature changes. A remote disable suppresses delivery as surely as a missing permission or an opt-out, and it is the only one of the three an app has no other way to see, so a preference center no longer shows "subscribed" on a device the app owner turned off. Hydration therefore fires the push subscription observer with the new state, and suppresses the enabled delta a local change of the same shape would generate, since sending that would tell the server what it just told us.

For reviewers

Two product calls, both matched by the companion PR in OneSignal-Android-SDK. A remote disable now flips the public optedIn to false, which reverses the earlier decision on this branch to keep that property to preference plus permission. And optIn() still overrides the suppression, on the reasoning that a deliberate user action beats an exclusion list.

Testing

Unit testing

Fourteen tests at the model level cover code recognition, verbatim recording of each code and switching between them, mirror clearing, ID-reset clearing, optIn() precedence over stale hydration both with and without a disable recorded, payload override, NSCoding round-trip, device-state refresh survival, optedIn reporting false for both codes, optedIn ignoring device-recoverable codes, and hydration firing the observer without enqueueing an enabled delta. An integration test reproduces the customer scenario end to end: fetch reports the disable, login to a different external ID, and the Create User body still sends enabled: false with that code.

Manual testing

Device tested before and after with REST API calls.

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

@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 (Opus 5, GPT 5.6 Sol, Grok 4.6)

The mirror + snapshot updateParams() / jsonRepresentation() is the right shape for SDK-5099. optedIn staying device-owned matches the stated product call.

Act on (3/3)

  • clearRestApiDisable() returns before setting restApiDisableClearedByUser when the mirror is already nil. optIn() then loses the race against a first -31 fetch (cold start after an operator disable, or after a subscription-ID reset). Tests only cover opt-in after the disable was already recorded.
  • recordRestApiDisable / clearRestApiDisable read and write the latch and reason across separate lock acquisitions, so a concurrent hydrate can still write -31 after opt-in.

Consider (2/3)

  • Cached start() / startNewSession() flush a subscription PATCH before fetch hydrates -31. The first session after a REST disable can still send enabled: true — the customer path this PR names.
  • Existing-ID hydration matches only id. A miss plus new-session “sub gone” (subscriptionId = nil clears the mirror and POSTs a new enabled sub) can re-enable.
  • The latch is process-lifetime and generation-less: a later legitimate -31 can be ignored until restart, and a stale -31 after a newer non-disable can re-record.

Dismissed: optedIn not flipping is the stated product choice. NSCoding decodeObject as? Int matches testType/netType and is covered. Assigning nil notification_types omits the key rather than inserting NSNull.

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment thread iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift Outdated
Comment thread iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift Outdated
A push subscription disabled through the REST API (notification_types
-31) was re-enabled by the SDK: fetch responses never hydrated the
disable onto an existing push subscription, and every Create User and
update payload recomputed enabled from device state.

Mirror the server's disable code on the subscription model when a
response reports it, and echo it back in Create User and update
payloads instead of the device-derived values. The mirror clears when
the server reports any other state, when the subscription ID resets
because the server record is gone, and on an explicit optIn(), whose
clear outranks stale in-flight hydration until the server confirms.
Both payload builders share one snapshot-based body so concurrent
changes cannot tear enabled away from notification_types.
@nan-li nan-li changed the title fix: respect REST API-disabled push subscriptions fix: [SDK-5099] respect REST API-disabled push subscriptions Sep 2, 2026
Recording, clearing, and accepting the server's disable state each read
the opt-in guard and wrote the reason under separate lock acquisitions,
so a hydrate racing an optIn() could leave the guard armed with the
reason re-recorded. Each transition now does its guard and write in one
critical section and fires events after release.

OSPushSubscription.optedIn documents that it reflects the user's
preference and OS permission, not a server-side disable.
@nan-li

nan-li commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Multi-model review (Opus 5, GPT 5.6 Sol, Grok 4.6)

The mirror + snapshot updateParams() / jsonRepresentation() is the right shape for SDK-5099. optedIn staying device-owned matches the stated product call.

Act on (3/3)

  • clearRestApiDisable() returns before setting restApiDisableClearedByUser when the mirror is already nil. optIn() then loses the race against a first -31 fetch (cold start after an operator disable, or after a subscription-ID reset). Tests only cover opt-in after the disable was already recorded.
  • recordRestApiDisable / clearRestApiDisable read and write the latch and reason across separate lock acquisitions, so a concurrent hydrate can still write -31 after opt-in.

Consider (2/3)

  • Cached start() / startNewSession() flush a subscription PATCH before fetch hydrates -31. The first session after a REST disable can still send enabled: true — the customer path this PR names.
  • Existing-ID hydration matches only id. A miss plus new-session “sub gone” (subscriptionId = nil clears the mirror and POSTs a new enabled sub) can re-enable.
  • The latch is process-lifetime and generation-less: a later legitimate -31 can be ignored until restart, and a stale -31 after a newer non-disable can re-record.

Dismissed: optedIn not flipping is the stated product choice. NSCoding decodeObject as? Int matches testType/netType and is covered. Assigning nil notification_types omits the key rather than inserting NSNull.

Open in Web View Automation 
Sent by Cursor Automation: PR Reviews

On the first launch after upgrading, if the SDK sends a routine device update before it has asked the server about the user, it can turn the subscription back on once. We accept that. It happens at most once and the next daily check fixes it. If the server's subscription ID doesn't match ours, we treat the old record as gone and make a new enabled one, which is intended. The "user opted in" marker is wiped as soon as the server reports anything other than a disable, so it can't stick around long enough to cause the problem described.

@nan-li

nan-li commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

This PR fixes the coordination between "the server switched this subscription off through the API" and local SDK state. Opting in and out can happen on the SDK and enabling and disabling can happen on the REST API.

Almost every review comment is about a moment where this server state is missing, stale, or ignored. The sync between SDK and server is a fundamental inherent problem regardless of this PR. It will never be completely right for all. Consider that if someone adds a tag or calls optIn() and there is no connection. 30 minutes later, they update something via the REST API. Later, the SDK gets a connection 8 hours later, it will send those updates even though it is technically stale.

@nan-li
nan-li requested a review from a team September 8, 2026 16:45
The server reports notification_types -22 when someone turns a subscription
off by hand from the dashboard. That means the same thing as -31, disabled
through the REST API, so both codes now suppress outgoing subscription
payloads the same way and neither is derived from device state.

The two codes stay distinct. remoteDisabledReason records whichever one the
server sent, and hydration writes it verbatim, so a payload echoes back the
code the server actually reported. A second disable arriving under the other
code replaces the recorded one instead of being ignored as already disabled.

Renamed the restApiDisable members to remoteDisable, since "REST API" no
longer describes the concept, and moved the codes themselves into a new
OSRemoteDisable namespace at file scope. Keeping them inside the class pushed
its body past swiftlint's type_body_length error threshold, and they describe
the wire protocol rather than any one model instance. The NSCoding key
changed with the property name, but no release has written the old key.
recordRemoteDisable and acceptServerNonDisabledState changed the model
silently, so parsing -22 or -31 off the wire left no trace at any log level
short of the raw HTTP body. Add a DEBUG line at the point each one actually
changes the value, matching the Android side.

Reading the previous reason for that message also made the guard inside
acceptServerNonDisabledState's lock redundant, since clearing a reason that
is already nil is a no-op on a struct field. The outer guard already gates
both the log and the persist.
clearRemoteDisable() returned before setting remoteDisableClearedByUser when
nothing was recorded locally, which is exactly the state the race starts in.
The customer disables the subscription, a fetch goes out that will report the
code, and optIn() runs before that response lands. The guard never armed, so
the fetch recorded the disable and the next update re-sent it, silently
undoing the opt-in.

Arm the flag on every opt-in and keep the persist and change event gated on
there having been a recorded reason. Android already did this, in "keep an
opt-in over a stale REST API disable report"; this brings iOS in line and
adds the regression test that platform has.
optedIn was the user's preference combined with OS permission, which
stopped describing "will push reach this device" once the SDK started
respecting a remote disable. The disable now sticks instead of being
flipped back on by the next routine update, and nothing else in the
public API reveals it, so an app syncing preferences through the REST
API would read opted in forever on a device receiving nothing.

calculateIsOptedIn takes the recorded reason alongside reachable and
isDisabled, so the value, the observer payload built by
currentPushSubscriptionState, and the previous/current pair inside
firePushSubscriptionChanged all agree. The two hydration paths now fire
so the transition reaches observers, and they pass
generateEnabledDelta: false because the state came from the server and
an enabled delta would tell it what it just told us. An optIn() clear
keeps the delta, which is how the server re-enables the subscription.
@fadi-george

Copy link
Copy Markdown
Collaborator

Thanks. Items 3 and 4 look resolved. I think 1 and 2 still need changes:

  1. settleOptInGuard() clears the flag after any completed subscription write, not specifically the opt-in write. An older in-flight update could clear a newly armed guard before the opt-in PATCH executes.

  2. The NORMAL correction does not address the actual session-start ordering. OperationRepo executes the metadata PATCH before RefreshUser, so the GET sees the subscription already enabled and never generates the correction. The test manually constructs a stale-plus-corrective batch, but that batch is not produced on this path.

Could we add an OperationRepo-level test covering metadata update followed by RefreshUser and address that ordering directly?

@nan-li

nan-li commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Items 3 and 4 look resolved. I think 1 and 2 still need changes:

  1. settleOptInGuard() clears the flag after any completed subscription write, not specifically the opt-in write. An older in-flight update could clear a newly armed guard before the opt-in PATCH executes.
  2. The NORMAL correction does not address the actual session-start ordering. OperationRepo executes the metadata PATCH before RefreshUser, so the GET sees the subscription already enabled and never generates the correction. The test manually constructs a stale-plus-corrective batch, but that batch is not produced on this path.

Could we add an OperationRepo-level test covering metadata update followed by RefreshUser and address that ordering directly?

I think this is meant for Android PR right? I answered it there: OneSignal/OneSignal-Android-SDK#2728 (comment)

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