feat: [SDK-5048] call KMP features API and wire flags into OSFeatureManager - #1723
Conversation
…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>
620d803 to
3d32109
Compare
|
I think this should remain a draft until these issues are resolved:
Transport errors are also reduced to status |
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>
|
All seven are now fixed, across 1. Prewarm latching 2. App-id change retaining the previous app's flags and latch. 3. 4. Foreground provider always returning Fixing this surfaced a second-order bug worth calling out, since it is the same class of failure the fix was closing. 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 6. Transport errors collapsing to status Two related cleanups: On the draft question — the self-rescheduling poll loop was the thing I was least willing to undraft without, since the fake queue's 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 |
|
A few lifecycle races still appear unresolved:
The foreground gating, transport diagnostics, and polling tests otherwise look improved. |
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>
|
Thanks — all three were real, and all three are now fixed in 1.
Your second point was the subtler one and needed its own fix: 2. So I removed the counting. UIKit already computes this exact aggregate: 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. 3. 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; Also in this push: the test file had grown past the 400-line SwiftLint limit, so 97/97 in |
…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>
|
Follow-up on the CI failure that appeared after my last comment: it was
All four checks green on |
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.
FeatureFlagsHttpAdapterwrappingIHttpClientOSFeatureFlagsHttpAdapter(OneSignalRequestfor URL/headers + privateURLSession)FeatureFlagsBackendServiceOSFeatureFlagsBackendService— same 4xx WARN / 5xx DEBUG severitiesConfigModel.sdkRemoteFeatureFlagsOSFeatureFlagsStore(sharedUserDefaults)FeatureManagerOSFeatureManager/OSFeatureManagerImplFeatureFlagsRefreshServiceOSFeatureFlagsRefreshService— foreground fetch, 8 min poll, app-id dedupeRequest shape is
GET apps/{app_id}/sdk/features/ios/{sdk_version}. Remote logging'sfeatureFlagsprovider 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-SDKsubmodule to v0.3.0 for thecom.onesignal.featurespackage (a clean forward move from main's17dcabac).Independent of #1722 (SDK-5047) — based directly on
main. The only file both touch is the one-linefeatureFlagsprovider hook inOSRemoteLoggingController, 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) and67f95c91(foreground contract, transport errors, tests).APP_STARTUPflags off for the whole process: startup sat above the guard that defers LA/IAM, so an empty pre-unlockUserDefaultsread committedsdk_custom_logging = falsepermanently.enabledFeatureKeysIfInitialized(), closing theconfigureFromCacheand crash-handler hydration paths.sdk_identity_verification.OSFeatureManager.sharedis resettable;handleAppIdChangenow callsresetAndClearCachedFlagsandOSFeatureFlagsRefreshService.reset, which drops the latch and the cached keys.OSFeatureFlagsStore.clear()finally has a caller.notificationTokenswas iterated and cleared on the caller's thread whileobserve()appended onioQueue— concurrentArraymutation, so buffer corruption rather than a lost update.stateLockas the rest of the mutable state; deregistration happens outside the lock.isInForegroundProviderdefaulted to{ true }, making the documented foreground-only contract inert — a background launch started the poll loop.falseand driven by the lifecycle notifications it already observed. OneSignalOSCore is extension-safe and cannot readUIApplication, so the host seeds it viastart(isInForeground:).notifyAppIdMayHaveChanged()had no production caller — only tests.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.status=0,body=<empty>, so offline, DNS, TLS, and timeout were indistinguishable and thelocalizedDescriptionbranch was unreachable.disableLocalCaching, which onlyOneSignalClientreads and this path bypasses — the private session already setsreloadIgnoringLocalCacheData.asyncAfterTimewas a no-op, so reschedule, generation cancellation, and unfocus-stops-loop went unexercised.Two changes beyond the findings, both fallout from #4:
+initcan run off the main queue, whereapplicationStateis 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.pollreads the app id per iteration instead of capturing it, so a mid-loop change can no longer keep fetching the previous app's flags.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 testsUnitTestApp_TestPlan_Reducedpasses locally (theOneSignal.mstartup change touches the main SDK target)OneSignalOSCore,OneSignalFramework, andOneSignalExampleall build for simulatorOSFeatureManager(different catalog, API, andOSUD_SDK_FEATURE_FLAGScache key)