Skip to content

feat: [SDK-5048] call KMP features API and wire flags into OSFeatureManager - #1723

Merged
abdulraqeeb33 merged 6 commits into
mainfrom
ar/sdk-5048
Aug 25, 2026
Merged

feat: [SDK-5048] call KMP features API and wire flags into OSFeatureManager#1723
abdulraqeeb33 merged 6 commits into
mainfrom
ar/sdk-5048

Conversation

@abdulraqeeb33

@abdulraqeeb33 abdulraqeeb33 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Wires iOS to the shared KMP Turbine feature-flags client so flag semantics cannot drift from Android. KMP owns path building, app-id/SDK-version validation, parsing, and activation-mode latching; the iOS host supplies only platform concerns.

Layer Android iOS (this PR)
HTTP hook FeatureFlagsHttpAdapter wrapping IHttpClient OSFeatureFlagsHttpAdapter (OneSignalRequest for URL/headers + private URLSession)
Backend FeatureFlagsBackendService OSFeatureFlagsBackendService — same 4xx WARN / 5xx DEBUG severities
Persistence ConfigModel.sdkRemoteFeatureFlags OSFeatureFlagsStore (shared UserDefaults)
Latch host FeatureManager OSFeatureManager / OSFeatureManagerImpl
Lifecycle FeatureFlagsRefreshService OSFeatureFlagsRefreshService — foreground fetch, 8 min poll, app-id dedupe

Request shape is GET apps/{app_id}/sdk/features/ios/{sdk_version}. Remote logging's featureFlags provider now reports the real enabled-key list instead of [], read through a non-forcing accessor so the crash handler never triggers first-touch construction.

Also bumps the OneSignal-KMP-SDK submodule to v0.3.0 for the com.onesignal.features package (a clean forward move from main's 17dcabac).

Independent of #1722 (SDK-5047) — based directly on main. The only file both touch is the one-line featureFlags provider hook in OSRemoteLoggingController, which exists in both versions, so whichever merges second needs a one-line conflict resolution.

Review findings and resolutions

An adversarial three-model review flagged seven issues on the initial wiring. All are now fixed, across 23955111 (startup, reset, lifecycle) and 67f95c91 (foreground contract, transport errors, tests).

# Finding Resolution
1 Prewarm latched APP_STARTUP flags off for the whole process: startup sat above the guard that defers LA/IAM, so an empty pre-unlock UserDefaults read committed sdk_custom_logging = false permanently. Startup moved behind the same prewarm guard and re-driven from the protected-data recovery path. Remote logging reads through enabledFeatureKeysIfInitialized(), closing the configureFromCache and crash-handler hydration paths.
2 App-id change cleared every other app-scoped artifact but not the flags: app B could run on app A's sdk_identity_verification. OSFeatureManager.shared is resettable; handleAppIdChange now calls resetAndClearCachedFlags and OSFeatureFlagsRefreshService.reset, which drops the latch and the cached keys. OSFeatureFlagsStore.clear() finally has a caller.
3 notificationTokens was iterated and cleared on the caller's thread while observe() appended on ioQueue — concurrent Array mutation, so buffer corruption rather than a lost update. The token list moves under the same stateLock as the rest of the mutable state; deregistration happens outside the lock.
4 isInForegroundProvider defaulted to { true }, making the documented foreground-only contract inert — a background launch started the poll loop. The service tracks foreground state itself, defaulting to false and driven by the lifecycle notifications it already observed. OneSignalOSCore is extension-safe and cannot read UIApplication, so the host seeds it via start(isInForeground:).
5 notifyAppIdMayHaveChanged() had no production caller — only tests. Folded into onFocus, which re-drives polling on every focus event. Refocusing is exactly when an app-id change needs picking up, so the two collapse into one reachable path.
6 Transport errors collapsed to status=0, body=<empty>, so offline, DNS, TLS, and timeout were indistinguishable and the localizedDescription branch was unreachable. The error text now travels as the body. Also drops disableLocalCaching, which only OneSignalClient reads and this path bypasses — the private session already sets reloadIgnoringLocalCacheData.
7 The self-rescheduling poll was untested: the fake queue's asyncAfterTime was a no-op, so reschedule, generation cancellation, and unfocus-stops-loop went unexercised. The fake queue holds deferred work until a test releases it, exercising all three plus background launches, multi-scene backgrounding, and the wedged-dedupe-key regression.

Two changes beyond the findings, both fallout from #4:

  • +init can run off the main queue, where applicationState is unreadable. Feature-flag startup now starts there anyway to register observers, then re-seeds from the main thread. Without that second pass, an off-main init while the app was already active would see "backgrounded" and wait for a foreground transition that may never come this session.
  • poll reads the app id per iteration instead of capturing it, so a mid-loop change can no longer keep fetching the previous app's flags.
  • Removed the no-arg start(): it had no callers, and a public entry point that silently means "assume backgrounded" is a trap.

Test plan

  • OneSignalOSCoreTests — 93/93 pass, including 28 feature-flag tests
  • Full UnitTestApp_TestPlan_Reduced passes locally (the OneSignal.m startup change touches the main SDK target)
  • OneSignalOSCore, OneSignalFramework, and OneSignalExample all build for simulator
  • KMP XCFramework rebuilds against v0.3.0
  • Run the dev app against a live app id and confirm a real Turbine fetch applies flags
  • Reconcile with the JWT branch's separate OSFeatureManager (different catalog, API, and OSUD_SDK_FEATURE_FLAGS cache key)

…anager

iOS now consumes the shared Turbine feature-flags client the same way Android
does, so flag semantics cannot drift between platforms: KMP owns path building,
app-id/SDK-version validation, response parsing, and activation-mode latching,
while the host supplies HTTP, persistence, and foreground lifecycle.

Bumps the KMP submodule to v0.3.0 for the com.onesignal.features package, and
replaces the placeholder empty feature-flag provider in remote logging with the
real enabled-key list.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 changed the base branch from ar/sdk-5047 to main August 21, 2026 19:40
@abdulraqeeb33
abdulraqeeb33 marked this pull request as ready for review August 21, 2026 20:04
@abdulraqeeb33
abdulraqeeb33 requested a review from a team August 21, 2026 20:35
@onesignal-deploy

Copy link
Copy Markdown
Collaborator

I think this should remain a draft until these issues are resolved:

  1. Prewarm can read protected UserDefaults as empty and permanently latch APP_STARTUP flags off.
  2. App ID changes retain the previous app’s cached flags and startup latch.
  3. reset() races observer registration through notificationTokens.
  4. The production foreground provider always returns true, allowing background polling.
  5. In multi-scene apps, backgrounding one scene stops polling for all scenes.
  6. try! XCTUnwrap(outcome) violates the SwiftLint force_try rule.

Transport errors are also reduced to status 0, which loses useful diagnostics.

AR Abdul Azeez and others added 2 commits August 24, 2026 12:55
Addresses review feedback on the initial wiring.
Defers feature-flag startup behind the same prewarm guard as Live Activities
and In-App Messages, and re-drives it from the protected-data recovery path, so
an unreadable UserDefaults read can no longer latch APP_STARTUP flags off for
the whole process. Remote logging now reads flags through a non-forcing
accessor, closing both the earlier hydration path through configureFromCache
and the crash-handler exposure, since first-touch construction takes locks and
reads storage.
Makes the manager resettable so an app-id change drops the latch and the cached
keys instead of running the new app on the previous app's flags. Guards the
notification token list with the same lock as the rest of the polling state,
releases the dedupe key on every early return so the loop cannot wedge
permanently, and counts scenes so one backgrounded window no longer stops
refreshing app-wide.
Replaces try! XCTUnwrap with a throwing helper to satisfy SwiftLint force_try.
… failures

Closes the remaining review findings on the feature-flags wiring.

`isInForegroundProvider` defaulted to `{ true }`, so the documented
foreground-only contract was inert and a background launch (silent push,
background fetch, prewarm) would start the 8-minute poll loop. The service now
tracks foreground state itself, defaulting to false and driven by the same
lifecycle notifications it already observed. OneSignalOSCore is extension-safe
and cannot read `UIApplication`, so the host seeds the initial value through
`start(isInForeground:)`. `+init` can run off the main queue where
`applicationState` is unreadable, so that call starts the service anyway to
register observers and then re-seeds from the main thread; without the second
pass an off-main init while the app was already active would wait for a
foreground transition that may never come.

Folds `notifyAppIdMayHaveChanged` into `onFocus`, which now re-drives polling
on every focus event. It had no production caller, and refocusing is exactly
when an app-id change needs picking up, so the two collapse into one path that
is actually reachable. `poll` reads the app id per iteration instead of
capturing it, so a mid-loop change can no longer keep fetching the old app's
flags.

Transport failures carried no status and an empty body, collapsing offline,
DNS, TLS, and timeout into an indistinguishable `status=0 body=<empty>` and
leaving the `localizedDescription` branch unreachable. The error text now
travels as the body. Drops `disableLocalCaching`, which only `OneSignalClient`
reads and this path bypasses; the private session already sets
`reloadIgnoringLocalCacheData`.

Removes the no-arg `start()`: it has no callers, and a public entry point that
silently means "assume backgrounded" is a trap.

Tests: the fake queue now holds deferred work until a test releases it, so the
self-rescheduling loop, generation cancellation, unfocus-stops-the-loop,
background launches, multi-scene backgrounding, and the wedged-dedupe-key
regression are all exercised. 93/93 pass.

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

Copy link
Copy Markdown
Contributor Author

All seven are now fixed, across 23955111 (startup, reset, lifecycle) and 67f95c91 (foreground contract, transport diagnostics, tests). Taking them in order:

1. Prewarm latching APP_STARTUP flags off. Startup moved behind the same prewarm guard that already defers Live Activities and In-App Messages, and it is re-driven from the protected-data recovery path, so an unreadable UserDefaults read can no longer pin flags off for the process. Remote logging now reads through enabledFeatureKeysIfInitialized(), which closes the two remaining hydration paths — configureFromCache and the crash handler, where first-touch construction would take locks and hit storage on a crashing thread.

2. App-id change retaining the previous app's flags and latch. OSFeatureManager.shared is resettable, and handleAppIdChange now calls resetAndClearCachedFlags plus OSFeatureFlagsRefreshService.reset, dropping both the latch and the cached keys. OSFeatureFlagsStore.clear() finally has a caller.

3. reset() racing observer registration. Worth flagging that this was more than a lost update: stopPolling() iterated and cleared notificationTokens on the caller's thread while observe() appended on ioQueue, so concurrent Array mutation risked buffer corruption. The token list now moves under the same stateLock as the rest of the mutable state, with deregistration outside the lock.

4. Foreground provider always returning true. The service tracks foreground state itself now, defaulting to false and driven by the lifecycle notifications it already observed. OneSignalOSCore is extension-safe and so cannot read UIApplication, so the host seeds the initial value via start(isInForeground:).

Fixing this surfaced a second-order bug worth calling out, since it is the same class of failure the fix was closing. applicationState is main-thread-only, but +init can run off the main queue, so the seeding helper had to answer "backgrounded" when off-main. That meant an off-main init while the app was already active would seed NO and then wait for a didBecomeActive that never fires — because the app is already active — silently never loading flags for that whole session. Startup now registers observers regardless and re-seeds from the main thread, only ever re-seeding to YES so it cannot clobber a focus event that landed first.

5. One backgrounded scene stopping polling app-wide. Foregrounded scenes are counted, and only the last one leaving stops the loop. The count is floored at zero rather than trusted absolutely, since we only observe activations after registering and can start below the true number of live scenes. Covered by testBackgroundingOneOfTwoScenesKeepsPolling and testBackgroundingTheLastSceneStopsPolling.

6. try! XCTUnwrap violating force_try. Replaced with a throwing helper. SwiftLint is clean on the file.

Transport errors collapsing to status 0. Agreed, and this one also made the error?.localizedDescription branch unreachable, so offline, DNS, TLS, and timeout were indistinguishable in the field. The error text now travels as the body. While in there I also dropped disableLocalCaching, which only OneSignalClient reads and this path deliberately bypasses — the private session already sets reloadIgnoringLocalCacheData, so the flag was decorative.

Two related cleanups: poll reads the app id per iteration instead of capturing it, so a mid-loop change can no longer keep fetching the previous app's flags; and notifyAppIdMayHaveChanged() is folded into onFocus, since it had no production caller and refocusing is exactly when an app-id change needs picking up.

On the draft question — the self-rescheduling poll loop was the thing I was least willing to undraft without, since the fake queue's asyncAfterTime was a no-op and so reschedule, generation cancellation, and unfocus-stops-loop were all unexercised. The queue now holds deferred work until a test releases it, which covers those three plus background launches, multi-scene backgrounding, and a wedged-dedupe-key regression. 28 feature-flag tests, 93/93 in OneSignalOSCoreTests, and I ran the full UnitTestApp_TestPlan_Reduced locally as well since the OneSignal.m startup change touches the main SDK target. All four CI checks are green.

Two items remain open and are tracked as unchecked in the test plan: a live-app-id smoke test confirming a real Turbine fetch applies flags, and reconciling with the JWT branch's separate OSFeatureManager (#1722), which uses a different catalog, API, and cache key. That second one is the one I would want settled before merge — whichever of the two lands second inherits it, on top of the known one-line conflict in the featureFlags provider hook.

@fadi-george

Copy link
Copy Markdown
Collaborator

A few lifecycle races still appear unresolved:

  1. OSFeatureFlagsRefreshService.reset() can be followed by an already queued startPolling(). observe() can also append a token after reset drained the list, leaving observers and duplicate pollers behind.
  2. activeSceneCount misses scenes already active when observers register and overcounts repeated activations without a matching background event. The tests only cover balanced synthetic events.
  3. OSFeatureManager.shared constructs outside its lock, so a concurrent reset can complete before an old-app manager publishes itself, restoring the stale startup latch.

The foreground gating, transport diagnostics, and polling tests otherwise look improved.

AR Abdul Azeez and others added 2 commits August 24, 2026 14:48
Addresses review feedback on the reset and scene-tracking paths.

`OSFeatureManager` built the shared instance outside its lock so construction
could log without re-entering it, on the reasoning that a lost race merely
discards a redundant instance. That misses the reset interleaving: a manager
that read storage before an app-id change would find `_shared` still nil
afterwards and publish itself, restoring the `APP_STARTUP` latch that the reset
existed to drop. Construction stays outside the lock, but publication is now
guarded by a generation stamped before the read, so a manager whose storage
snapshot has been invalidated is discarded and the read retried.

`stopPolling` now marks the instance invalidated, and the work that can restart
a loop consults that flag. Previously a `startPolling` already queued on
`ioQueue` when a reset landed would re-register lifecycle observers and start
polling on an instance `shared` had already dropped, leaving a second poller
and observers nothing could reach to remove. `restartForegroundPolling` checks
the flag inside the same lock that claims a generation, so a reset cannot be
overtaken by work that passed an earlier check. `observe` also tears down its
own token when a reset lands between registering and recording it, the one
window where nothing else holds the token.

Replaces scene counting with the app-level lifecycle notifications in both
scene and non-scene apps. The counter could not be made correct: it only saw
activations from registration onward, so scenes already active were invisible
and the first background event stopped polling app-wide, and repeated
activations without an intervening background event inflated it so polling
outlived the foreground. UIKit already answers the exact question being asked —
`didEnterBackgroundNotification` posts only once the last scene backgrounds,
and `didBecomeActiveNotification` when the app becomes active again — so the
aggregation belongs to the OS rather than to a counter here. This also drops
the `usesScenes` dependency from the service.

Tests: 97/97. Adds coverage for a reset racing a queued start, a reset racing
observer registration, observer teardown asserted rather than inferred,
repeated activation still stopping on one background event, and a reset landing
mid-construction in the manager.

Co-authored-by: Cursor <cursoragent@cursor.com>
The single file had grown past the 400-line SwiftLint limit as the refresh
service picked up lifecycle coverage. Moves the refresh-service cases and their
fakes into their own file, leaving the manager and backend cases behind, so both
sit well under the limit and each file covers one unit. `StubFeatureFlagsHttp`
is shared by both and becomes internal.

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

Copy link
Copy Markdown
Contributor Author

Thanks — all three were real, and all three are now fixed in 941bca3d. Each one had a test that fails against the old code and passes against the new, verified by reverting the fix and re-running rather than by inspection.

1. reset() racing a queued startPolling(). Confirmed. stopPolling runs on the caller's thread while startPolling's block sits on ioQueue, so a reset landing in between left the dropped instance re-registering observers and starting a loop — a second poller, plus observers nothing could reach to remove, since _shared no longer pointed at it.

stopPolling now marks the instance invalidated, and every path that can start work consults it. The check in restartForegroundPolling lives inside the same lock acquisition that claims a poll generation, so a reset can't be overtaken by work that passed an earlier check and then stalled.

Your second point was the subtler one and needed its own fix: observe registers with the notification center and then records the token, and in that window nothing else holds it. Guarding the array alone doesn't help — the token has to be torn down by observe itself, which it now does when it finds the service invalidated. testObserverRegisteredDuringAResetIsTornDown drives the reset from inside addObserver to hit exactly that window, and asserts on a live-observer count from a NotificationCenter subclass rather than inferring absence from "no fetch happened," which would have passed even with a leaked observer.

2. activeSceneCount. Confirmed, both halves, and I don't think the counter was fixable. Seeding it correctly needs the set of already-active scenes, which means UIApplication.connectedScenes — unavailable here, because OneSignalOSCore builds with APPLICATION_EXTENSION_API_ONLY = YES and so can't touch UIApplication.shared at all. That constraint is also why the host injects foreground state rather than the service reading it. Tracking scene identity instead of a count would have fixed the overcount but not the missing-scenes half.

So I removed the counting. UIKit already computes this exact aggregate: didEnterBackgroundNotification posts only once the last scene backgrounds, and didBecomeActiveNotification when the app becomes active again. The service now observes the app-level notifications in both scene and non-scene apps, and usesScenes is gone from it. Apple documents the pairing on sceneDidEnterBackground ("UIKit posts a didEnterBackgroundNotification notification from UIApplication and UIScene"), and it matches what WWDC19 session 212 describes for the app-level lifecycle under scenes.

Worth being explicit that this trades away per-scene granularity, which I think is right here: the question polling asks is "is any part of this app foreground," not "which window moved." The old per-scene path was answering a question the poller never had. OneSignalLifecycleObserver still uses scene notifications, correctly — it tracks focus time, where the distinction matters.

3. OSFeatureManager.shared constructing outside its lock. Confirmed, and the existing comment was actively wrong: it reasoned that "a lost race just discards the extra instance," but in the reset interleaving the stale instance isn't discarded, it's published. A manager that read storage before the app-id change finds _shared still nil afterwards and installs itself, restoring the APP_STARTUP latch the reset existed to drop.

Construction still has to happen outside the lock — it logs, and logging reaches back through the feature-flag provider, so holding the lock across it risks re-entering it on the same thread. Instead, publication is now guarded by a generation stamped before the storage read; reset() bumps it, and a builder whose snapshot has been invalidated discards its instance and reads again. testResetDuringConstructionDiscardsTheStaleManager lands a reset inside that window through a test-only hook, following the localFeatureOverrides precedent already in the class.

Also in this push: the test file had grown past the 400-line SwiftLint limit, so b1a528dc splits the refresh-service cases into their own file. That's a pure move plus the project.pbxproj entries.

97/97 in OneSignalOSCoreTests. One note on the full UnitTestApp_TestPlan_Reduced: testBasicCombiningUserUpdateDeltas_resultsInOneRequest in OneSignalUserTests fails intermittently for me locally, but it does so on main without any of these changes too — I checked before assuming — and it passes on CI. Flaky and unrelated, not something this branch introduced.

…torage

The reset-during-construction test proved the discard by writing a flag to the
shared store and asserting the published manager did not carry the latch. That
worked, but it wrote to the real app-group UserDefaults from a unit test, which
is avoidable cross-test residue. Counting constructions asserts the same
invariant — build, discard, build again — and touches no storage. Still fails
against the unguarded publication path.

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

Copy link
Copy Markdown
Contributor Author

Follow-up on the CI failure that appeared after my last comment: it was OneSignalUserTests.testBasicCombiningUserUpdateDeltas_resultsInOneRequest, and it is flaky rather than a regression here. Re-running that job against the identical commit, with no code change, passed. None of the feature-flag tests were involved, and the most recent commit touching that file is #1719, "fix flakey tests".

80b722e4 also reworks the reset-during-construction test. The first version proved the discard by writing a flag to the shared store and asserting the published manager didn't carry the latch, which meant a unit test writing to the real app-group UserDefaults — avoidable residue, and not something I want in a suite that already has timing-sensitive neighbours. Counting constructions asserts the same invariant (build, discard, build again) and touches no storage. Still fails against the unguarded publication path.

All four checks green on 80b722e4.

@abdulraqeeb33
abdulraqeeb33 requested a review from a team August 24, 2026 20:51
Comment thread iOS_SDK/OneSignalSDK/Source/OneSignal.m
@abdulraqeeb33
abdulraqeeb33 merged commit b11d86c into main Aug 25, 2026
4 checks passed
@abdulraqeeb33
abdulraqeeb33 deleted the ar/sdk-5048 branch August 25, 2026 15:24
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.

4 participants