From dd0f1ffd25e01792c22ab76d2f2c8be6f6893b83 Mon Sep 17 00:00:00 2001
From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:51:13 -0700
Subject: [PATCH 1/2] feat: Support usePost for FDv2 polling and streaming
requests
The FDv2 polling endpoint accepts POST, not REPORT, so the FDv2 sources
now send GET or POST. The new DataSystemBuilder.usePost option selects
POST and sends the evaluation context in the request body. The FDv1
fallback synchronizer sends REPORT in that case because the FDv1
endpoints do not accept POST. HttpConfigurationBuilder.useReport has no
effect under the data system and logs a warning once.
---
.../DataSourceBuildInputsInternal.java | 4 +-
.../sdk/android/DataSystemComponents.java | 11 +-
.../sdk/android/DefaultFDv2Requestor.java | 35 ++-
.../sdk/android/FDv2DataSourceBuilder.java | 36 ++++
.../android/FDv2StreamingSynchronizer.java | 16 +-
.../launchdarkly/sdk/android/LDConfig.java | 2 +-
.../sdk/android/StandardEndpoints.java | 4 +-
.../integrations/DataSystemBuilder.java | 34 +++
.../HttpConfigurationBuilder.java | 4 +
.../subsystems/DataSourceBuildInputs.java | 15 ++
.../DataSourceBuildInputsInternalTest.java | 17 +-
.../sdk/android/DataSystemBuilderTest.java | 12 ++
.../sdk/android/DataSystemComponentsTest.java | 204 ++++++++++++++++++
.../sdk/android/DefaultFDv2RequestorTest.java | 66 +++---
.../android/FDv2DataSourceBuilderTest.java | 95 +++++++-
.../sdk/android/FDv2EntryConverterTest.java | 2 +-
.../FDv2StreamingSynchronizerTest.java | 21 +-
.../sdk/android/LDConfigDataSystemTest.java | 15 ++
18 files changed, 514 insertions(+), 79 deletions(-)
create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java
index 1299859b5..16dc82108 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java
@@ -35,13 +35,14 @@ final class DataSourceBuildInputsInternal extends DataSourceBuildInputs {
ServiceEndpoints serviceEndpoints,
HttpConfiguration http,
boolean evaluationReasons,
+ boolean usePost,
SelectorSource selectorSource,
ScheduledExecutorService sharedExecutor,
@NonNull File cacheDir,
LDLogger baseLogger,
@Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData
) {
- super(evaluationContext, serviceEndpoints, http, evaluationReasons,
+ super(evaluationContext, serviceEndpoints, http, evaluationReasons, usePost,
selectorSource, sharedExecutor, cacheDir, baseLogger);
this.perEnvironmentData = perEnvironmentData;
}
@@ -60,6 +61,7 @@ static DataSourceBuildInputsInternal get(DataSourceBuildInputs inputs) {
inputs.getServiceEndpoints(),
inputs.getHttp(),
inputs.isEvaluationReasons(),
+ inputs.isUsePost(),
inputs.getSelectorSource(),
inputs.getSharedExecutor(),
inputs.getCacheDir(),
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java
index 4d9dfea8f..652488f63 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java
@@ -141,7 +141,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) {
StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH,
requestor,
initialReconnectDelayMillis,
- inputs.isEvaluationReasons(), inputs.getHttp().isUseReport(),
+ inputs.isEvaluationReasons(), inputs.isUsePost(),
httpProps, inputs.getSharedExecutor(),
inputs.getBaseLogger(), null);
}
@@ -159,10 +159,12 @@ public FDv1PollingSynchronizerBuilderImpl pollIntervalMillis(int pollIntervalMil
@Override
public Synchronizer build(DataSourceBuildInputs inputs) {
+ // The FDv1 endpoints do not accept POST. REPORT is their method that carries the
+ // context in the request body, so usePost selects REPORT here.
FeatureFetcher fetcher = new HttpFeatureFlagFetcher(
inputs.getServiceEndpoints().getPollingBaseUri(),
inputs.isEvaluationReasons(),
- inputs.getHttp().isUseReport(),
+ inputs.isUsePost(),
LDUtil.makeHttpProperties(inputs.getHttp()),
inputs.getCacheDir(),
inputs.getBaseLogger()
@@ -360,9 +362,8 @@ private static FDv2Requestor makePollingRequestor(DataSourceBuildInputs inputs,
"polling", inputs.getBaseLogger());
return new DefaultFDv2Requestor(
inputs.getEvaluationContext(), pollingBase,
- StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH,
- StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH,
- httpProps, inputs.getHttp().isUseReport(),
+ StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH,
+ httpProps, inputs.isUsePost(),
inputs.isEvaluationReasons(), null, inputs.getBaseLogger());
}
}
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java
index 2ffa79e8c..0a554ee5f 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java
@@ -31,7 +31,7 @@
/**
* Default OkHttp-based implementation of {@link FDv2Requestor}.
*
- * Builds GET or REPORT requests to the FDv2 polling endpoint. If the current selector is
+ * Builds GET or POST requests to the FDv2 polling endpoint. If the current selector is
* non-empty, its state is sent as the {@code basis} query parameter. ETag tracking is used
* to detect 304 Not Modified responses; when the server returns 304 the application layer
* treats it as {@code ChangeSetType.None} (no flags changed), so no OkHttp disk cache is
@@ -40,7 +40,6 @@
* The OkHttpClient is closed by {@link #close()}.
*/
final class DefaultFDv2Requestor implements FDv2Requestor {
- private static final String METHOD_REPORT = "REPORT";
private static final String BASIS_PARAM = "basis";
private static final String FILTER_PARAM = "filter";
private static final String WITH_REASONS_PARAM = "withReasons";
@@ -50,10 +49,10 @@ final class DefaultFDv2Requestor implements FDv2Requestor {
private final OkHttpClient httpClient;
private final URI pollingUri;
private final okhttp3.Headers headers;
- private final boolean useReport;
+ private final boolean usePost;
private final boolean evaluationReasons;
@Nullable
- private final RequestBody reportBody;
+ private final RequestBody postBody;
@Nullable
private final String payloadFilter;
private final LDLogger logger;
@@ -67,10 +66,10 @@ final class DefaultFDv2Requestor implements FDv2Requestor {
/**
* @param evaluationContext the context to evaluate flags for
* @param baseUri polling base URI from service endpoints
- * @param getRequestPath path for GET requests (context appended as a path segment)
- * @param reportRequestPath path for REPORT requests (context sent in the request body)
+ * @param requestPath polling request path; GET appends the base64-encoded context as a
+ * path segment, POST uses the path as is
* @param httpProperties SDK HTTP configuration (timeouts, proxy, TLS, user-agent, etc.)
- * @param useReport if true, send context in the request body via REPORT; otherwise
+ * @param usePost if true, send the context in the request body via POST; otherwise
* append the base64-encoded context to the GET path
* @param evaluationReasons if true, append {@code withReasons=true} to the query string
* @param payloadFilter optional payload filter key; sent as {@code filter} query param
@@ -79,14 +78,13 @@ final class DefaultFDv2Requestor implements FDv2Requestor {
DefaultFDv2Requestor(
@NonNull LDContext evaluationContext,
@NonNull URI baseUri,
- @NonNull String getRequestPath,
- @NonNull String reportRequestPath,
+ @NonNull String requestPath,
@NonNull HttpProperties httpProperties,
- boolean useReport,
+ boolean usePost,
boolean evaluationReasons,
@Nullable String payloadFilter,
@NonNull LDLogger logger) {
- this.useReport = useReport;
+ this.usePost = usePost;
this.evaluationReasons = evaluationReasons;
this.payloadFilter = payloadFilter;
this.logger = logger;
@@ -94,14 +92,13 @@ final class DefaultFDv2Requestor implements FDv2Requestor {
// Precompute the base polling URI for the chosen request method. For GET, the
// base64-encoded context is a fixed path segment (context never changes after
- // construction). For REPORT, the context goes in the request body so no path
+ // construction). For POST, the context goes in the request body so no path
// segment is needed.
- URI basePollingUri = HttpHelpers.concatenateUriPath(baseUri,
- useReport ? reportRequestPath : getRequestPath);
- this.pollingUri = useReport
+ URI basePollingUri = HttpHelpers.concatenateUriPath(baseUri, requestPath);
+ this.pollingUri = usePost
? basePollingUri
: HttpHelpers.concatenateUriPath(basePollingUri, LDUtil.urlSafeBase64(evaluationContext));
- this.reportBody = useReport
+ this.postBody = usePost
? RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON)
: null;
@@ -139,8 +136,8 @@ public Future poll(@NonNull Selector selector) {
.url(requestUri.toURL())
.headers(headers);
- if (useReport) {
- reqBuilder.method(METHOD_REPORT, reportBody);
+ if (usePost) {
+ reqBuilder.post(postBody);
} else {
synchronized (etagLock) {
if (!requestUri.equals(lastRequestUri)) {
@@ -207,7 +204,7 @@ private void handleResponse(
return;
}
- if (!useReport) {
+ if (!usePost) {
synchronized (etagLock) {
cachedEtag = response.header(ETAG_HEADER);
}
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java
index 769eaa310..5d90b6ea8 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java
@@ -34,10 +34,13 @@ class FDv2DataSourceBuilder implements ComponentConfigurer, Closeabl
private final Map modeTable;
private final ConnectionMode startingMode;
private final ModeResolutionTable resolutionTable;
+ private final boolean usePost;
private ConnectionMode activeMode;
private boolean includeInitializers = true; // start with initializers
private ScheduledExecutorService sharedExecutor;
+ // The useReport warning is logged on the first build only. Builds run on one thread.
+ private boolean useReportWarningLogged = false;
FDv2DataSourceBuilder() {
this(DataSystemComponents.makeDefaultModeTable(), ConnectionMode.STREAMING, ModeResolutionTable.MOBILE);
@@ -54,10 +57,24 @@ class FDv2DataSourceBuilder implements ComponentConfigurer, Closeabl
@NonNull Map modeTable,
@NonNull ConnectionMode startingMode,
@NonNull ModeResolutionTable resolutionTable
+ ) {
+ this(modeTable, startingMode, resolutionTable, false);
+ }
+
+ /**
+ * @param usePost true if FDv2 polling and streaming requests send the context in the request
+ * body with POST instead of encoding it into the GET path
+ */
+ FDv2DataSourceBuilder(
+ @NonNull Map modeTable,
+ @NonNull ConnectionMode startingMode,
+ @NonNull ModeResolutionTable resolutionTable,
+ boolean usePost
) {
this.modeTable = modeTable;
this.startingMode = startingMode;
this.resolutionTable = resolutionTable;
+ this.usePost = usePost;
}
/**
@@ -75,6 +92,15 @@ ConnectionMode getStartingMode() {
return startingMode;
}
+ /**
+ * Returns whether FDv2 flag requests send the context in the request body with POST.
+ *
+ * @return true if flag requests use POST
+ */
+ boolean isUsePost() {
+ return usePost;
+ }
+
/**
* Configures the mode to build for and whether to include initializers.
* Called by {@link ConnectivityManager} before each {@link #build} call.
@@ -108,6 +134,15 @@ public DataSource build(ClientContext clientContext) {
ConnectionMode mode = activeMode != null ? activeMode : startingMode;
+ // The FDv2 endpoints take POST, not REPORT. The data system has its own usePost option,
+ // so the FDv1 useReport option does not apply here.
+ if (clientContext.getHttp().isUseReport() && !useReportWarningLogged) {
+ useReportWarningLogged = true;
+ clientContext.getBaseLogger().warn(
+ "useReport has no effect with the FDv2 data system and is ignored. " +
+ "Use DataSystemBuilder.usePost to send the evaluation context in the request body.");
+ }
+
ModeDefinition modeDef = modeTable.get(mode);
if (modeDef == null) {
throw new IllegalStateException(
@@ -159,6 +194,7 @@ private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) {
clientContext.getServiceEndpoints(),
clientContext.getHttp(),
clientContext.isEvaluationReasons(),
+ usePost,
selectorSource,
sharedExecutor,
impl.getPlatformState().getCacheDir(),
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java
index ef8e0d5f8..756d37925 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java
@@ -53,7 +53,7 @@
*
*/
final class FDv2StreamingSynchronizer implements Synchronizer {
- private static final String METHOD_REPORT = "REPORT";
+ private static final String METHOD_POST = "POST";
private static final String PING = "ping";
private static final long READ_TIMEOUT_MS = 300_000; // 5 minutes
private static final long MAX_RECONNECT_TIME_MS = 300_000; // 5 minutes
@@ -61,7 +61,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer {
private final HttpProperties httpProperties;
private final URI streamBaseUri;
private final String streamRequestPath;
- private final boolean useReport;
+ private final boolean usePost;
private final LDContext evaluationContext;
private final SelectorSource selectorSource;
@Nullable
@@ -92,7 +92,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer {
* @param requestor optional requestor for handling ping events via poll; may be null
* @param initialReconnectDelayMillis delay before reconnecting after an error, in milliseconds
* @param evaluationReasons true to request evaluation reasons in the stream
- * @param useReport true to use HTTP REPORT for the request body
+ * @param usePost true to send the context in the request body with POST
* @param httpProperties HTTP configuration for the stream request
* @param executor executor used to run the streaming loop on a background
* thread; should use background-priority threads
@@ -107,7 +107,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer {
@Nullable FDv2Requestor requestor,
int initialReconnectDelayMillis,
boolean evaluationReasons,
- boolean useReport,
+ boolean usePost,
@NonNull HttpProperties httpProperties,
@NonNull Executor executor,
@NonNull LDLogger logger,
@@ -120,7 +120,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer {
this.requestor = requestor;
this.initialReconnectDelayMillis = initialReconnectDelayMillis;
this.evaluationReasons = evaluationReasons;
- this.useReport = useReport;
+ this.usePost = usePost;
this.httpProperties = httpProperties;
this.executor = executor;
this.logger = logger;
@@ -188,9 +188,9 @@ private void startStream() {
return reqBuilder.build();
});
- if (useReport) {
+ if (usePost) {
connectStrategy = connectStrategy.methodAndBody(
- METHOD_REPORT,
+ METHOD_POST,
RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON));
}
@@ -244,7 +244,7 @@ private void startStream() {
private URI getStreamUri() {
URI uri = HttpHelpers.concatenateUriPath(streamBaseUri, streamRequestPath);
- if (!useReport) {
+ if (!usePost) {
uri = HttpHelpers.concatenateUriPath(uri, LDUtil.urlSafeBase64(evaluationContext));
}
if (evaluationReasons) {
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java
index 9056f9dcc..bb2026d6f 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java
@@ -814,7 +814,7 @@ public LDConfig build() {
ModeResolutionTable resolutionTable = ModeResolutionTable.createMobile(
startingMode, backgroundMode);
effectiveDataSource = new FDv2DataSourceBuilder(
- modeTable, startingMode, resolutionTable);
+ modeTable, startingMode, resolutionTable, this.dataSystemBuilder.isUsePost());
effectiveAutoModeSwitching = this.dataSystemBuilder.getAutomaticModeSwitchingConfig();
} else {
effectiveDataSource = this.dataSource == null
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java
index 06c51e32a..a0b415b7d 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java
@@ -17,8 +17,8 @@ private StandardEndpoints() {}
static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk";
static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic";
- static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval";
- static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval";
+ // GET appends the base64url context as a path segment; POST sends it in the body.
+ static final String FDV2_POLLING_REQUEST_BASE_PATH = "/sdk/poll/eval";
static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval";
/**
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java
index a96e96a4f..e2b24228f 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java
@@ -89,6 +89,7 @@ public class DataSystemBuilder {
private ConnectionMode foregroundConnectionMode = ConnectionMode.STREAMING;
private ConnectionMode backgroundConnectionMode = ConnectionMode.BACKGROUND;
private AutomaticModeSwitchingConfig automaticModeSwitchingConfig = AutomaticModeSwitchingConfig.enabled();
+ private boolean usePost = false;
private final Map connectionModeOverrides = new LinkedHashMap<>();
/**
@@ -160,6 +161,30 @@ public DataSystemBuilder automaticModeSwitching(@NonNull AutomaticModeSwitchingC
return this;
}
+ /**
+ * Sets whether polling and streaming flag requests send the evaluation context in the
+ * request body with the HTTP POST method.
+ *
+ * By default the SDK uses GET and encodes the context into the request path. POST keeps
+ * the context out of the URL, so it does not appear in request logs, and it avoids URL
+ * length limits for large contexts.
+ *
+ * When the SDK falls back to the FDv1 endpoints, the context is sent in the request body
+ * with the REPORT method, which is the body-carrying method those endpoints accept.
+ *
+ * This option replaces {@link HttpConfigurationBuilder#useReport(boolean)} for the data
+ * system. The {@code useReport} option has no effect when a data system is configured.
+ *
+ * The default is {@code false}.
+ *
+ * @param usePost true to send flag requests with POST
+ * @return this builder
+ */
+ public DataSystemBuilder usePost(boolean usePost) {
+ this.usePost = usePost;
+ return this;
+ }
+
/**
* Overrides the data pipeline for a specific connection mode.
*
@@ -221,6 +246,15 @@ public AutomaticModeSwitchingConfig getAutomaticModeSwitchingConfig() {
return automaticModeSwitchingConfig;
}
+ /**
+ * Returns whether flag requests send the evaluation context in the request body with POST.
+ *
+ * @return true if flag requests use POST
+ */
+ public boolean isUsePost() {
+ return usePost;
+ }
+
/**
* Returns any user-specified mode overrides.
*
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java
index d59f1680d..f353e5f0e 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java
@@ -92,6 +92,10 @@ public HttpConfigurationBuilder headerTransform(LDHeaderUpdater headerTransform)
* However, the REPORT method is not always supported by operating systems or network gateways.
* Therefore it is disabled in the SDK by default. You can enable it if you know your code will
* not be running in an environment that disallows REPORT.
+ *
+ * This option has no effect when the data system is configured with
+ * {@link com.launchdarkly.sdk.android.LDConfig.Builder#dataSystem(DataSystemBuilder)}. Use
+ * {@link DataSystemBuilder#usePost(boolean)} instead to send the context in the request body.
*
* @param useReport true to enable the REPORT method
* @return the builder
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java
index 14843b404..3e3290e7a 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java
@@ -28,6 +28,7 @@ public class DataSourceBuildInputs {
private final ServiceEndpoints serviceEndpoints;
private final HttpConfiguration http;
private final boolean evaluationReasons;
+ private final boolean usePost;
private final SelectorSource selectorSource;
private final ScheduledExecutorService sharedExecutor;
private final File cacheDir;
@@ -40,6 +41,7 @@ public class DataSourceBuildInputs {
* @param serviceEndpoints the service endpoint URIs
* @param http the HTTP configuration
* @param evaluationReasons whether evaluation reasons are enabled
+ * @param usePost whether flag requests send the context in the request body with POST
* @param selectorSource the source for obtaining the current selector
* @param sharedExecutor shared executor for scheduling tasks; owned and shut down by
* the calling data source, so components must not shut it down
@@ -51,6 +53,7 @@ public DataSourceBuildInputs(
ServiceEndpoints serviceEndpoints,
HttpConfiguration http,
boolean evaluationReasons,
+ boolean usePost,
SelectorSource selectorSource,
ScheduledExecutorService sharedExecutor,
@NonNull File cacheDir,
@@ -60,6 +63,7 @@ public DataSourceBuildInputs(
this.serviceEndpoints = serviceEndpoints;
this.http = http;
this.evaluationReasons = evaluationReasons;
+ this.usePost = usePost;
this.selectorSource = selectorSource;
this.sharedExecutor = sharedExecutor;
this.cacheDir = cacheDir;
@@ -102,6 +106,17 @@ public boolean isEvaluationReasons() {
return evaluationReasons;
}
+ /**
+ * Returns whether polling and streaming flag requests send the evaluation context in the
+ * request body with the POST method. When false, the context is base64url-encoded into the
+ * request path and the GET method is used.
+ *
+ * @return true if flag requests use POST
+ */
+ public boolean isUsePost() {
+ return usePost;
+ }
+
/**
* Returns the selector source for obtaining the current FDv2 selector.
*
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java
index 6ea149573..9fe164544 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java
@@ -32,7 +32,7 @@ private static DataSourceBuildInputsInternal makeInternalInputs(
PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData
) {
return new DataSourceBuildInputsInternal(
- CONTEXT, null, null, false,
+ CONTEXT, null, null, false, false,
() -> Selector.EMPTY, null, CACHE_DIR,
LDLogger.none(), envData
);
@@ -40,7 +40,7 @@ private static DataSourceBuildInputsInternal makeInternalInputs(
private static DataSourceBuildInputs makePlainInputs() {
return new DataSourceBuildInputs(
- CONTEXT, null, null, false,
+ CONTEXT, null, null, false, false,
() -> Selector.EMPTY, null, CACHE_DIR,
LDLogger.none()
);
@@ -72,12 +72,25 @@ public void get_withPlainInputs_preservesBaseProperties() {
assertEquals(plain.getServiceEndpoints(), result.getServiceEndpoints());
assertEquals(plain.getHttp(), result.getHttp());
assertEquals(plain.isEvaluationReasons(), result.isEvaluationReasons());
+ assertEquals(plain.isUsePost(), result.isUsePost());
assertEquals(plain.getSelectorSource(), result.getSelectorSource());
assertEquals(plain.getSharedExecutor(), result.getSharedExecutor());
assertEquals(plain.getCacheDir(), result.getCacheDir());
assertEquals(plain.getBaseLogger(), result.getBaseLogger());
}
+ @Test
+ public void get_withPlainInputs_preservesUsePost() {
+ DataSourceBuildInputs plain = new DataSourceBuildInputs(
+ CONTEXT, null, null, false, true,
+ () -> Selector.EMPTY, null, CACHE_DIR,
+ LDLogger.none()
+ );
+ DataSourceBuildInputsInternal result = DataSourceBuildInputsInternal.get(plain);
+
+ assertTrue(result.isUsePost());
+ }
+
// ---- getPerEnvironmentDataIfAvailable() ----
@Test
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java
index 0195ec4c1..3ec823c5e 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java
@@ -1,6 +1,7 @@
package com.launchdarkly.sdk.android;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
@@ -26,6 +27,17 @@ public void defaultBuilder_hasExpectedConnectionModesAndAutoSwitching() {
assertTrue(b.getConnectionModeOverrides().isEmpty());
}
+ @Test
+ public void defaultBuilder_usePostIsFalse() {
+ assertFalse(Components.dataSystem().isUsePost());
+ }
+
+ @Test
+ public void usePost_setsFlag() {
+ assertTrue(Components.dataSystem().usePost(true).isUsePost());
+ assertFalse(Components.dataSystem().usePost(true).usePost(false).isUsePost());
+ }
+
@Test
public void buildModeTable_containsAllStandardModes() {
Map table = Components.dataSystem().buildModeTable(false);
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java
new file mode 100644
index 000000000..f7ee56653
--- /dev/null
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java
@@ -0,0 +1,204 @@
+package com.launchdarkly.sdk.android;
+
+import com.launchdarkly.logging.LDLogger;
+import com.launchdarkly.sdk.LDContext;
+import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints;
+import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs;
+import com.launchdarkly.sdk.android.subsystems.HttpConfiguration;
+import com.launchdarkly.sdk.android.subsystems.Initializer;
+import com.launchdarkly.sdk.android.subsystems.Synchronizer;
+import com.launchdarkly.sdk.fdv2.Selector;
+import com.launchdarkly.testhelpers.httptest.Handlers;
+import com.launchdarkly.testhelpers.httptest.HttpServer;
+import com.launchdarkly.testhelpers.httptest.RequestInfo;
+
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.Timeout;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Verifies that the built-in FDv2 data source builders wire the build inputs into the
+ * requests they send: the request method, the request path, and where the context travels.
+ */
+public class DataSystemComponentsTest {
+
+ @Rule
+ public Timeout globalTimeout = Timeout.seconds(15);
+
+ private static final LDContext CONTEXT = LDContext.create("wiring-test-context-key");
+ private static final File CACHE_DIR = new File(System.getProperty("java.io.tmpdir"));
+
+ private static final String FDV2_POLL_RESPONSE_JSON =
+ "{\"events\":[" +
+ "{\"event\":\"server-intent\",\"data\":{\"payloads\":[{\"id\":\"p1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"\"}]}}," +
+ "{\"event\":\"payload-transferred\",\"data\":{\"state\":\"(p:p1:100)\",\"version\":100}}" +
+ "]}";
+
+ private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
+
+ @After
+ public void tearDown() {
+ executor.shutdownNow();
+ }
+
+ private DataSourceBuildInputs makeInputs(HttpServer server, boolean usePost) {
+ ServiceEndpoints endpoints = Components.serviceEndpoints()
+ .polling(server.getUri())
+ .streaming(server.getUri())
+ .events(server.getUri())
+ .createServiceEndpoints();
+ HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, false);
+ return new DataSourceBuildInputs(
+ CONTEXT, endpoints, http, false, usePost,
+ () -> Selector.EMPTY, executor, CACHE_DIR, LDLogger.none());
+ }
+
+ private static String contextPathSegment() {
+ return "/" + LDUtil.urlSafeBase64(CONTEXT);
+ }
+
+ private static void assertContextInBody(RequestInfo request) {
+ assertNotNull("body should contain the serialized context", request.getBody());
+ assertTrue("body should contain the context key",
+ request.getBody().contains("wiring-test-context-key"));
+ }
+
+ private static void assertNoBody(RequestInfo request) {
+ assertTrue("request should have no body",
+ request.getBody() == null || request.getBody().isEmpty());
+ }
+
+ // ---- polling initializer ----
+
+ @Test
+ public void pollingInitializer_defaultUsesGetWithContextInPath() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.bodyJson(FDV2_POLL_RESPONSE_JSON))) {
+ Initializer initializer = new DataSystemComponents.PollingInitializerBuilderImpl()
+ .build(makeInputs(server, false));
+ initializer.run().get(5, TimeUnit.SECONDS);
+ initializer.close();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ assertEquals("GET", request.getMethod());
+ assertEquals(StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH + contextPathSegment(),
+ request.getPath());
+ assertNoBody(request);
+ }
+ }
+
+ @Test
+ public void pollingInitializer_usePostSendsContextInBody() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.bodyJson(FDV2_POLL_RESPONSE_JSON))) {
+ Initializer initializer = new DataSystemComponents.PollingInitializerBuilderImpl()
+ .build(makeInputs(server, true));
+ initializer.run().get(5, TimeUnit.SECONDS);
+ initializer.close();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ assertEquals("POST", request.getMethod());
+ assertEquals(StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH, request.getPath());
+ assertContextInBody(request);
+ }
+ }
+
+ // ---- polling synchronizer ----
+
+ @Test
+ public void pollingSynchronizer_usePostSendsContextInBody() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.bodyJson(FDV2_POLL_RESPONSE_JSON))) {
+ Synchronizer synchronizer = new DataSystemComponents.PollingSynchronizerBuilderImpl()
+ .build(makeInputs(server, true));
+ synchronizer.next().get(5, TimeUnit.SECONDS);
+ synchronizer.close();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ assertEquals("POST", request.getMethod());
+ assertEquals(StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH, request.getPath());
+ assertContextInBody(request);
+ }
+ }
+
+ // ---- streaming synchronizer ----
+
+ @Test
+ public void streamingSynchronizer_defaultUsesGetWithContextInPath() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.all(
+ Handlers.SSE.start(),
+ Handlers.SSE.leaveOpen()))) {
+ Synchronizer synchronizer = new DataSystemComponents.StreamingSynchronizerBuilderImpl()
+ .build(makeInputs(server, false));
+ synchronizer.next();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ synchronizer.close();
+
+ assertEquals("GET", request.getMethod());
+ assertEquals(StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH + contextPathSegment(),
+ request.getPath());
+ assertNoBody(request);
+ }
+ }
+
+ @Test
+ public void streamingSynchronizer_usePostSendsContextInBody() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.all(
+ Handlers.SSE.start(),
+ Handlers.SSE.leaveOpen()))) {
+ Synchronizer synchronizer = new DataSystemComponents.StreamingSynchronizerBuilderImpl()
+ .build(makeInputs(server, true));
+ synchronizer.next();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ synchronizer.close();
+
+ assertEquals("POST", request.getMethod());
+ assertEquals(StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, request.getPath());
+ assertContextInBody(request);
+ }
+ }
+
+ // ---- FDv1 fallback synchronizer ----
+
+ @Test
+ public void fdv1FallbackSynchronizer_defaultUsesGetWithContextInPath() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.bodyJson("{}"))) {
+ Synchronizer synchronizer = new DataSystemComponents.FDv1PollingSynchronizerBuilderImpl()
+ .build(makeInputs(server, false));
+ synchronizer.next().get(5, TimeUnit.SECONDS);
+ synchronizer.close();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ assertEquals("GET", request.getMethod());
+ assertEquals(StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH + contextPathSegment(),
+ request.getPath());
+ assertNoBody(request);
+ }
+ }
+
+ @Test
+ public void fdv1FallbackSynchronizer_usePostSendsContextInBodyWithReport() throws Exception {
+ // The FDv1 endpoints do not accept POST; REPORT is their body-carrying method.
+ try (HttpServer server = HttpServer.start(Handlers.bodyJson("{}"))) {
+ Synchronizer synchronizer = new DataSystemComponents.FDv1PollingSynchronizerBuilderImpl()
+ .build(makeInputs(server, true));
+ synchronizer.next().get(5, TimeUnit.SECONDS);
+ synchronizer.close();
+
+ RequestInfo request = server.getRecorder().requireRequest();
+ assertEquals("REPORT", request.getMethod());
+ assertEquals(StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH, request.getPath());
+ assertContextInBody(request);
+ }
+ }
+}
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java
index a724ea87e..574a6c673 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java
@@ -36,8 +36,7 @@ public class DefaultFDv2RequestorTest {
private static final LDContext CONTEXT = LDContext.create("test-context-key");
// Bogus paths used by tests — deliberately not the real production paths.
- private static final String GET_REQUEST_PATH = "/fake-poll-get";
- private static final String REPORT_REQUEST_PATH = "/fake-poll-report";
+ private static final String REQUEST_PATH = "/fake-poll";
private static final LDLogger LOGGER = LDLogger.none();
private static HttpProperties httpProperties() {
@@ -49,14 +48,13 @@ private static HttpProperties httpProperties() {
null, null);
}
- private static DefaultFDv2Requestor makeRequestor(HttpServer server, boolean useReport, boolean evaluationReasons, String payloadFilter) {
+ private static DefaultFDv2Requestor makeRequestor(HttpServer server, boolean usePost, boolean evaluationReasons, String payloadFilter) {
return new DefaultFDv2Requestor(
CONTEXT,
server.getUri(),
- GET_REQUEST_PATH,
- REPORT_REQUEST_PATH,
+ REQUEST_PATH,
httpProperties(),
- useReport,
+ usePost,
evaluationReasons,
payloadFilter,
LOGGER);
@@ -93,7 +91,7 @@ public void successfulRequestWithEvents() throws Exception {
assertEquals("payload-transferred", response.getEvents().get(2).getEventType());
RequestInfo req = server.getRecorder().requireRequest();
- assertTrue("path should start with " + GET_REQUEST_PATH, req.getPath().startsWith(GET_REQUEST_PATH));
+ assertTrue("path should start with " + REQUEST_PATH, req.getPath().startsWith(REQUEST_PATH));
}
}
}
@@ -121,7 +119,7 @@ public void requestWithBasisQueryParameter() throws Exception {
future.get(5, TimeUnit.SECONDS);
RequestInfo req = server.getRecorder().requireRequest();
- assertTrue(req.getPath().startsWith(GET_REQUEST_PATH));
+ assertTrue(req.getPath().startsWith(REQUEST_PATH));
assertTrue("query should contain basis=test-state", req.getQuery() != null && req.getQuery().contains("basis=test-state"));
}
}
@@ -260,7 +258,7 @@ public void requestWithBasisContainingState() throws Exception {
future.get(5, TimeUnit.SECONDS);
RequestInfo req = server.getRecorder().requireRequest();
- assertTrue(req.getPath().startsWith(GET_REQUEST_PATH));
+ assertTrue(req.getPath().startsWith(REQUEST_PATH));
assertTrue("query should contain URL-encoded basis", req.getQuery() != null && req.getQuery().contains("basis=%28p%3Apayload-1%3A100%29"));
}
}
@@ -274,7 +272,7 @@ public void requestWithComplexBasisState() throws Exception {
future.get(5, TimeUnit.SECONDS);
RequestInfo req = server.getRecorder().requireRequest();
- assertTrue(req.getPath().startsWith(GET_REQUEST_PATH));
+ assertTrue(req.getPath().startsWith(REQUEST_PATH));
assertTrue("query should contain URL-encoded basis", req.getQuery() != null && req.getQuery().contains("basis=%28p%3Amy-payload%3A200%29"));
}
}
@@ -357,8 +355,7 @@ public void baseUriCanHaveContextPath() throws Exception {
try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor(
CONTEXT,
uri,
- GET_REQUEST_PATH,
- REPORT_REQUEST_PATH,
+ REQUEST_PATH,
httpProperties(),
false,
false,
@@ -368,7 +365,7 @@ public void baseUriCanHaveContextPath() throws Exception {
future.get(5, TimeUnit.SECONDS);
RequestInfo req = server.getRecorder().requireRequest();
- assertTrue("path should start with context path + request path", req.getPath().startsWith("/fake-base" + GET_REQUEST_PATH));
+ assertTrue("path should start with context path + request path", req.getPath().startsWith("/fake-base" + REQUEST_PATH));
}
}
}
@@ -506,18 +503,16 @@ public void evaluationReasonsAddedToRequest() throws Exception {
}
@Test
- public void useReportMethod() throws Exception {
+ public void usePostMethod() throws Exception {
try (HttpServer server = HttpServer.start(Handlers.bodyJson(EMPTY_EVENTS_JSON))) {
try (DefaultFDv2Requestor requestor = makeRequestor(server, true, false, null)) {
requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS);
RequestInfo req = server.getRecorder().requireRequest();
- assertEquals("REPORT", req.getMethod());
- // Context should be in the request body, not the URL path
- assertTrue("path should use REPORT path without context segment",
- req.getPath().startsWith(REPORT_REQUEST_PATH));
- assertFalse("path should not contain GET context segment",
- req.getPath().startsWith(GET_REQUEST_PATH));
+ assertEquals("POST", req.getMethod());
+ // The context goes in the request body, not in the URL path
+ assertEquals("path should be the request path with no context segment",
+ REQUEST_PATH, req.getPath());
assertNotNull("body should contain serialized context", req.getBody());
assertTrue("body should contain context key",
req.getBody().contains("test-context-key"));
@@ -526,23 +521,38 @@ public void useReportMethod() throws Exception {
}
@Test
- public void useReportMethodDoesNotUseEtag() throws Exception {
- // ETag caching is disabled for REPORT requests since the context is in the body
+ public void getMethodAppendsContextToPath() throws Exception {
+ try (HttpServer server = HttpServer.start(Handlers.bodyJson(EMPTY_EVENTS_JSON))) {
+ try (DefaultFDv2Requestor requestor = makeRequestor(server)) {
+ requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS);
+
+ RequestInfo req = server.getRecorder().requireRequest();
+ assertEquals("GET", req.getMethod());
+ assertEquals(REQUEST_PATH + "/" + LDUtil.urlSafeBase64(CONTEXT), req.getPath());
+ assertTrue("GET request should have no body",
+ req.getBody() == null || req.getBody().isEmpty());
+ }
+ }
+ }
+
+ @Test
+ public void usePostMethodDoesNotUseEtag() throws Exception {
+ // ETag caching is disabled for POST requests since the context is in the body
Handler etagResp = Handlers.all(
Handlers.header("ETag", "some-etag"),
Handlers.bodyJson(EMPTY_EVENTS_JSON));
try (HttpServer server = HttpServer.start(etagResp)) {
try (DefaultFDv2Requestor requestor = makeRequestor(server, true, false, null)) {
- // First REPORT request — server returns an ETag, but it should not be cached
+ // First POST request: the server returns an ETag, but it is not stored
requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS);
RequestInfo req1 = server.getRecorder().requireRequest();
- assertNull("REPORT request should not send If-None-Match", req1.getHeader("If-None-Match"));
+ assertNull("POST request should not send If-None-Match", req1.getHeader("If-None-Match"));
- // Second REPORT request — still no If-None-Match because ETag was never stored
+ // Second POST request: still no If-None-Match because the ETag was never stored
requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS);
RequestInfo req2 = server.getRecorder().requireRequest();
- assertNull("REPORT request should never send If-None-Match", req2.getHeader("If-None-Match"));
+ assertNull("POST request should never send If-None-Match", req2.getHeader("If-None-Match"));
}
}
}
@@ -551,7 +561,7 @@ public void useReportMethodDoesNotUseEtag() throws Exception {
public void http400LogsProguardWarning() throws Exception {
try (HttpServer server = HttpServer.start(Handlers.status(400))) {
try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor(
- CONTEXT, server.getUri(), GET_REQUEST_PATH, REPORT_REQUEST_PATH,
+ CONTEXT, server.getUri(), REQUEST_PATH,
httpProperties(), false, false, null, logging.logger)) {
Future future = requestor.poll(Selector.EMPTY);
@@ -569,7 +579,7 @@ public void http400LogsProguardWarning() throws Exception {
public void networkFailureThrowsException() throws Exception {
try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor(
CONTEXT, URI.create("http://localhost:1"),
- GET_REQUEST_PATH, REPORT_REQUEST_PATH,
+ REQUEST_PATH,
httpProperties(), false, false, null, LOGGER)) {
requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS);
}
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java
index a2044ab77..936190f1d 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java
@@ -15,6 +15,7 @@
import com.launchdarkly.sdk.android.subsystems.ClientContext;
import com.launchdarkly.sdk.android.subsystems.DataSource;
import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder;
+import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult;
import com.launchdarkly.sdk.android.subsystems.HttpConfiguration;
import com.launchdarkly.sdk.android.subsystems.Initializer;
import com.launchdarkly.sdk.android.subsystems.Synchronizer;
@@ -25,7 +26,9 @@
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
public class FDv2DataSourceBuilderTest {
@@ -36,9 +39,13 @@ public class FDv2DataSourceBuilderTest {
public LogCaptureRule logging = new LogCaptureRule();
private ClientContext makeClientContext() {
+ return makeClientContext(false);
+ }
+
+ private ClientContext makeClientContext(boolean useReport) {
LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build();
MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink();
- HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, false);
+ HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, useReport);
ClientContext base = new ClientContext(
"mobile-key",
ENV_REPORTER,
@@ -65,6 +72,92 @@ public void defaultBuilder_buildsFDv2DataSource() {
assertTrue(ds instanceof FDv2DataSource);
}
+ @Test
+ public void usePost_isPassedToDataSourceBuilders() throws Exception {
+ AtomicReference seenUsePost = new AtomicReference<>();
+ Map customTable = new LinkedHashMap<>();
+ customTable.put(ConnectionMode.POLLING, new ModeDefinition(
+ Collections.>singletonList(inputs -> {
+ seenUsePost.set(inputs.isUsePost());
+ return new Initializer() {
+ @Override
+ public Future run() {
+ LDAwaitFuture future = new LDAwaitFuture<>();
+ future.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false));
+ return future;
+ }
+
+ @Override
+ public void close() {
+ }
+ };
+ }),
+ Collections.>emptyList(),
+ null
+ ));
+
+ FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(
+ customTable, ConnectionMode.POLLING, ModeResolutionTable.MOBILE, true);
+ assertTrue(builder.isUsePost());
+ DataSource ds = builder.build(makeClientContext());
+
+ // Factories are lazy: start the source so the initializer builder receives the inputs.
+ AwaitableCallback startCallback = new AwaitableCallback<>();
+ ds.start(startCallback);
+ try {
+ startCallback.await(5000);
+ } catch (Exception ignored) {
+ // The start outcome is not under test; only the inputs the builder received.
+ }
+ AwaitableCallback stopCallback = new AwaitableCallback<>();
+ ds.stop(stopCallback);
+ stopCallback.await(2000);
+ builder.close();
+
+ assertEquals(Boolean.TRUE, seenUsePost.get());
+ }
+
+ @Test
+ public void usePost_defaultsToFalse() {
+ assertFalse(new FDv2DataSourceBuilder().isUsePost());
+ Map customTable = new LinkedHashMap<>();
+ customTable.put(ConnectionMode.POLLING, new ModeDefinition(
+ Collections.>emptyList(),
+ Collections.>emptyList(),
+ null
+ ));
+ assertFalse(new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING).isUsePost());
+ }
+
+ @Test
+ public void useReport_logsWarningOnceAcrossBuilds() {
+ FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder();
+ ClientContext ctx = makeClientContext(true);
+ builder.build(ctx);
+ builder.build(ctx);
+ builder.close();
+
+ int warnings = 0;
+ for (String message : logging.logCapture.getMessageStrings()) {
+ if (message.contains("WARN:") && message.contains("useReport")) {
+ warnings++;
+ }
+ }
+ assertEquals(1, warnings);
+ logging.assertWarnLogged("DataSystemBuilder.usePost");
+ }
+
+ @Test
+ public void useReportDisabled_logsNoUseReportWarning() {
+ FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder();
+ builder.build(makeClientContext(false));
+ builder.close();
+
+ for (String message : logging.logCapture.getMessageStrings()) {
+ assertFalse(message, message.contains("useReport"));
+ }
+ }
+
@Test
public void customModeTable_buildsCorrectly() {
Map customTable = new LinkedHashMap<>();
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java
index 7065d6d7e..1f58a044f 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java
@@ -37,7 +37,7 @@ public class FDv2EntryConverterTest {
private static DataSourceBuildInputs plainInputs() {
return new DataSourceBuildInputs(
- CONTEXT, null, null, false,
+ CONTEXT, null, null, false, false,
() -> Selector.EMPTY, null, CACHE_DIR,
LDLogger.none());
}
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java
index 9aa3891d9..7589b5d79 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java
@@ -51,8 +51,7 @@ public void tearDown() {
// Bogus paths used by tests — deliberately not the real production paths.
private static final String STREAM_PATH = "/fake-stream";
- private static final String POLL_GET_PATH = "/fake-poll-get";
- private static final String POLL_REPORT_PATH = "/fake-poll-report";
+ private static final String POLL_PATH = "/fake-poll";
private static HttpProperties httpProperties() {
return new HttpProperties(
@@ -92,10 +91,10 @@ private FDv2StreamingSynchronizer makeSynchronizer(
private FDv2StreamingSynchronizer makeSynchronizer(
URI streamBaseUri,
- boolean evaluationReasons, boolean useReport) {
+ boolean evaluationReasons, boolean usePost) {
return new FDv2StreamingSynchronizer(
CONTEXT, EMPTY_SELECTOR_SOURCE, streamBaseUri, STREAM_PATH,
- null, 100, evaluationReasons, useReport,
+ null, 100, evaluationReasons, usePost,
httpProperties(), executor, LOGGER, null);
}
@@ -1051,7 +1050,7 @@ public void evaluationReasonsAddedToStreamUrl() throws Exception {
}
@Test
- public void useReportMethodForStreamRequest() throws Exception {
+ public void usePostMethodForStreamRequest() throws Exception {
String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}");
String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}");
@@ -1062,17 +1061,17 @@ public void useReportMethodForStreamRequest() throws Exception {
Handlers.SSE.leaveOpen()))) {
FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri(),
- false, true /* useReport */);
+ false, true /* usePost */);
Future resultFuture = sync.next();
FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS);
assertEquals(SourceResultType.CHANGE_SET, result.getResultType());
RequestInfo request = server.getRecorder().requireRequest();
- assertEquals("REPORT", request.getMethod());
- // With REPORT the context goes in the body, not the URL path
- assertFalse("path should not contain base64 context segment when using REPORT",
- request.getPath().contains(LDUtil.urlSafeBase64(CONTEXT)));
+ assertEquals("POST", request.getMethod());
+ // With POST the context goes in the body, not the URL path
+ assertEquals("path should be the stream path with no context segment",
+ STREAM_PATH, request.getPath());
assertNotNull("body should contain serialized context", request.getBody());
assertTrue("body should contain context key",
request.getBody().contains("test-context"));
@@ -1126,7 +1125,7 @@ public void pingEventWithRequestorTriggersPoll() throws Exception {
DefaultFDv2Requestor requestor = new DefaultFDv2Requestor(
CONTEXT, pollServer.getUri(),
- POLL_GET_PATH, POLL_REPORT_PATH,
+ POLL_PATH,
httpProperties(), false, false, null, LOGGER);
FDv2StreamingSynchronizer sync = new FDv2StreamingSynchronizer(
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java
index a29aacba5..ee39916d8 100644
--- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java
@@ -54,6 +54,21 @@ public void dataSystem_resolutionTable_matchesCreateMobileForForegroundAndBackgr
assertResolutionTablesAgree(expected, fdv2.getResolutionTable());
}
+ @Test
+ public void dataSystem_propagatesUsePostToFDv2DataSourceBuilder() {
+ LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled)
+ .mobileKey("k")
+ .dataSystem(Components.dataSystem().usePost(true))
+ .build();
+ assertTrue(((FDv2DataSourceBuilder) config.dataSource).isUsePost());
+
+ LDConfig defaultConfig = new LDConfig.Builder(AutoEnvAttributes.Disabled)
+ .mobileKey("k")
+ .dataSystem(Components.dataSystem())
+ .build();
+ assertFalse(((FDv2DataSourceBuilder) defaultConfig.dataSource).isUsePost());
+ }
+
@Test
public void dataSystem_propagatesAutomaticModeSwitchingFromDataSystemBuilder() {
AutomaticModeSwitchingConfig granular = DataSystemComponents.automaticModeSwitching()
From bcad1eae8e3e67d627498edd5ed1ca08273f0c14 Mon Sep 17 00:00:00 2001
From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:51:13 -0700
Subject: [PATCH 2/2] test: Run the client-use-post contract tests against the
Android service
The test service declares client-use-post and maps clientSide.usePost
into the data system configuration. The v3 harness pin moves to
v3.2.0-alpha.10, the first release with the POST request variants.
That release also adds slash-prefixed attribute redaction tests that
the SDK does not pass yet; they are suppressed for the FDv2 run.
---
Makefile | 4 ++--
.../java/com/launchdarkly/sdktest/Representations.java | 1 +
.../java/com/launchdarkly/sdktest/SdkClientEntity.java | 9 +++++----
.../main/java/com/launchdarkly/sdktest/TestService.java | 3 ++-
testharness-suppressions-fdv2.txt | 5 ++++-
5 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/Makefile b/Makefile
index ed9965b7b..05f23b0a6 100644
--- a/Makefile
+++ b/Makefile
@@ -21,8 +21,8 @@ run-contract-tests:
| 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.1.0-alpha.6/downloader/run.sh \
- | VERSION=v3.1.0-alpha.6 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
+ -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.2.0-alpha.10/downloader/run.sh \
+ | VERSION=v3.2.0-alpha.10 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
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java
index 4b7aa650a..5c94b5806 100644
--- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java
@@ -78,6 +78,7 @@ public static class SdkConfigClientSideParams {
LDContext initialContext;
boolean evaluationReasons;
boolean useReport;
+ boolean usePost;
boolean includeEnvironmentAttributes;
}
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java
index f96277041..1e35a31d2 100644
--- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java
@@ -284,7 +284,7 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter,
ConfigHelper.configureIsolatedInMemoryPersistence(builder);
if (params.dataSystem != null) {
- configureDataSystem(builder, params.dataSystem);
+ configureDataSystem(builder, params.dataSystem, params.clientSide.usePost);
} else {
if (params.polling != null && params.polling.baseUri != null) {
endpoints.polling(params.polling.baseUri);
@@ -391,15 +391,16 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter,
return builder.build();
}
- private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem) {
+ private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem,
+ boolean usePost) {
if (Boolean.TRUE.equals(dataSystem.useDefaultDataSystem)) {
- builder.dataSystem(Components.dataSystem());
+ builder.dataSystem(Components.dataSystem().usePost(usePost));
return;
}
SdkConfigConnectionModeConfig connModeConfig = dataSystem.connectionModeConfig;
- DataSystemBuilder dsBuilder = Components.dataSystem();
+ DataSystemBuilder dsBuilder = Components.dataSystem().usePost(usePost);
// 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
diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java
index b6618d9e0..105006a6f 100644
--- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java
+++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java
@@ -43,7 +43,8 @@ public class TestService extends NanoHTTPD {
"evaluation-hooks",
"track-hooks",
"client-per-context-summaries",
- "client-event-source-http-errors"
+ "client-event-source-http-errors",
+ "client-use-post"
};
private static final String MIME_JSON = "application/json";
static final Gson gson = new GsonBuilder()
diff --git a/testharness-suppressions-fdv2.txt b/testharness-suppressions-fdv2.txt
index 5e53af750..ceb41bc53 100644
--- a/testharness-suppressions-fdv2.txt
+++ b/testharness-suppressions-fdv2.txt
@@ -1 +1,4 @@
-streaming/fdv2/fallback to FDv1 handling
\ No newline at end of file
+streaming/fdv2/fallback to FDv1 handling
+events/feature events/single-kind anonymous context redacts all attributes
+events/feature events/multi-kind with anonymous context redacts attributes appropriately
+events/context properties/single-kind, allAttributesPrivate, slash-prefixed attribute name