Skip to content

feat: SDK-4363: Turbine remote SDK feature flags and foreground refresh#2612

Open
abdulraqeeb33 wants to merge 5 commits intomainfrom
sdk-4363-android-sdk-turbine-feature-flags
Open

feat: SDK-4363: Turbine remote SDK feature flags and foreground refresh#2612
abdulraqeeb33 wants to merge 5 commits intomainfrom
sdk-4363-android-sdk-turbine-feature-flags

Conversation

@abdulraqeeb33
Copy link
Copy Markdown
Contributor

Summary

Implements Turbine-aligned remote SDK feature flags for Android: GET /apps/:app_id/sdk/features/android/:sdk_version with validated SDK version label, kotlinx.serialization parsing (including optional sibling metadata per flag), foreground refresh (~10 min), and FeatureManager integration with remoteFeatureFlagMetadata().

Linear

SDK-4363

Test plan

  • CI passes
  • Unit tests for backend service, JSON parser, and FeatureManager

AR Abdul Azeez added 2 commits April 9, 2026 13:37
- Compare base.sha...head.sha on pull_request so the target branch matches the PR base.
- Enforce >= threshold on aggregate touched executable lines; keep per-file detail informational.
- Fail when main-source files in the diff cannot be mapped to JaCoCo.
- Rename workflow step for clarity.

Made-with: Cursor
- GET /apps/:app_id/sdk/features/android/:sdk_version with validated version label
- FeatureFlagsJsonParser (kotlinx.serialization), metadata as sibling JsonObjects
- FeatureFlagsRefreshService with ~10 min foreground polling; ConfigModelChangeTags.REMOTE_FEATURE_FLAGS
- FeatureManager merges remote flags; IFeatureManager.remoteFeatureFlagMetadata()
- SDK_BACKGROUND_THREADING / sdk_background_threading; CoreModule wiring and tests

Made-with: Cursor
Copilot AI review requested due to automatic review settings April 14, 2026 15:59
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements Turbine-aligned remote SDK feature flags on Android by adding a dedicated backend client + JSON parser, persisting results into ConfigModel, and wiring foreground refresh + FeatureManager evaluation (including metadata access).

Changes:

  • Add Turbine SDK feature flags backend service and strict kotlinx.serialization-based parser (with optional per-flag sibling metadata objects).
  • Add ConfigModel fields for remote SDK flags + metadata, and a foreground polling refresh service to keep them updated.
  • Update feature flag key conventions and threading flag usage across core, plus Gradle/CI coverage tooling updates.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt Updates mocks for new IFeatureManager API + new flag enum value.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt Updates mocks for new IFeatureManager API + new flag enum value.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt Expands tests for remote sdk flags + metadata parsing.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt Updates key-format expectations for FeatureFlag.key.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsJsonParserTests.kt Adds unit tests for new Turbine feature-flags JSON parser.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendServiceTest.kt Adds unit tests for Turbine endpoint path construction + version label validation.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt Switches to the new SDK_BACKGROUND_THREADING flag.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt Switches to the new SDK_BACKGROUND_THREADING flag.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt Switches to the new SDK_BACKGROUND_THREADING flag.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt Evaluates new remote SDK flags, observes new change tags/properties, and exposes metadata map API.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt Updates canonical feature key to lowercase Turbine/ConfigCat-style id.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/FeatureFlagsRefreshService.kt Adds foreground polling service to fetch/apply remote SDK flags + metadata into ConfigModel.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelChangeTags.kt Introduces a dedicated replace-tag for remote SDK feature flag updates.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt Adds persisted fields for remote SDK feature flags + metadata and JSON-array parsing support.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/FeatureFlagsJsonParser.kt Adds strict JSON parsing + metadata encoding/decoding helpers.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendService.kt Adds Turbine endpoint client with SDK version label validation and parsing integration.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IFeatureFlagsBackendService.kt Adds backend interface and result model for remote SDK feature flags.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt Registers new backend service + refresh service into the module wiring.
OneSignalSDK/onesignal/core/build.gradle Enables Kotlin serialization plugin + adds kotlinx-serialization-json dependency.
OneSignalSDK/coverage/checkCoverage.sh Updates diff-coverage gating to use exact PR base/head SHAs and an aggregate “touched lines” gate.
OneSignalSDK/build.gradle Adds kotlinx serialization plugin classpath and version pin.
.github/workflows/ci.yml Passes PR base/head SHAs into diff coverage step for accurate patch gating.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +104 to +111
val newMetaString = FeatureFlagsJsonParser.encodeMetadata(result.metadata)
if (result.enabledKeys == current.sdkRemoteFeatureFlags && newMetaString == current.sdkRemoteFeatureFlagMetadata) {
return
}

val updated = ConfigModel()
updated.initializeFromModel(null, current)
updated.sdkRemoteFeatureFlags = result.enabledKeys
Comment on lines +39 to +41
if (applicationService.isInForeground) {
onFocus(firedOnSubscribe = true)
}
Comment on lines +80 to +98
private fun restartForegroundPolling() {
pollJob?.cancel()
pollJob =
launchOnIO {
while (coroutineContext.isActive) {
if (!applicationService.isInForeground) {
break
}
val appId = configModelStore.model.appId
if (appId.isNotEmpty()) {
try {
fetchAndApply(appId)
} catch (t: Throwable) {
Logging.warn("FeatureFlagsRefreshService: fetch failed", t)
}
}
delay(REFRESH_INTERVAL_MS)
}
}
@nan-li
Copy link
Copy Markdown
Contributor

nan-li commented Apr 14, 2026

@claude review

- Annotate pollJob with @volatile for cross-thread visibility
- Remove duplicate onFocus from start(); ApplicationService already fires on subscribe
- Apply remote flags via in-place model property updates (REMOTE_FEATURE_FLAGS tag) to avoid
  clobbering concurrent params hydration with a stale full-model replace
- Use OneSignalDispatchers.launchOnIO so CancellationException is not logged as an error
- Compare enabled key sets (order-insensitive) before writing

Made-with: Cursor
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements Turbine-aligned remote SDK feature flags for the Android SDK, including a dedicated backend fetch, JSON parsing with optional per-flag metadata, foreground refresh polling, and FeatureManager integration.

Changes:

  • Added Turbine SDK feature flags endpoint client + path/version label validation.
  • Added kotlinx.serialization-based parser for features + optional sibling per-flag metadata, storing results into ConfigModel.
  • Added foreground polling service to refresh remote SDK flags periodically, and updated FeatureManager/FeatureFlag to use lowercase remote keys.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt Updates mocks to use new SDK_BACKGROUND_THREADING flag + new remoteFeatureFlagMetadata() API.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt Updates mocks for renamed flag + new FeatureManager API.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt Expands FeatureManager tests to include sdk-remote flags + stored metadata parsing behavior.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt Updates feature-flag key convention test to lowercase snake_case and validates new canonical key.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/TurbineSdkFeatureFlagsPathTest.kt New tests for RFC3986-ish path encoding + Turbine relative path construction.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsJsonParserTests.kt New tests for JSON parsing, normalization, metadata extraction, and storage round-trip.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendServiceTest.kt New tests for endpoint path building and sdk version label validation.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt Switches background threading gate to SDK_BACKGROUND_THREADING.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt Switches background threading gate to SDK_BACKGROUND_THREADING.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/startup/StartupService.kt Switches background threading gate to SDK_BACKGROUND_THREADING.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt Unions legacy config features with sdk-remote flags; adds remoteFeatureFlagMetadata(); listens to new config fields/tags.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt Renames/updates BACKGROUND_THREADING feature key to lowercase remote id.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/FeatureFlagsRefreshService.kt New foreground polling service that fetches and stores remote SDK feature flags + metadata.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModelChangeTags.kt New config replace tag for remote feature-flag refresh updates.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt Adds persisted fields for sdk-remote feature keys and per-flag metadata string; supports list creation for new field.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/TurbineSdkFeatureFlagsPath.kt New pure-Kotlin path builder + version label validator + UTF-8 percent-encoding helper.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/FeatureFlagsJsonParser.kt New kotlinx.serialization parser for endpoint response and metadata storage/restore utilities.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendService.kt New HTTP client implementation for fetching remote SDK feature flags from Turbine endpoint.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IFeatureFlagsBackendService.kt New backend interface + result model for enabled keys and optional metadata payloads.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt Registers new backend service and refresh service into the core module DI graph.
OneSignalSDK/onesignal/core/build.gradle Enables Kotlin serialization plugin and adds kotlinx-serialization-json dependency.
OneSignalSDK/build.gradle Adds kotlinx serialization JSON version and Kotlin serialization Gradle plugin dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +90 to +94
try {
fetchAndApply(appId)
} catch (t: Throwable) {
Logging.warn("FeatureFlagsRefreshService: fetch failed", t)
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Catching Throwable here will also catch CancellationException when the polling job is cancelled (e.g., onUnfocused / restartForegroundPolling), which can lead to noisy warning logs and can interfere with prompt coroutine cancellation. Prefer not catching cancellation (e.g., catch Exception and rethrow CancellationException) so the Job cancels cleanly.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +99
val enabledFeatureKeys =
(
model.features +
model.sdkRemoteFeatureFlags +
localFeatureOverrides
).toSet()
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

FeatureFlag.key is now documented/validated as lowercase, but enabledFeatureKeys is built from model.features + model.sdkRemoteFeatureFlags without normalizing casing. If cached/remote values ever differ in case (including upgrades from previously stored keys), APP_STARTUP flags can latch the wrong value for the whole run. Consider canonicalizing the unioned keys (e.g., lowercase) or providing an alias mapping for legacy keys before evaluating FeatureFlag.isEnabledIn().

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +11
/**
* Remote feature-flags HTTP endpoint updated [ConfigModel] SDK flag fields only
* ([sdkRemoteFeatureFlags], [sdkRemoteFeatureFlagMetadata]).
*/
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

KDoc links in this comment likely won't resolve as written because sdkRemoteFeatureFlags / sdkRemoteFeatureFlagMetadata aren't in scope here. Qualify them as ConfigModel.sdkRemoteFeatureFlags / ConfigModel.sdkRemoteFeatureFlagMetadata (or use plain code formatting) so the documentation stays accurate and navigable.

Copilot uses AI. Check for mistakes.
Comment on lines 27 to 30
// Threading mode is selected once per app startup to avoid mixed-mode behavior mid-session.
//
// Naming convention:
// SDK_<MMmmpp>_<FEATURE_DESCRIPTION>
// Remote key (lowercase) must match backend / ConfigCat flag id.
//
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

This comment mentions ConfigCat, but there are no other references to ConfigCat in the codebase and the rest of this PR describes Turbine/remote config. Consider removing or updating the reference so readers don't infer an unrelated dependency or backend source of truth.

Copilot uses AI. Check for mistakes.
@abdulraqeeb33 abdulraqeeb33 changed the title SDK-4363: Turbine remote SDK feature flags and foreground refresh feat: SDK-4363: Turbine remote SDK feature flags and foreground refresh Apr 14, 2026
Comment on lines +105 to +120
val result = featureFlagsBackend.fetchRemoteFeatureFlags(appId)
val current = configModelStore.model
val newMetaString = FeatureFlagsJsonParser.encodeMetadata(result.metadata)
if (result.enabledKeys.toSet() == current.sdkRemoteFeatureFlags.toSet() &&
newMetaString == current.sdkRemoteFeatureFlagMetadata
) {
return
}

val tag = ConfigModelChangeTags.REMOTE_FEATURE_FLAGS
current.setListProperty(ConfigModel::sdkRemoteFeatureFlags.name, result.enabledKeys, tag)
current.setOptStringProperty(ConfigModel::sdkRemoteFeatureFlagMetadata.name, newMetaString, tag)
}

companion object {
private const val REFRESH_INTERVAL_MS = 600_000L
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 In fetchAndApply(), RemoteFeatureFlagsResult.EMPTY (with enabledKeys=emptyList()) is returned for all failure modes—HTTP non-success, empty/null body, invalid SDK version, and JSON parse errors—making it indistinguishable from a legitimate server response of "no flags enabled". When previously-cached flags exist (e.g. ["sdk_background_threading"]), the equality guard emptySet() \!= setOf("sdk_background_threading") fails and setListProperty is called with emptyList(), wiping all stored feature flags from SharedPreferences. A single transient server 500 or network error silently disables all remote feature flags at the next app startup; fix by distinguishing failure results from a legitimately-empty server response and skipping the write on failure.

Extended reasoning...

What the bug is and how it manifests

FeatureFlagsBackendService.fetchRemoteFeatureFlags() returns the sentinel value RemoteFeatureFlagsResult.EMPTY (which has enabledKeys = emptyList() and metadata = null) for at least four distinct failure conditions: (1) the SDK version label fails validation via isValidFeaturesSdkVersionLabel, (2) the HTTP response is non-success or has a null/blank body, (3) the JSON body cannot be parsed by FeatureFlagsJsonParser.parse() (the catch (_: Throwable) block returns EMPTY), and (4) the top-level parsed element is not a JsonObject. In all of these cases, the returned value is identical to what the server would return for a legitimate response containing zero enabled flags. There is no field or separate return path to distinguish "request failed" from "server says no flags".

The specific code path that triggers it

In fetchAndApply() (FeatureFlagsRefreshService.kt lines 105–120), the equality guard compares result.enabledKeys.toSet() against current.sdkRemoteFeatureFlags.toSet(). If the device previously had cached flags — for example ["sdk_background_threading"] — and a transient HTTP 500 or network timeout causes fetchRemoteFeatureFlags to return EMPTY, the comparison becomes emptySet() == setOf("sdk_background_threading"), which is false. The early-return is not taken. The code proceeds to call current.setListProperty(ConfigModel::sdkRemoteFeatureFlags.name, result.enabledKeys, tag) where result.enabledKeys is emptyList(), clearing all persisted feature flags.

Why existing code does not prevent it

The equality guard was intended to prevent unnecessary writes when nothing has changed, but it cannot distinguish between two semantically different situations that produce the same value: (a) the server responded with an empty features array, and (b) the request failed entirely. RemoteFeatureFlagsResult.EMPTY is a single shared sentinel used as a universal failure fallback across all error branches. There is no isFailure field, no separate error return type, and no null check in fetchAndApply to skip the write on failure.

What the impact would be

For the current session, the impact is limited: FeatureManager has already latched APP_STARTUP features (like SDK_BACKGROUND_THREADING) at startup with applyNextRunOnlyFeatures=true, and subsequent onModelUpdated calls during the session are guarded by the APP_STARTUP check which prevents changing the latched value mid-run. However, sdkRemoteFeatureFlags in ConfigModel/SharedPreferences IS cleared. On the next app startup, FeatureManager.init calls refreshEnabledFeatures(model, applyNextRunOnlyFeatures=true) and reads the now-empty sdkRemoteFeatureFlags, so sdk_background_threading is absent from enabledFeatureKeys and SDK_BACKGROUND_THREADING is initialized to false. The SDK runs in legacy threading mode for that entire session — even though the feature was previously enabled and the server would have returned it enabled if the transient error had not occurred.

How to fix it

The cleanest fix is to return null (or a dedicated Failure subtype) from fetchRemoteFeatureFlags on error, and only write to the model when the result is non-null. For example:

  • Change the return type to RemoteFeatureFlagsResult? and return null in all error branches in FeatureFlagsBackendService.
  • In fetchAndApply, add if (result == null) return before the equality check.
  • Reserve RemoteFeatureFlagsResult.EMPTY (or a new RemoteFeatureFlagsResult(emptyList(), null) instance) exclusively for the legitimate case where the server responds with an empty features array.

Alternatively, add a val isFailure: Boolean field to RemoteFeatureFlagsResult and set it to true in all error code paths, then skip the write in fetchAndApply when result.isFailure is true.

Step-by-step proof

  1. App starts. FeatureManager.init reads sdkRemoteFeatureFlags = ["sdk_background_threading"] from SharedPreferences. SDK_BACKGROUND_THREADING is latched to true. The SDK starts in background-threading mode.
  2. App is foregrounded. FeatureFlagsRefreshService.restartForegroundPolling() launches the polling coroutine. After 10 minutes, it calls fetchAndApply(appId).
  3. The server returns HTTP 500. FeatureFlagsBackendService.fetchRemoteFeatureFlags() hits the \!response.isSuccess branch and returns RemoteFeatureFlagsResult.EMPTY (enabledKeys=[], metadata=null).
  4. Back in fetchAndApply: result.enabledKeys.toSet() = emptySet(); current.sdkRemoteFeatureFlags.toSet() = setOf("sdk_background_threading"). Equality check is false — early return is not taken.
  5. current.setListProperty("sdkRemoteFeatureFlags", emptyList(), REMOTE_FEATURE_FLAGS) is called. SharedPreferences now stores an empty list for sdkRemoteFeatureFlags. FeatureManager.onModelUpdated fires but the APP_STARTUP guard keeps the current-session state unchanged.
  6. App is killed and relaunched. FeatureManager.init reads sdkRemoteFeatureFlags = [] from SharedPreferences. SDK_BACKGROUND_THREADING is initialized to false. The SDK starts in legacy threading mode, despite the feature being actively enabled on the server.

Comment on lines 54 to 65
override fun onModelReplaced(
model: ConfigModel,
tag: String,
) {
Logging.debug("OneSignal: FeatureManager.onModelReplaced(tag=$tag)")
if (tag == ModelChangeTags.HYDRATE || tag == ModelChangeTags.NORMAL) {
if (tag == ModelChangeTags.HYDRATE ||
tag == ModelChangeTags.NORMAL ||
tag == ConfigModelChangeTags.REMOTE_FEATURE_FLAGS
) {
try {
refreshEnabledFeatures(model, applyNextRunOnlyFeatures = false)
} catch (t: Throwable) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The tag == ConfigModelChangeTags.REMOTE_FEATURE_FLAGS branch inside FeatureManager.onModelReplaced() is dead code that can never be reached. fetchAndApply() updates the model via current.setListProperty() and current.setOptStringProperty() directly on the live ConfigModel, which fire onModelUpdated callbacks — not onModelReplaced. Consider removing the REMOTE_FEATURE_FLAGS check from onModelReplaced() in both FeatureManager and FeatureFlagsRefreshService to avoid misleading future readers.

Extended reasoning...

What the dead code is and how it manifests

FeatureManager.onModelReplaced() was updated to process tag == ConfigModelChangeTags.REMOTE_FEATURE_FLAGS alongside HYDRATE and NORMAL. Similarly, FeatureFlagsRefreshService.onModelReplaced() has an early-return guard if (tag == ConfigModelChangeTags.REMOTE_FEATURE_FLAGS) return. Neither branch can ever be reached in practice.

The specific code path that shows why

FeatureFlagsRefreshService.fetchAndApply() updates the model at lines 113–116 via:

current.setListProperty(ConfigModel::sdkRemoteFeatureFlags.name, result.enabledKeys, tag)
current.setOptStringProperty(ConfigModel::sdkRemoteFeatureFlagMetadata.name, newMetaString, tag)

Both calls operate on the live singleton ConfigModel reference. In Model.kt, setListPropertysetOptAnyPropertynotifyChanged() → fires ISingletonModelStoreChangeHandler.onModelUpdated() on all subscribers. The onModelReplaced() callback is only triggered by SingletonModelStore.replace(), which calls changeSubscription.fire { it.onModelReplaced(existingModel, tag) }. Since fetchAndApply() never calls configModelStore.replace(), the REMOTE_FEATURE_FLAGS tag can never arrive via onModelReplaced().

Why existing code doesn't prevent the confusion

The docstring on ConfigModelChangeTags.REMOTE_FEATURE_FLAGS correctly states it is for "ConfigModel / ConfigModelStore replace notifications," but the tag is also passed as an argument to the property setters (which fire onModelUpdated). A developer reading FeatureManager.onModelReplaced() would reasonably infer that there is a code path where the feature-flags refresh triggers model replacement — there is not. Features ARE correctly updated through the onModelUpdated path (lines 73–83 in FeatureManager) which checks for sdkRemoteFeatureFlags and sdkRemoteFeatureFlagMetadata property changes.

Addressing the refutation

The refutation correctly notes this is "harmless defensive code" with no functional regression. All three confirmers agree — the flags ARE correctly updated via onModelUpdated, and no behavior is broken. The concern is purely about code clarity: the dead REMOTE_FEATURE_FLAGS branch implies a model-replacement update path that does not exist, and a future maintainer might test only that path and miss the real one.

Impact

No functional regression. Feature flag states are always correctly reflected in FeatureManager.featureStates. The risk is purely maintenance: a developer adding a new FeatureActivationMode or changing the update flow might trace through onModelReplaced as the canonical handler and miss the property-setter path.

How to fix it

Remove the tag == ConfigModelChangeTags.REMOTE_FEATURE_FLAGS branch from FeatureManager.onModelReplaced() (since it is unreachable) and remove the early-return guard in FeatureFlagsRefreshService.onModelReplaced() for the same reason. If the intent is to document that REMOTE_FEATURE_FLAGS updates go through onModelUpdated, a code comment at the onModelUpdated call site would be clearer.

Step-by-step proof

  1. FeatureFlagsRefreshService.fetchAndApply() retrieves val current = configModelStore.model — the live singleton.
  2. It calls current.setListProperty(..., ConfigModelChangeTags.REMOTE_FEATURE_FLAGS).
  3. Inside Model.setListPropertysetOptAnyPropertynotifyChanged(tag=REMOTE_FEATURE_FLAGS).
  4. notifyChanged fires changeNotifier listeners → SingletonModelStore.onModelUpdated(model, args, tag)ISingletonModelStoreChangeHandler.onModelUpdated(args, tag).
  5. FeatureManager.onModelUpdated is called with args.property == "sdkRemoteFeatureFlags" — this is the real update path.
  6. SingletonModelStore.replace() is never called, so onModelReplaced() with REMOTE_FEATURE_FLAGS is never triggered.

Comment on lines +93 to +100
try {
fetchAndApply(appId)
} catch (t: Throwable) {
Logging.warn("FeatureFlagsRefreshService: fetch failed", t)
}
}
delay(REFRESH_INTERVAL_MS)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The inner catch (t: Throwable) in restartForegroundPolling() catches CancellationException (which extends Throwable on the JVM), so every normal pollJob.cancel() call on app unfocus that interrupts an in-flight HTTP request inside fetchAndApply() emits a spurious Logging.warn warning. Fix by changing catch (t: Throwable) to catch (t: Exception), or by adding if (t is CancellationException) throw t as the first line of the catch body.

Extended reasoning...

What the bug is and how it manifests

In FeatureFlagsRefreshService.restartForegroundPolling() (lines 93-100 of the new file), the polling loop wraps fetchAndApply(appId) in a try/catch (t: Throwable) block that logs Logging.warn("FeatureFlagsRefreshService: fetch failed", t). On the JVM, CancellationException extends IllegalStateException -> RuntimeException -> Exception -> Throwable, so it is caught by catch (t: Throwable). This means any coroutine cancellation that occurs while the coroutine is suspended inside fetchAndApply() will be caught and logged as a warning rather than propagated.

The specific code path that triggers it

When the user moves the app to the background, onUnfocused() calls pollJob?.cancel(). If the coroutine is suspended at the http.get() call inside featureFlagsBackend.fetchRemoteFeatureFlags(appId) (a suspend fun), the runtime injects a CancellationException at that suspension point. The exception propagates up through fetchAndApply() and is caught by the catch (t: Throwable) handler in restartForegroundPolling(), triggering Logging.warn("FeatureFlagsRefreshService: fetch failed", t). After the catch block completes, the delay(REFRESH_INTERVAL_MS) call (which sits outside the try/catch) also throws CancellationException, this time exiting the while loop normally.

Why existing code does not prevent it

The coroutine is correctly launched via OneSignalDispatchers.launchOnIO (which has no outer catch), so the outer launcher does not swallow cancellation. However, the inner catch (t: Throwable) is too broad: it was intended to catch transient network or parsing errors from the HTTP fetch, but Throwable also encompasses CancellationException. There is no if (t is CancellationException) throw t guard, nor is the catch narrowed to Exception.

What the impact would be

Every app unfocus event during which an HTTP call to the feature-flags endpoint is in-flight generates a spurious WARNING-level log entry. In production log-aggregation or monitoring systems, repeated false-alarm warnings inflate error rates and can drown out genuine issues. The coroutine terminates correctly (via the subsequent delay() outside the try/catch), so there is no functional regression — only polluted warning logs on each focus/unfocus cycle.

How to fix it

Change the catch clause from Throwable to Exception:

Alternatively, add an explicit rethrow guard as the first line of the catch body:

Both approaches ensure CancellationException is rethrown and the coroutine exits cleanly without a spurious log entry.

Step-by-step proof

  1. App is in the foreground; restartForegroundPolling() launches a coroutine on the IO dispatcher. The coroutine calls fetchAndApply(appId), which calls featureFlagsBackend.fetchRemoteFeatureFlags(appId), which suspends inside http.get() waiting for an HTTP response.
  2. User presses the home button; onUnfocused() is invoked. It calls pollJob?.cancel().
  3. The coroutine runtime delivers a CancellationException to the active suspension point inside http.get(). The exception propagates up through fetchRemoteFeatureFlags and fetchAndApply (no inner catch there).
  4. The exception is caught by catch (t: Throwable) in restartForegroundPolling(). Logging.warn("FeatureFlagsRefreshService: fetch failed", t) is emitted — the spurious warning.
  5. Execution continues past the catch block and reaches delay(REFRESH_INTERVAL_MS). Since the job is still cancelled, delay() immediately throws another CancellationException. This one is not caught (it is outside the try/catch), so it propagates out of the while loop and the coroutine terminates — but only after the false-alarm warning was already emitted in step 4.

🔬 also observed by 3081204109

Comment thread OneSignalSDK/build.gradle
kotlinVersion = '1.9.25'
dokkaVersion = '1.9.10'
coroutinesVersion = '1.7.3'
kotlinxSerializationJsonVersion = '1.6.3'
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.

do we need this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, so i shifted away from using org.json.JsonObject to using kotlinxSerializationJson. This will help us in KMP as it needs to be pure kotlin.

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