Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions Makefile
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.

"events/disabling" was skipped, but it is supported and has been for some time. I have updated it to not skip this.

Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
TEST_HARNESS_PARAMS= -skip "events/disabling" -status-timeout 60
# can add temporary test skips etc. here
# Currently we are skipping the "events/disabling" tests because the Android SDK has no way to
# disable events. That wasn't an issue earlier because the "events/disabling" tests were getting
# automatically skipped by sdk-test-harness for a different reason: they rely on the
# ServiceEndpoints API, which the Android SDK didn't previously support.
SUPPRESSION_FILE=testharness-suppressions.txt
SUPPRESSION_FILE_FDV2=testharness-suppressions-fdv2.txt

TEST_HARNESS_PARAMS_V2= -status-timeout 60
TEST_HARNESS_PARAMS_V3= -status-timeout 60

build-contract-tests:
@cd contract-tests && ../gradlew --no-daemon -s assembleDebug -PdisablePreDex
Expand All @@ -14,10 +13,24 @@ start-emulator:
start-contract-test-service:
@scripts/start-test-service.sh

# Note that only the last version of the tests have the stop-service-at-end flag set, so the contract test service will be stopped after the tests are run.
run-contract-tests:
@echo "Running SDK contract test v2..."
@curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \
-s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \
| VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -skip-from testharness-suppressions.txt -debug $(TEST_HARNESS_PARAMS)" sh
-s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \
| VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -stop-service-at-end -skip-from $(SUPPRESSION_FILE) $(TEST_HARNESS_PARAMS_V2)" sh
Copy link
Copy Markdown
Contributor Author

@tanderson-ld tanderson-ld Apr 6, 2026

Choose a reason for hiding this comment

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

For reviewers: The above block will be deleted and replaced with the below block after the below block is uncommented in a future PR.


# Uncomment this, update v3 version, and replace existing run-contract-tests once sdk-test-harness releases a version that includes FDv2 client contract tests.
#
# run-contract-tests:
# @echo "Running SDK contract test v2..."
# @curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \
# -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \
# | VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -skip-from $(SUPPRESSION_FILE) $(TEST_HARNESS_PARAMS_V2)" sh
# @echo "Running SDK contract test v3..."
# @curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \
# -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.0.0-alpha.4/downloader/run.sh \
# | VERSION=v3.0.0-alpha.4 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -stop-service-at-end -skip-from $(SUPPRESSION_FILE_FDV2) $(TEST_HARNESS_PARAMS_V3)" sh

contract-tests: build-contract-tests start-emulator start-contract-test-service run-contract-tests

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public static class SdkConfigParams {
SdkConfigClientSideParams clientSide;
SdkConfigServiceEndpointParams serviceEndpoints;
SdkConfigHookParams hooks;
SdkConfigDataSystemParams dataSystem;
}

public static class SdkConfigStreamParams {
Expand Down Expand Up @@ -102,6 +103,37 @@ public static class HookErrors {
String afterTrack;
}

public static class SdkConfigDataSystemParams {
Boolean useDefaultDataSystem;
SdkConfigConnectionModeConfig connectionModeConfig;
/**
* FDv2 / data-system tests: pipelines when {@link #connectionModeConfig} does not define
* {@link SdkConfigConnectionModeConfig#customConnectionModes}. If both are present, custom
* connection modes take precedence and these lists are ignored.
*/
List<SdkConfigDataInitializer> initializers;
List<SdkConfigDataSynchronizer> synchronizers;
}

public static class SdkConfigConnectionModeConfig {
String initialConnectionMode;
Map<String, SdkConfigModeDefinition> customConnectionModes;
}

public static class SdkConfigModeDefinition {
List<SdkConfigDataInitializer> initializers;
List<SdkConfigDataSynchronizer> synchronizers;
}

public static class SdkConfigDataInitializer {
SdkConfigPollParams polling;
}
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.

For my current cache initializer work, I'll want to add a new SdkConfigCacheParams class and wire it up here?


public static class SdkConfigDataSynchronizer {
SdkConfigStreamParams streaming;
SdkConfigPollParams polling;
}

public static class CommandParams {
String command;
EvaluateFlagParams evaluate;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,27 @@
import com.launchdarkly.sdk.LDValue;
import com.launchdarkly.sdk.android.Components;
import com.launchdarkly.sdk.android.ConfigHelper;
import com.launchdarkly.sdk.android.ConnectionMode;
import com.launchdarkly.sdk.android.DataSystemComponents;
import com.launchdarkly.sdk.android.LaunchDarklyException;
import com.launchdarkly.sdk.android.LDClient;
import com.launchdarkly.sdk.android.LDConfig;

import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder;
import com.launchdarkly.sdk.android.integrations.AutomaticModeSwitchingConfig;
import com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder;
import com.launchdarkly.sdk.android.integrations.DataSystemBuilder;
import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder;
import com.launchdarkly.sdk.android.integrations.Hook;
import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder;
import com.launchdarkly.sdk.android.integrations.PollingInitializerBuilder;
import com.launchdarkly.sdk.android.integrations.PollingSynchronizerBuilder;
import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder;
import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder;
import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder;
import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder;
import com.launchdarkly.sdk.android.subsystems.Initializer;
import com.launchdarkly.sdk.android.subsystems.Synchronizer;
import com.launchdarkly.sdk.json.JsonSerialization;

import com.launchdarkly.sdktest.Representations.CommandParams;
Expand All @@ -37,6 +48,11 @@
import com.launchdarkly.sdktest.Representations.HookErrors;
import com.launchdarkly.sdktest.Representations.IdentifyEventParams;
import com.launchdarkly.sdktest.Representations.SdkConfigParams;
import com.launchdarkly.sdktest.Representations.SdkConfigDataSystemParams;
import com.launchdarkly.sdktest.Representations.SdkConfigConnectionModeConfig;
import com.launchdarkly.sdktest.Representations.SdkConfigModeDefinition;
import com.launchdarkly.sdktest.Representations.SdkConfigDataInitializer;
import com.launchdarkly.sdktest.Representations.SdkConfigDataSynchronizer;

import android.app.Application;

Expand Down Expand Up @@ -268,26 +284,29 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter,
// to be affected by each other's cached flag values.
ConfigHelper.configureIsolatedInMemoryPersistence(builder);

if (params.polling != null && params.polling.baseUri != null) {
// Note that this property can be set even if streaming is enabled
endpoints.polling(params.polling.baseUri);
}

if (params.polling != null && params.streaming == null) {
PollingDataSourceBuilder pollingBuilder = Components.pollingDataSource();
if (params.polling.pollIntervalMs != null) {
pollingBuilder.pollIntervalMillis(params.polling.pollIntervalMs.intValue());
}
builder.dataSource(pollingBuilder);
} else if (params.streaming != null) {
if (params.streaming.baseUri != null) {
endpoints.streaming(params.streaming.baseUri);
if (params.dataSystem != null) {
configureDataSystem(builder, params.dataSystem);
} else {
if (params.polling != null && params.polling.baseUri != null) {
endpoints.polling(params.polling.baseUri);
}
StreamingDataSourceBuilder streamingBuilder = Components.streamingDataSource();
if (params.streaming.initialRetryDelayMs != null) {
streamingBuilder.initialReconnectDelayMillis(params.streaming.initialRetryDelayMs.intValue());

if (params.polling != null && params.streaming == null) {
PollingDataSourceBuilder pollingBuilder = Components.pollingDataSource();
if (params.polling.pollIntervalMs != null) {
pollingBuilder.pollIntervalMillis(params.polling.pollIntervalMs.intValue());
}
builder.dataSource(pollingBuilder);
} else if (params.streaming != null) {
if (params.streaming.baseUri != null) {
endpoints.streaming(params.streaming.baseUri);
}
StreamingDataSourceBuilder streamingBuilder = Components.streamingDataSource();
if (params.streaming.initialRetryDelayMs != null) {
streamingBuilder.initialReconnectDelayMillis(params.streaming.initialRetryDelayMs.intValue());
}
builder.dataSource(streamingBuilder);
}
builder.dataSource(streamingBuilder);
}

if (params.events == null) {
Expand Down Expand Up @@ -373,6 +392,122 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter,
return builder.build();
}

private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem) {
if (Boolean.TRUE.equals(dataSystem.useDefaultDataSystem)) {
builder.dataSystem(Components.dataSystem());
return;
}

SdkConfigConnectionModeConfig connModeConfig = dataSystem.connectionModeConfig;
boolean hasTopLevelPipelines = hasTopLevelDataSystemPipelines(dataSystem);

if (connModeConfig == null && !hasTopLevelPipelines) {
return;
}

DataSystemBuilder dsBuilder = Components.dataSystem();

if (connModeConfig != null && connModeConfig.initialConnectionMode != null) {
dsBuilder.foregroundConnectionMode(connectionModeFromString(connModeConfig.initialConnectionMode));
}

// at the time of writing this, we did not have contract tests that could test platform state changes,
// disabling automatic mode simplifies the behavior being tested
dsBuilder.automaticModeSwitching(AutomaticModeSwitchingConfig.disabled());

// Prefer connectionModeConfig when the harness sends both that and top-level pipelines.
if (hasConnectionModeCustomPipelines(connModeConfig)) {
for (Map.Entry<String, SdkConfigModeDefinition> entry : connModeConfig.customConnectionModes.entrySet()) {
ConnectionMode mode = connectionModeFromString(entry.getKey());
ConnectionModeBuilder modeBuilder = buildConnectionModeBuilder(entry.getValue());
dsBuilder.customizeConnectionMode(mode, modeBuilder);
}
} else if (hasTopLevelPipelines) {
SdkConfigModeDefinition topLevel = new SdkConfigModeDefinition();
topLevel.initializers = dataSystem.initializers;
topLevel.synchronizers = dataSystem.synchronizers;
dsBuilder.customizeConnectionMode(ConnectionMode.STREAMING, buildConnectionModeBuilder(topLevel));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Top-level pipelines hardcoded to STREAMING ignoring foreground mode

Medium Severity

When connectionModeConfig.initialConnectionMode is set to a non-STREAMING value (e.g., "polling") but customConnectionModes is absent, the top-level initializers/synchronizers are hardcoded onto ConnectionMode.STREAMING via customizeConnectionMode. Meanwhile, foregroundConnectionMode was already changed to the requested mode. This means the custom pipelines are applied to an inactive mode while the actual foreground mode uses its default pipeline, silently ignoring the test harness's intended configuration.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b4efaf. Configure here.

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.

I think this is right, looking at the code. Do you want to use connectionModeConfig.initialConnectionMode as the first parameter, but fall back to STREAMING if the initial connection mode is empty?

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.

And should this issue be triggered by a contract test that exercises the POLLING mode?

}

builder.dataSystem(dsBuilder);
}

private static boolean hasTopLevelDataSystemPipelines(SdkConfigDataSystemParams dataSystem) {
return (dataSystem.initializers != null && !dataSystem.initializers.isEmpty())
|| (dataSystem.synchronizers != null && !dataSystem.synchronizers.isEmpty());
}

/** True when {@code connectionModeConfig.customConnectionModes} defines at least one mode pipeline. */
private static boolean hasConnectionModeCustomPipelines(SdkConfigConnectionModeConfig connModeConfig) {
return connModeConfig != null
&& connModeConfig.customConnectionModes != null
&& !connModeConfig.customConnectionModes.isEmpty();
}

private static ConnectionModeBuilder buildConnectionModeBuilder(SdkConfigModeDefinition modeDef) {
ConnectionModeBuilder modeBuilder = DataSystemComponents.customMode();

if (modeDef.initializers != null) {
List<DataSourceBuilder<Initializer>> initList = new ArrayList<>();
for (SdkConfigDataInitializer init : modeDef.initializers) {
if (init.polling != null) {
PollingInitializerBuilder initBuilder = DataSystemComponents.pollingInitializer();
if (init.polling.baseUri != null) {
initBuilder.serviceEndpointsOverride(
Components.serviceEndpoints().polling(init.polling.baseUri));
}
initList.add(initBuilder);
}
}
@SuppressWarnings("unchecked")
DataSourceBuilder<Initializer>[] initArray = initList.toArray(new DataSourceBuilder[0]);
modeBuilder.initializers(initArray);
}

if (modeDef.synchronizers != null) {
List<DataSourceBuilder<Synchronizer>> syncList = new ArrayList<>();
for (SdkConfigDataSynchronizer sync : modeDef.synchronizers) {
if (sync.streaming != null) {
StreamingSynchronizerBuilder syncBuilder = DataSystemComponents.streamingSynchronizer();
if (sync.streaming.initialRetryDelayMs != null) {
syncBuilder.initialReconnectDelayMillis(sync.streaming.initialRetryDelayMs.intValue());
}
if (sync.streaming.baseUri != null) {
syncBuilder.serviceEndpointsOverride(
Components.serviceEndpoints().streaming(sync.streaming.baseUri));
}
syncList.add(syncBuilder);
} else if (sync.polling != null) {
PollingSynchronizerBuilder syncBuilder = DataSystemComponents.pollingSynchronizer();
if (sync.polling.pollIntervalMs != null) {
syncBuilder.pollIntervalMillis(sync.polling.pollIntervalMs.intValue());
}
if (sync.polling.baseUri != null) {
syncBuilder.serviceEndpointsOverride(
Components.serviceEndpoints().polling(sync.polling.baseUri));
}
syncList.add(syncBuilder);
}
}
@SuppressWarnings("unchecked")
DataSourceBuilder<Synchronizer>[] syncArray = syncList.toArray(new DataSourceBuilder[0]);
modeBuilder.synchronizers(syncArray);
}

return modeBuilder;
}

private static ConnectionMode connectionModeFromString(String name) {
switch (name) {
case "streaming": return ConnectionMode.STREAMING;
case "polling": return ConnectionMode.POLLING;
case "offline": return ConnectionMode.OFFLINE;
case "one-shot": return ConnectionMode.ONE_SHOT;
case "background": return ConnectionMode.BACKGROUND;
default: throw new IllegalArgumentException("Unknown connection mode: " + name);
}
}
Copy link
Copy Markdown
Contributor

@aaron-zeisler aaron-zeisler Apr 8, 2026

Choose a reason for hiding this comment

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

Is this function something that could be put into the ConnectionMode class?

Each ConnectionMode has a defined name string. I see that ConnectionMode currently has toString(), maybe we could add fromString()?


public void close() {
try {
client.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,20 @@ static ChangeSet<Map<String, Flag>> toChangeSet(
logger.warn("FDv2 PUT for flag '{}' is missing object data; skipping", change.getKey());
continue;
}
flag = Flag.fromJson(change.getObject().toString());
Flag parsed = Flag.fromJson(change.getObject().toString());
// Inner object JSON omits "key" (it appears on the envelope). Always use the envelope key.
flag = new Flag(
change.getKey(),
parsed.getValue(),
parsed.getVersion(),
parsed.getFlagVersion(),
parsed.getVariation(),
parsed.isTrackEvents(),
parsed.isTrackReason(),
parsed.getDebugEventsUntilDate(),
parsed.getReason(),
parsed.getPrerequisites()
);
} else {
flag = Flag.deletedItemPlaceholder(change.getKey(), change.getVersion());
}
Expand Down
1 change: 1 addition & 0 deletions testharness-suppressions-fdv2.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
streaming/fdv2/fallback to FDv1 handling
Loading