fix: respect REST API-disabled push subscriptions - #1731
Conversation
There was a problem hiding this comment.
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 settingrestApiDisableClearedByUserwhen the mirror is already nil.optIn()then loses the race against a first-31fetch (cold start after an operator disable, or after a subscription-ID reset). Tests only cover opt-in after the disable was already recorded.recordRestApiDisable/clearRestApiDisableread and write the latch and reason across separate lock acquisitions, so a concurrent hydrate can still write-31after 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 sendenabled: true— the customer path this PR names. - Existing-ID hydration matches only
id. A miss plus new-session “sub gone” (subscriptionId = nilclears the mirror and POSTs a new enabled sub) can re-enable. - The latch is process-lifetime and generation-less: a later legitimate
-31can be ignored until restart, and a stale-31after 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.
Sent by Cursor Automation: PR Reviews
| 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)) | ||
| } |
There was a problem hiding this comment.
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.
| private func recordRestApiDisable(_ code: Int) { | ||
| let clearedByUser = stateLock.withLock { state.restApiDisableClearedByUser } | ||
| guard !clearedByUser else { | ||
| return | ||
| } | ||
| restApiDisabledReason = code | ||
| } |
There was a problem hiding this comment.
Act on (3/3): the latch read and the restApiDisabledReason write are separate lock acquisitions. Interleaved with clearRestApiDisable() (swap reason → set latch):
- clear swaps the reason to
nil - record reads the latch as
falseand passes - clear sets the latch and enqueues the re-enable
- record writes
-31back
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.


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 (
parseFetchUserResponseis gated onsubscriptionId == nil), and every Create User and PATCH body recomputedenabledfrom 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
restApiDisabledReasonfield that mirrors the server'snotification_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 explicitoptIn(), whose clear outranks stale in-flight hydration until the server confirms. While recorded, Create User and PATCH payloads sendenabled: falsewith -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, andoptIn()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
Checklist
Overview
Testing
Final pass