Skip to content

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

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

fix: [SDK-5099] respect REST API-disabled push subscriptions#2728
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. RefreshUser discarded the server's disable state for push, the session-start self-heal (SDK-4474) re-asserted local truth over it on every app open, and every subscription payload recomputed enabled from device state. Tracked internally as SDK-5099.

Scope

Push subscriptions only. The push model gains a server-owned restApiDisabledReason field that mirrors the server's notification_types: RefreshUser records -31 and clears on any other reported value, getSubscriptionEnabledAndStatus reports (false, -31) while recorded so create/update/login payloads preserve it, the self-heal skips it, and it is carried across the login/logout user switch. optIn() clears it. The 404 recovery paths (user rebuild and update-404 re-create) treat the dead record's disable as gone and recreate from device truth.

Also removes the mislabeled DISABLED_FROM_REST_API_DEFAULT_REASON(-30) enum case; no OneSignal API writes -30 as a REST disable (the docs list -30 as an APNs error, and the actual dashboard/player disable code is -22). Because models and operations persist enum properties by name, Model.getOptEnumProperty now parses leniently so anything persisted under an unknown enum name reads as SUBSCRIBED instead of throwing on upgrade. Adding DISABLED_FROM_REST_API(-31) also fixes -31 email/SMS rows previously misparsing to SUBSCRIBED.

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 iOS (companion PR in OneSignal-iOS-SDK).

Testing

Unit testing

New coverage across four suites: RefreshUser recording/mirror-clearing and self-heal skip, payload reporting via getSubscriptionEnabledAndStatus, optIn() clearing, user-switch carry-over, update-404 recovery from device truth, and lenient parsing of unknown persisted enum names for both models and operations (through initializeFromJson).

Manual testing

Not device-tested; the reported scenarios are reproduced by the executor and user-switch tests 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

@nan-li
nan-li requested a review from a team as a code owner September 1, 2026 16:07
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • PushSubscription.kt: 2/2 touched executable lines (100.0%) (5 touched lines in diff)
  • UserSwitcher.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • RebuildUserService.kt: 0/16 touched executable lines (0.0%) (30 touched lines in diff)
    • 16 uncovered touched lines in this file
  • CreateSubscriptionOperation.kt: 1/1 touched executable lines (100.0%) (2 touched lines in diff)
  • UpdateSubscriptionOperation.kt: 1/1 touched executable lines (100.0%) (2 touched lines in diff)
  • RefreshUserOperationExecutor.kt: 14/14 touched executable lines (100.0%) (29 touched lines in diff)
  • SubscriptionOperationExecutor.kt: 11/11 touched executable lines (100.0%) (15 touched lines in diff)
  • SubscriptionModel.kt: 5/5 touched executable lines (100.0%) (26 touched lines in diff)

Overall (aggregate gate)

35/51 touched executable lines covered (68.6% — requires ≥ 80%)

Per-file detail (informational; gate is aggregate above):

  • RebuildUserService.kt: 0.0% (16 uncovered touched lines)

❌ Coverage Check Failed

Aggregate coverage on touched lines is 68.6% (minimum 80%).

📥 View workflow run

A push subscription disabled through the REST API (notification_types
-31) was re-enabled by the SDK: RefreshUser discarded the server's
disable state for push, the session-start self-heal re-asserted local
truth over it, and every subscription payload recomputed enabled from
device state.

Mirror the server's disable code on the push model when RefreshUser
reports it, report it back in subscription payloads instead of the
device-derived values, skip the stuck-subscription self-heal for it,
and carry it across the login/logout user switch. The mirror clears
when the server reports any other state and on an explicit optIn().
The 404 recovery paths (user rebuild and update-404 re-create) treat
the dead record's disable as gone and recreate from device truth.

Also remove the mislabeled DISABLED_FROM_REST_API_DEFAULT_REASON(-30)
enum case; no OneSignal API has ever written -30 as a REST disable.
Enum-name persistence now parses leniently in the shared model
accessor, so models and queued operations persisted under an unknown
enum name read as SUBSCRIBED instead of throwing on upgrade.

@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

Models: Claude Opus 5, GPT 5.6 Sol, Cursor Grok 4.6.

The -31 mirror, self-heal skip, payload reporting, optIn() clear, and 404 device-truth recreate are internally consistent. Two holes can still re-enable a REST-disabled push subscription — the bug this PR is meant to close.

Act on

  1. Session-start / queued update wins over hydrate (2/3). getUpdateOperation freezes enabled/status at enqueue time. On first launch of this build, SubscriptionManager.refreshPushSubscriptionState writes a new sdk (NORMAL) while restApiDisabledReason is still 0, enqueueing UpdateSubscriptionOperation(enabled=true, SUBSCRIBED). RefreshUser then records -31, but that already-queued PATCH still re-enables. Same shape for any token/permission write while the GET is in flight. Snapshot at execute time, or drop/rewrite in-flight push updates once -31 is recorded.
  2. 4.x→5.x legacy sync never sets restApiDisabledReason (3/3). createPushSubscriptionFromLegacySync now parses -31 into device status and leaves the new field at 0. addOrUpdatePushSubscriptionToken then overwrites status with SUBSCRIBED, and the next payload is enabled. Seed restApiDisabledReason when isRestApiDisable(notificationTypes).
  3. 404 recovery fallback recreates the dead disable (2/3). If the cached model is missing, recovery uses lastOperation.enabled/status, which can be (false, DISABLED_FROM_REST_API). That contradicts “the dead record’s disable is gone.” Do not reuse -31 from the failed op.

Consider

  • Unknown persisted enum names (including the removed -30 case) fall back to SUBSCRIBED (3/3). Fail-open can re-enable on downgrade; ERROR is safer for model status.
  • notificationTypes == null leaves a recorded -31 in place but does not skip self-heal (3/3). Treat omit as unknown: no clear and no self-heal.
  • optIn() then a still-in-flight RefreshUser GET can write -31 back after the clear (Opus). Public optedIn will not show it.
  • Opt-out (or permission loss) during -31 is never sent; when the server later clears -31, hydrate is HYDRATE so nothing re-asserts local opted-out (Opus).

Noted / dismissed

  • optIn() tests != 0 instead of isRestApiDisable — same today; they diverge if a second code is added.
  • Detekt baseline still lists DISABLED_FROM_REST_API_DEFAULT_REASON$30. One new MagicNumber is unlikely to fail CI (maxIssues: 10); still worth regenerating the baseline.
  • Leftover pre-upgrade self-heal ops: possible if the last PATCH never ran, not “almost certainly” still queued.
  • No RebuildUserService test for the 404 clear; no test that public optedIn stays true while -31 is recorded.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment on lines +258 to 272
) {
val cachedPushSubscriptionModel = _subscriptionsModelStore.get(pushSubscriptionId) ?: return
val serverTypes = serverSubscription.notificationTypes ?: return
// The recorded reason mirrors the server's field: -31 records, any other reported value clears.
val target = if (SubscriptionStatus.isRestApiDisable(serverTypes)) serverTypes else 0
if (cachedPushSubscriptionModel.restApiDisabledReason != target) {
cachedPushSubscriptionModel.setIntProperty(
SubscriptionModel::restApiDisabledReason.name,
target,
ModelChangeTags.HYDRATE,
)
}
}

companion object {

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 (2/3): queued updates still re-enable after this hydrate.

getUpdateOperation snapshots enabled/status at enqueue time. On first launch of this build, SubscriptionManager.refreshPushSubscriptionState writes a new sdk while restApiDisabledReason is still 0, so a NORMAL UpdateSubscriptionOperation(enabled=true, SUBSCRIBED) can already be queued before this GET returns. Hydrate then records -31, but the in-flight PATCH still re-enables the subscription this PR is meant to leave suppressed.

Same race for any token/permission write while RefreshUser is outstanding. Snapshot at execute time, or drop/rewrite in-flight push updates once -31 is recorded.

Also: notificationTypes ?: return leaves a recorded -31 in place, but isRestApiDisable(null) does not skip self-heal. Treat omit as unknown (no clear and no self-heal).

optedIn = currentPushSubscription?.optedIn ?: true
address = currentPushSubscription?.address ?: ""
status = currentPushSubscription?.status ?: SubscriptionStatus.NO_PERMISSION
restApiDisabledReason = currentPushSubscription?.restApiDisabledReason ?: 0

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 copy is good; the 4.x→5.x path in this class is not.

createPushSubscriptionFromLegacySync still never sets restApiDisabledReason. A legacy notification_types == -31 now lands in device status (because fromInt(-31) resolves) with optedIn = true and reason 0. SubscriptionManager.addOrUpdatePushSubscriptionToken then overwrites status with SUBSCRIBED, and the next payload is enabled — the silent re-enable this PR is fixing, on the one entry path that does not populate the new field.

If isRestApiDisable(notificationTypes), set restApiDisabledReason and keep device status as the real device status.

Comment on lines +282 to +284
val (recoveryEnabled, recoveryStatus) =
recoveryModel?.let { SubscriptionModelStoreListener.getSubscriptionEnabledAndStatus(it) }
?: Pair(lastOperation.enabled, lastOperation.status)

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 (2/3): model-missing fallback recreates the dead disable.

Clearing -31 and recreating from device truth only runs when recoveryModel exists. If get(staleSubscriptionId) is null, this falls back to lastOperation.enabled/status, which can be (false, DISABLED_FROM_REST_API) and POSTs the deleted record’s disable back.

When the model is missing, still discard DISABLED_FROM_REST_API rather than reusing the failed op’s pair.


return getEnumProperty(::status.name)
// A persisted name this build's enum lacks reads as SUBSCRIBED instead of throwing.
return getOptEnumProperty<SubscriptionStatus>(::status.name) ?: SubscriptionStatus.SUBSCRIBED

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider (3/3): fail-open fallback.

An unknown persisted name (including the removed DISABLED_FROM_REST_API_DEFAULT_REASON) becomes SUBSCRIBED. With optedIn == true and a token, getSubscriptionEnabledAndStatus reports enabled. That is the same silent re-enable class this PR is fixing, on the upgrade/downgrade path.

ERROR is a safer model-status fallback (isRetryableTokenError == false). The added test only asserts the fallback value, not the resulting payload.

@cursor

cursor Bot commented Sep 1, 2026

Copy link
Copy Markdown

Holistic multi-model review (not just the diff)

Models: Claude Opus 5, GPT 5.6 Sol, Claude Sonnet 5 — findings independently re-checked against the subscription lifecycle, operation queue, and iOS #1731.

Request changes. The -31 overlay, self-heal skip, payload reporting, and optIn() clear are internally consistent. They do not close SDK-5099 on the path this PR ships to: the first launch of this build.

Act on

1. Upgrade session PATCHes enabled=true before RefreshUser hydrates -31 (3/3)

Model.setOptAnyProperty no-ops when a value is unchanged, so this is not every warm start. It is the first session after upgrading to this SDK:

  1. SubscriptionManager subscribes to session at construction; UserRefreshService subscribes later in start().
  2. onSessionStartedrefreshPushSubscriptionState writes the new sdk (NORMAL) → getUpdateOperation freezes enabled=true, SUBSCRIBED because restApiDisabledReason is still 0.
  3. RefreshUser is enqueued after that.
  4. FIFO: the PATCH re-enables on the server; RefreshUser then sees notification_types=1 and never records -31.

Same shape for token rotation while RefreshUser is in flight, and for persisted pre-upgrade ops. SubscriptionOperationExecutor.updateSubscription and LoginUserOperationExecutor.createSubscriptionsFromOperation never re-read the model.

iOS builds PATCH / Create User from a live snapshot (updateParams() / outboundNotificationTypes). Re-resolve push enabled/status at execute time in both Android executors — that is the highest-leverage fix and also closes the races below.

2. 4.x→5.x legacy sync never sets restApiDisabledReason (3/3)

createPushSubscriptionFromLegacySync now parses -31 into device status, leaves optedIn=true and restApiDisabledReason=0. DeviceRegistrationListener.needsPushTokenRefresh sees non-SUBSCRIBED and addOrUpdatePushSubscriptionToken overwrites status with SUBSCRIBED. The overlay that is supposed to protect payloads was never armed.

If isRestApiDisable(notificationTypes), set restApiDisabledReason and keep device status as the real device status.

3. RebuildUserService clears a live disable (2/3); CI coverage 0% on this file

buildPushRecoveryOperation HYDRATE-clears -31 on the live store, then emits CreateSubscriptionOperation with the existing id and device-derived enabled=true. It runs on 404 from GET /users, PATCH /users, alias ops, and create-subscription — none of which independently prove the push row is gone.

That Create groups with LoginUserOperation (createComparisonKey = "$appId.User.$onesignalId") → POST /users with the live id. If it runs alone, updateExistingSubscriptionFromCreate PATCHes the live row.

iOS clears when the subscription ID resets, which is the right trigger. This path has no unit test (RebuildUserService.kt 0% of 16 touched lines; aggregate 68.6% vs 80% gate).

Consider

  • 404 missing-model fallback (3/3): ?: Pair(lastOperation.enabled, lastOperation.status) can recreate (false, DISABLED_FROM_REST_API) — contradicts “the dead record’s disable is gone.”
  • optIn() vs in-flight RefreshUser (2/3): iOS has restApiDisableClearedByUser so a user opt-in outranks a stale GET. Android hydrate writes -31 back; the next token write re-disables the server.
  • optOut() during -31 (2/3): -31 wins over UNSUBSCRIBE. Local optedIn=false is never sent. When the server later clears -31 via HYDRATE, nothing re-asserts the opt-out.
  • notificationTypes == null (3/3): hydrate returns without touching the overlay; self-heal treats null as disabled-not-minus-31 and can still PATCH enabled=true. Treat omit as unknown (no clear, no self-heal).
  • Unknown enum name → SUBSCRIBED (3/3): queued ops persisted as DISABLED_FROM_REST_API_DEFAULT_REASON now dispatch notification_types=1 instead of dropping. ERROR (or keep the raw value) is safer. The new tests only assert the fallback value.
  • SubscriptionModelStore.replaceAll(HYDRATE) copies sdk/deviceOS/carrier/appVersion/status from the existing push model, not restApiDisabledReason. Safe today only because RefreshUser re-adds the same instance.

Product calls (PR asked reviewers to confirm; iOS matches)

  • Public optedIn stays true while the server will not deliver. IPushSubscription KDoc says true means the user is able to receive notifications. Defensible as “user preference vs owner suppression,” but then the KDoc and changelog need to say that; optIn() as the escape hatch is the same on iOS.
  • Login/logout copies restApiDisabledReason onto the same physical subscription id (subscription-scoped, not user-scoped). iOS’s integration test asserts Create User after login still sends enabled: false / -31. Contrast: isDisabledInternally is deliberately not copied.

What is solid

A separate Int overlay is the right shape — status is device-owned and is stomped by every token write. HYDRATE recording persists without an echo op. Hydrate runs before the SDK-4474 self-heal. isRestApiDisable is correctly only -31 (the -30 case was a mislabel). Carrying the overlay across login/logout is required because the same push row is reused. The update-404 path that mints a fresh local id is the one recovery that actually recreates a new record.

Tests that would have caught the Act-on items

Session-start upgrade (sdk write + RefreshUser -31, assert no enabled=true PATCH); execute-time rebase of a queued Update while the model holds -31; legacy notification_types=-31 then token registration; RebuildUserService with a non-local id; LoginUser POST /users body; optIn() vs in-flight RefreshUser; public optedIn stays true.

Traced beyond the 14-file diff: SubscriptionManager.refreshPushSubscriptionState, DeviceRegistrationListener, OperationRepo grouping, LoginUserOperationExecutor payload folding, all RebuildUserService 404 call sites, and SubscriptionModelStore.replaceAll.

externalId: String?,
pushSubscription: SubscriptionModel,
): CreateSubscriptionOperation {
_subscriptionsModelStore.get(pushSubscription.id)?.setIntProperty(

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 (2/3): this clears a live disable, not a dead one.

getRebuildOperationsIfCurrentUser runs on 404 from GET /users, PATCH /users, alias ops, and create-subscription — none of those independently prove this push row is gone. This HYDRATE-clears restApiDisabledReason on the live store, then emits CreateSubscriptionOperation with the existing id and device-derived enabled=true.

That Create groups with LoginUserOperation (createComparisonKey = "$appId.User.$onesignalId") and becomes a POST /users subscription with the live id. If it later runs alone, updateExistingSubscriptionFromCreate PATCHes the live row.

iOS clears when the subscription ID resets. Only clear here when IDManager.isLocalId(pushSubscription.id), or drop the store mutation and let the trailing RefreshUser re-establish truth. This file also has 0% diff coverage (16/16 uncovered), which is why the aggregate gate is 68.6%.

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