Skip to content

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

Merged
nan-li merged 9 commits into
mainfrom
nan/sdk-5099
Sep 14, 2026
Merged

nan-li merged 9 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 only when the optIn() changed something, either it cleared a recorded disable or lifted the user's own opt-out; a call that changes nothing sends nothing and must not blind the SDK to a later disable. 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

Model-level tests cover code recognition, verbatim recording of each code and switching between them, mirror clearing, ID-reset clearing, optIn() precedence over stale hydration when it changed something and none when it didn't, email and SMS hydration staying off the push path, the reset event's previous state, 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)

@fadi-george

Copy link
Copy Markdown
Collaborator

Potential issues:

  • OSSubscriptionModel.hydrateNotificationTypes handles -22 and -31 without checking type == .push. Email and SMS models are also hydrated through this path, so one of these values can set remoteDisabledReason and fire onPushSubscriptionDidChange with an email address or phone number as the push token. Please restrict remote-disable handling to push models and add an email/SMS regression test.

  • clearRemoteDisable() sets remoteDisableClearedByUser on every optIn(), including idempotent calls when no disable is recorded. The flag is only cleared after fetching a non-disabled state. If an operator applies a legitimate remote disable before that happens, hydration ignores it and a later PATCH can re-enable the subscription. Please scope the guard to server responses that were already in flight when optIn() occurred, such as with a request generation or timestamp, and test a remote disable arriving after optIn().

  • (non-blocking) When subscriptionId becomes nil, remoteDisabledReason is cleared before firePushSubscriptionChanged(.subscriptionId(...)) reconstructs the previous state. A remotely disabled subscription therefore emits previous.optedIn == true instead of false. Please preserve the old disable reason when constructing the observer event and test the previous/current values.

@abdulraqeeb33 abdulraqeeb33 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.

Two issues on the hydration / opt-in path.

  1. Email/SMS hydrate records a remote disable and fires the push observer.
  2. The opt-in guard never settles, so optIn() on launch ignores -22/-31 for the process.

Comment thread iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift Outdated
Comment thread iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift
…vent state

Three review findings on the remote disable work.

Email and SMS models hydrate through the same path as push, and the server
writes the same codes on their rows when the app owner disables them. The
notification_types hydration recorded the code on those models and fired the
push subscription observer with an email address or phone number as the
token. Remote disable state is push-only now; email and SMS keep the plain
enabled mapping they had before.

The opt-in guard armed on every optIn(), including calls that changed
nothing. Those send nothing, so the guard had no write to protect, and it
stayed armed until a fetch reported a non-disabled state. Any disable that
landed in the meantime was ignored for the life of the process, and the next
routine PATCH re-enabled the subscription. Apps that call optIn() on every
launch re-armed it before each session's fetch returned and never recorded a
disable at all. The guard now arms only when the opt-in cleared a recorded
disable or lifted the user's own opt-out.

When the subscription ID resets, the recorded code was cleared before the
observer event was built, so a disabled subscription reported its previous
state as opted in. The old code now travels with the event, and the reset no
longer generates an enabled delta, since the new record's state goes out with
the create request.
@nan-li

nan-li commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Potential issues:

  • OSSubscriptionModel.hydrateNotificationTypes handles -22 and -31 without checking type == .push. Email and SMS models are also hydrated through this path, so one of these values can set remoteDisabledReason and fire onPushSubscriptionDidChange with an email address or phone number as the push token. Please restrict remote-disable handling to push models and add an email/SMS regression test.
  • clearRemoteDisable() sets remoteDisableClearedByUser on every optIn(), including idempotent calls when no disable is recorded. The flag is only cleared after fetching a non-disabled state. If an operator applies a legitimate remote disable before that happens, hydration ignores it and a later PATCH can re-enable the subscription. Please scope the guard to server responses that were already in flight when optIn() occurred, such as with a request generation or timestamp, and test a remote disable arriving after optIn().
  • (non-blocking) When subscriptionId becomes nil, remoteDisabledReason is cleared before firePushSubscriptionChanged(.subscriptionId(...)) reconstructs the previous state. A remotely disabled subscription therefore emits previous.optedIn == true instead of false. Please preserve the old disable reason when constructing the observer event and test the previous/current values.

thanks, All three fixed in b31ea63.

  1. Remote disable handling is push-only now, so email and SMS keep their old enabled mapping. Test covers both types with both codes and checks push observers hear nothing.
  2. The flag is set only when optIn() actually changed something, since a call that changes nothing sends nothing and setting it anyway would ignore every later disable until the process died. Tests cover an opted-out user opting in during a fetch that reports the first disable, and a no-op optIn() followed by a real disable.
  3. The old code now travels with the reset event, so previous reads as disabled and current as opted in. The reset no longer queues an enabled delta either, since the create request carries the new record's state.

@fadi-george

Copy link
Copy Markdown
Collaborator

Still some issues:

  • The opt-in guard is still open-ended after a real opt-in. remoteDisableClearedByUser remains set until a fetch reports a non-disabled state, and PATCH success does not clear it. If an operator disables the subscription after the opt-in but before that fetch, the legitimate -22/-31 is ignored and a later routine PATCH can re-enable it. The guard should apply only to fetch responses already in flight when optIn() was called.

  • The new condition only treats _isDisabled == true as a meaningful opt-in. If _isDisabled == false but notification permission is unavailable, optIn() still initiates a real permission recovery. The guard is not armed, so a stale fetch can record the remote disable before permission succeeds and prevent the explicit opt-in from enabling the subscription. Please include this state in the guard logic and regression tests.

@abdulraqeeb33 abdulraqeeb33 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.

Both comments addressed in b31ea63.

  1. Email/SMS hydrate is push-only.
  2. No-op optIn() no longer arms the guard.

Remaining: the guard still only drops on a non-disable fetch, and userWasOptedOut can arm with no write when there is no token or permission. Neither blocks.

optIn() prompts for permission when the device has none, and the grant is
the write that re-enables the subscription. That opt-in set nothing, so a
fetch already in flight could record the operator's first disable before the
grant landed, and the grant's update then carried the disable instead of
enabling the subscription the user asked for.

clearRemoteDisable() now reads the model itself, before optIn() flips the
opt-out, and sets the flag when a disable was recorded, the user had opted
out, or permission is missing.
@nan-li

nan-li commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Still some issues:

  • The opt-in guard is still open-ended after a real opt-in. remoteDisableClearedByUser remains set until a fetch reports a non-disabled state, and PATCH success does not clear it. If an operator disables the subscription after the opt-in but before that fetch, the legitimate -22/-31 is ignored and a later routine PATCH can re-enable it. The guard should apply only to fetch responses already in flight when optIn() was called.
  • The new condition only treats _isDisabled == true as a meaningful opt-in. If _isDisabled == false but notification permission is unavailable, optIn() still initiates a real permission recovery. The guard is not armed, so a stale fetch can record the remote disable before permission succeeds and prevent the explicit opt-in from enabling the subscription. Please include this state in the guard logic and regression tests.
  1. This one I think to leave as is. It needs three things in one process: a real opt-in, an operator disable landing before the next fetch, and a device change after that fetch. Cold starts clear the flag, so that last one is rare. Same answer as on Android, and I'd rather keep both platforms on one rule than stamp every fetch and update for that window. Do you feel like this one is blocking?

  2. This second one is fair so fixed in 85764c4. A missing permission now counts as a real opt-in, since the prompt's grant is the write that re-enables. Test added.

@fadi-george

Copy link
Copy Markdown
Collaborator

Yeah #1 is not really blocking.

@nan-li
nan-li merged commit 15561f4 into main Sep 14, 2026
7 of 8 checks passed
@nan-li
nan-li deleted the nan/sdk-5099 branch September 14, 2026 04:20
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