Skip to content

fix: respect REST API-disabled push subscriptions - #1731

Open
nan-li wants to merge 1 commit into
mainfrom
nan/sdk-5099
Open

fix: respect REST API-disabled push subscriptions#1731
nan-li wants to merge 1 commit 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 were disabled through the REST API (notification_types -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. Tracked internally as SDK-5099.

Scope

Push subscriptions only; email/SMS hydration is unchanged. The model gains a server-owned restApiDisabledReason field that mirrors the server's notification_types. A response reporting -31 records it, 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. While recorded, Create User and PATCH payloads send enabled: false with -31 instead of device values; both builders share one snapshot-based body (updateParams() / outboundNotificationTypes). No public API signature changes.

For reviewers

A server-side disable deliberately does not flip the public optedIn, and optIn() deliberately overrides the suppression. Both are product calls worth confirming, and they should match Android (companion PR in OneSignal-Android-SDK).

Testing

Unit testing

Nine model-level tests cover recording, mirror clearing, ID-reset clearing, optIn precedence over stale hydration, payload override, NSCoding round-trip, and device-state refresh survival. An integration test reproduces the customer scenario end to end: fetch reports -31, login to a different external ID, and the Create User body still sends enabled: false / notification_types: -31.

Manual testing

Not device-tested; the reported scenarios are reproduced by the integration test above.

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 on lines +634 to +642
func clearRestApiDisable() {
let oldValue = swapValue(\.restApiDisabledReason, to: nil)
guard oldValue != nil else {
return
}
stateLock.withLock { state.restApiDisableClearedByUser = true }
self.set(property: "restApiDisabledReason", newValue: nil as Int?, preventServerUpdate: true)
firePushSubscriptionChanged(.restApiDisabledReason(oldValue))
}

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): this early-return never sets restApiDisableClearedByUser. The latch exists so optIn() outranks a stale -31 hydration, but that only works when a disable is already mirrored.

If the local reason is still nil (first launch after an operator disable, or after subscriptionId reset) and a fetch is in flight, optIn() is a no-op here. The later recordRestApiDisable(-31) succeeds, and every subsequent payload sends enabled: false / -31 — undoing the user action.

testRestApiDisable_optInOutranksStaleHydration only covers the already-recorded ordering. Set the latch unconditionally; fire the delta/event only when oldValue != nil.

Comment on lines +573 to +579
private func recordRestApiDisable(_ code: Int) {
let clearedByUser = stateLock.withLock { state.restApiDisableClearedByUser }
guard !clearedByUser else {
return
}
restApiDisabledReason = code
}

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): the latch read and the restApiDisabledReason write are separate lock acquisitions. Interleaved with clearRestApiDisable() (swap reason → set latch):

  1. clear swaps the reason to nil
  2. record reads the latch as false and passes
  3. clear sets the latch and enqueues the re-enable
  4. record writes -31 back

Final state is latch true + reason -31. Do the guard-and-write for both operations in one stateLock critical section; fire set / events after release.

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

1 participant