Skip to content

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

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

fix: [SDK-5099] respect REST API-disabled push subscriptions#2728
nan-li wants to merge 13 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. 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. A dashboard unsubscribe writes a different code and was undone the same way. Tracked internally as SDK-5099.

Scope

Push subscriptions only. The push model gains a server-owned remoteDisabledReason field that mirrors the server's notification_types. SubscriptionStatus gains MANUALLY_UNSUBSCRIBED(-22) beside DISABLED_FROM_REST_API(-31), and remoteDisableStatus / isRemoteDisable answer for both. RefreshUser records whichever of the two the server reported, verbatim, and clears on any other reported value. While a code is recorded, getSubscriptionEnabledAndStatus reports enabled = false with that same code, so create, update and login payloads preserve what the server sent rather than collapsing both codes into one. The self-heal skips both, and the field is carried across the login/logout user switch. optIn() clears it. Recording and clearing log at DEBUG. The 404 recovery paths (user rebuild and update-404 re-create, with or without the cached model) treat the dead record's disable as gone and recreate from device truth, and replaceAll carries the field across the push model copy.

Public IPushSubscription.optedIn now reports false while a disable is recorded, as does PushSubscriptionState.optedIn, and both doc comments say so. It reads the recorded reason rather than status, which stays device-owned. RefreshUser writes the field with the HYDRATE tag, and nothing between the model and the observer filters on that tag, so onPushSubscriptionChange fires with the new opt-in answer while the store listener still declines to send an update for it. The demo app logs the observer payload.

Also removes the mislabeled DISABLED_FROM_REST_API_DEFAULT_REASON(-30) enum case. No OneSignal API writes -30 as a remote disable, and the docs list -30 among the APNs error codes. The dashboard code is -22, now handled above. 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.

Decisions from review

optedIn now reports false while a remote disable is recorded, which reverses the earlier decision on this branch to keep it to user preference plus app permission. 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. iOS matches. optIn() overrides the suppression. An in-memory flag keeps a fetch that started before the opt-in's update from recording the disable again, which would otherwise be re-sent by the next device update. The user-404 rebuild starts fresh from device truth rather than carrying the disable: an exclusion list is disable-only and re-applied daily, so a born-disabled record would strand users who leave the list. An opt-out recorded during a suppression goes out on the next routine update rather than outranking the recorded disable, consistent with every other offline edit. The legacy 4.x seed is skipped (v4 cached device-derived codes), and the -22 email/SMS misparse is out of scope. Known limit: a device-metadata update queued before RefreshUser on the first launch after a disable (guaranteed once at upgrade) sends enabled: true once, bounded to one sync cycle; omitting those fields from metadata updates was built and reverted as too much branching for that case. Matches the companion PR in OneSignal-iOS-SDK.

Testing

Unit testing

New coverage across six suites. RefreshUser records and mirror-clears both codes and skips the self-heal for them; payload reporting through getSubscriptionEnabledAndStatus echoes the recorded code; optIn() clears it and reports optedIn true again; the user switch carries it; update-404 recovery starts from device truth; RebuildUserService recreates fresh; unknown persisted enum names parse leniently for both models and operations, through initializeFromJson. A new PushSubscriptionObserverTests suite drives a hydrated disable through the model store to a registered observer and asserts the transition the app sees, pinning both codes end to end.

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

@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: 6/6 touched executable lines (100.0%) (13 touched lines in diff)
  • UserSwitcher.kt: 2/2 touched executable lines (100.0%) (3 touched lines in diff)
  • RebuildUserService.kt: 16/16 touched executable lines (100.0%) (30 touched lines in diff)
  • 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: 24/24 touched executable lines (100.0%) (49 touched lines in diff)
  • SubscriptionOperationExecutor.kt: 29/30 touched executable lines (96.7%) (64 touched lines in diff)
  • SubscriptionModel.kt: 10/10 touched executable lines (100.0%) (62 touched lines in diff)
  • SubscriptionModelStore.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)

Overall (aggregate gate)

90/91 touched executable lines covered (98.9% — requires ≥ 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

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

@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
The update-404 recovery now starts from device truth even when the
cached model is missing, replaceAll carries restApiDisabledReason
across the push model copy, and the user-404 rebuild is covered by
tests. IPushSubscription.optedIn documents that it reflects the user's
preference and permission rather than a server-side disable, and the
detekt baseline is regenerated.

A session-start device-metadata write that precedes RefreshUser can
still send enabled=true once before the server's disable is learned;
that bounded window is accepted, matching iOS.
@nan-li

nan-li commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

As mentioned in the ios equivalent PR: 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.

Many review comments are 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 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

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

Items 1 to 3 are answered on their own threads.

On the rest. If a stored status is one this version doesn't recognize, we treat it as subscribed, see the SubscriptionModel thread. If the server's reply leaves out notification_types, nothing turns back on, because once a disable is recorded the SDK already treats itself as disabled. If the user opts in while we're still waiting on the server, the opt-in still gets sent, because it was queued as enabled before the reply came back. The old disable can sit locally until the next fetch, then it clears. If the user opts out while a REST disable is in effect, the opt-out goes out with the next regular update. Same as any change made offline, the most recent change wins.

Smaller notes. The detekt baseline is regenerated. Updates left over from before the upgrade are the same one-time gap. RebuildUserService tests are in 9807ac1. There's no test that optedIn stays true while a disable is recorded, the doc comment covers it.

@nan-li

nan-li commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

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.

  1. We accept that on the first launch after upgrading, one routine update can turn the subscription back on before the SDK learns about the disable. Rebuilding that update right before sending wouldn't help, because the call that learns about the disable runs after it. I tried leaving the enabled fields out of routine updates and backed it out as too much branching for a gap the next daily sync closes.
  2. Skipping. The 4.x cache only stores what the device said about itself, never anything the server said, so a REST disable can't be in there.
  3. Starting fresh after a deleted user is on purpose, see the RebuildUserService thread. Tests are in 9807ac1 and that file is now fully covered.

Smaller items. The missing-model case is fixed and replaceAll now keeps the field, both in 9807ac1. An opt-in during a pending fetch still goes out. An opt-out during a disable goes out with the next regular update. A reply missing notification_types doesn't turn anything back on. Unknown status is treated as subscribed.

Product calls, yes to both. optedIn means the user's choice plus OS permission only, and the doc comment says so on both platforms. Keeping the disable across login and logout is intended because the same push record is reused.

Keep both detekt baseline LongMethod entries from main after the
CoreModule and FeatureFlagsBackendService changes landed there.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

SDK review — request changes

Fan-out: Opus + Grok + GPT, cross-checked against iOS #1731.

The -31 mirror, self-heal skip, payload echo, 404/rebuild device-truth recreate, and login carry are internally consistent. First-session PATCH-before-hydrate is inherent and not re-filed.

Please address (also inline):

  1. optIn() has no stale-hydrate latch. A RefreshUser GET started while the server still reported -31 re-records it under HYDRATE. The next token/metadata update can PATCH -31 and undo opt-in. iOS already has restApiDisableClearedByUser and testRestApiDisable_optInOutranksStaleHydration.
  2. Missing-model 404 recovery can revive an opt-out. freshStartWithoutDeadDisable maps any -31 op to (true, SUBSCRIBED), including when local optedIn is false. Reacquire device preference before recovery.

optIn() always sends a subscription update, but a RefreshUser fetch queued
before that update still reports the disable the opt-in cleared. Recording
that stale -31 again meant the next device update re-sent it and the next
fetch read it back, silently undoing the opt-in.

The push model now carries an in-memory flag set by optIn() and cleared when
the server reports any state other than a REST API disable. While it is set,
RefreshUser leaves the recorded reason alone. The flag arms on every opt-in
rather than only when a disable was already recorded, because the same race
exists on the first fetch after the customer disables the subscription.
@fadi-george

Copy link
Copy Markdown
Contributor

Notification type -22 (from dashboard) is not handled.

@fadi-george

fadi-george commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Woks for rest api disabling.

small nit: after setting to unsubscribed via rest api and doing cold-start on demo. the enabled toggle is still shown as enabled (visual bug).

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. SubscriptionModel.remoteDisabledReason records
whichever one the server sent, and SubscriptionModelStoreListener maps it
back to its own status rather than reporting every remote disable as -31.
The widened check also covers the optedIn derivation on refresh, the
stuck-subscription self-heal guard, and the dead-record recovery in
SubscriptionOperationExecutor.

Renamed isRestApiDisable to isRemoteDisable and restApiDisabledReason to
remoteDisabledReason, since "REST API" no longer describes the concept. The
property name doubles as the persistence key, but no release has written the
old name, so this needs no migration.
hydrateRemoteDisableState 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 the value actually changes.
optedIn documented that "the user is able to receive notifications
through this subscription", and the public reference says it returns
true when the subscription status is subscribed. Neither holds once the
SDK respects a remote disable, because the disable now sticks instead of
being flipped back on by the next routine update, and nothing else in
the public API reveals it. An app syncing preferences through the REST
API would read "subscribed" forever on a device receiving nothing.

The disable is read from the model's recorded reason rather than its
status, which stays device-owned, so a device-recoverable error code
still reports opted in. optIn() already clears the reason, so a
preference-center toggle reading false is not a dead end. The push
observer already fires on the hydration write, so its payload now
carries a real optedIn transition instead of an unchanged pair.
The observer callback goes out through Dispatchers.Main, so nothing in
this repo covered an IPushSubscriptionObserver receiving a change. That
left the delivery half of the remote disable untested: the payload was
pinned through refreshState, but not the path from a model write to the
app's callback.

This installs a test main dispatcher in its own spec, wires a real
SubscriptionModelStore to a SubscriptionManager, and asserts that a
HYDRATE-tagged write of the server's disable code reaches an attached
observer as optedIn true to false. Kept separate from
SubscriptionManagerTests so the dispatcher swap does not touch the other
specs in that file.
@fadi-george

Copy link
Copy Markdown
Contributor

Disabled from dashboard seems to work now.

@fadi-george fadi-george 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.

Potential issues:

  • remoteDisableClearedByUser is set by every optIn() call but is only cleared after RefreshUser observes a non-disabled server state. If the server is legitimately disabled again before that happens, all subsequent -22/-31 responses are ignored for the lifetime of the process. Could this guard instead be tied to completion or ordering of the opt-in update?
  • On session start, SDK/app metadata changes can enqueue an UpdateSubscriptionOperation with enabled=true before RefreshUser hydrates remoteDisabledReason. Hydration uses the HYDRATE tag, so it does not enqueue a corrective update. The earlier PATCH can therefore re-enable the subscription. This seems especially problematic for dashboard -22, since it will not be automatically reapplied and is not limited to one sync cycle.
  • While remoteDisabledReason is present, getSubscriptionEnabledAndStatus() gives it precedence over local optedIn=false. An optOut() therefore sends -22/-31 rather than UNSUBSCRIBE. If the subscription is later enabled remotely, RefreshUser clears the reason using HYDRATE, leaving the server enabled without reasserting the local opt-out.
  • UserSwitcher.createAndSwitchToNewUser() copies remoteDisabledReason, but not remoteDisableClearedByUser. If login/logout occurs while an opt-in update and an older RefreshUser request overlap, the replacement model can record the stale disable and undo the opt-in.

So we can see what info changed
Device metadata written at session start enqueues a subscription update
before RefreshUser knows about a disable, and the operation freezes its
enabled at that point. Recording the disable under HYDRATE generated no
operation of its own, so the queued update re-enabled the subscription and
the next fetch cleared the local record to match. A dashboard unsubscribe
never recovered from that on its own, unlike a REST API disable that the
integration re-sends on its next sync.

Record under NORMAL so the write produces its own update. Executors read the
last operation in a merged batch, so the correction replaces the stale one
while it is still queued, and re-disables the subscription when it already
went out.

Clearing the record now generates an update too. That is the first chance to
send an opt-out made while the disable was suppressing every payload.
remoteDisableClearedByUser armed on every optIn() and only came down when a
fetch reported a non-disabled state. If the app owner disabled the
subscription again before that arrived, every later -22 and -31 was
discarded for the rest of the process.

Clear it once the opt-in's write has been sent. The operation repo runs one
batch at a time, so a fetch issued after that point reports current state
and the disable it carries has to be recorded. A retry or an auth failure
means the write is still coming, so the guard stays armed.

Also covers the merge precedence the previous commit depends on. Nothing
asserted which values a batch holding both a stale enabled and a corrective
disable actually sends.
createAndSwitchToNewUser copied remoteDisabledReason but not
remoteDisableClearedByUser, which lives in memory rather than as a model
property and so does not travel with the copy beside it. A login or logout
landing between an opt-in and its write handed the replacement model a
cleared guard, letting an older fetch record the disable the opt-in had
just cleared.
@nan-li

nan-li commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fixed in three commits, one per item.

  1. Guard sticking (e090781): It now clears as soon as the opt-in's write
    reaches the server. If that write fails and retries, the guard stays. Tested both ways.

  2. Queued PATCH re-enabling (c931c81): Recording the disable is NORMAL now,
    not HYDRATE, so it sends its own update. Executors use the last op in a merged
    batch, so the correction beats the stale one while it's queued, or re-disables
    if it already went out.

  3. optOut during a disable (c931c81). The bad part is fixed. Clearing the reason also sends an update, so the opt-out goes out as soon as the disable lifts, as enabled=false with UNSUBSCRIBE. There's a test for that.
    I did not change the precedence, so an optOut while a disable is recorded keeps sending the server's own code, -22 or -31, not -2. We never overwrite the reason. The -2 goes out later, when the disable lifts and we clear the record. That's deliberate. There's only one notification_types field and it has to carry two different things, so if an optOut did overwrite it with -2, the next fetch would see a code that isn't a disable and we'd clear our record thinking the owner lifted it when really we overwrote it. We'd lose the only signal that tells us the disable is gone. Either way the opt-out wins in the end.

  4. UserSwitcher (df93138). Copies the flag now. It's in memory, not a model property, so it didn't come along

SDK-5128 rewrote DemoLog to hold its own tag, so its methods take a message
and nothing else, and it dropped the TAG constant from MainViewModel. The
onPushSubscriptionChange log this branch added still passed a tag. Neither
side conflicted textually, since they touched different lines, so the merge
compiled on each branch alone and failed only once combined. Drop the tag
argument from that one call.
@nan-li
nan-li requested a review from fadi-george September 10, 2026 15:20
@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?

  1. It's a real race on paper, but here's what it takes to hit it. The SDK already knows about a disable from an earlier session. Something about the device changed this session, like an app update, so there's an older update to send at all. And the user taps opt-in while that older update is on the wire, which starts about five seconds after launch and lasts one network round trip. Tap earlier and the opt-in goes out in the same request and wins. Tap later and the guard is already set before we ask the server. If it does hit, the tap is undone once, the toggle shows off, and tapping again works. Nothing is lost and the app owner doesn't have to do anything. I'd rather not add code for that. If you still want it closed, say so.
  2. Agreed. My fix only helps when we ask the server before the older update goes out, and at session start it's the other way around. Fixing that means changing the order we queue things at session start, and iOS has the same gap in a different form. I'm filing it as a follow-up for both platforms, with the test you asked for, rather than adding it here. What's left on this PR only happens on sessions where something about the device changed. Before this PR it happened on every session. If a dashboard unsubscribe getting undone on an app-update session should block this, tell me and I'll pull the change in. I've filed SDK-5194 for both platforms.

Stepping back, we're chasing gaps one round at a time. The SDK writes while offline and the server keeps whatever it hears last, so the two will never be perfectly in sync. I'd rather name the gaps we accept and merge on that than keep patching orderings. After this PR, they are: updates left in the queue from an older SDK build go out once; a new push token or a login that lands before the first server check after a disable; an opt-out during a disable sends the disable code until the disable lifts; recreating a subscription the server deleted, when we have nothing local, starts it subscribed. Each happens once. If that list is the bar, I'd like to merge on it.

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

Nits only on this HEAD. The -22/-31 mirror, optedIn, observers, and 404/login carry look consistent with iOS.

Fadi's settleOptInGuard item is still the open request. Not re-raising it.

Small split: public optedIn tests remoteDisabledReason == 0, payloads use isRemoteDisable. Same today, they diverge if a third code is added.

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