diff --git a/.gitignore b/.gitignore index 92e132338..f80df59e2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ build .project .settings/ .vscode/ + +# CENTINEL seat lane (local branches; never pushed upstream): build outputs and probe leftovers +/gen/ +/sdk-classes/ +/seat-classes/ +/sdk.jar +tools/seat-test/classes/ +tools/seat-test/extracted-cons/ +tools/seat-test/stub-src/ +tools/seat-test/stub-src-tmp/ diff --git a/README.md b/README.md index 70dba02bb..555cbe93d 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,10 @@ We encourage pull requests and other contributions from the community. Check out * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation * [launchdarkly.com/blog](https://launchdarkly.com/blog/ "LaunchDarkly Blog Documentation") for the latest product updates + +## Continuous integration + +`docs/ci/contract-tests.yml.template` holds the CI gate's bytes: an operator applies +them at `.github/workflows/contract-tests.yml`, and the landed workflow prices the +`ConnectivityManagerTest` replay faces — the `a243c235` FDv2-OFFLINE double-window +trio — on every pull request to `main` (Ubuntu Actions runner, ubuntu-22.04, JDK 17). diff --git a/contract-tests/src/main/java/com/launchdarkly/sdk/android/ConfigHelper.java b/contract-tests/src/main/java/com/launchdarkly/sdk/android/ConfigHelper.java index 562a40452..c4743d018 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdk/android/ConfigHelper.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdk/android/ConfigHelper.java @@ -1,5 +1,9 @@ package com.launchdarkly.sdk.android; +import com.launchdarkly.sdk.LDContext; + +import java.util.concurrent.ConcurrentHashMap; + public class ConfigHelper { // This method exists because Config.Builder.persistentDataStore is currently package-private - // using a custom persistence component is not yet part of the public API. @@ -7,4 +11,224 @@ public static LDConfig.Builder configureIsolatedInMemoryPersistence(LDConfig.Bui builder.persistentDataStore(new InMemoryPersistentDataStore()); return builder; } + + /** + * CENTINEL seat-cell TLS seam (the v3 TLS data face: skipVerifyPeer / + * customCAFile): provisions the socket factory + trust manager the SDK's + * own {@code LDUtil.makeHttpProperties} honors for every HTTP stack + * (poll / stream / events). Module-internal, no public API grows; + * absent params reset the slots so one scenario's TLS faces never leak + * into the next client. + */ + public static void configureTls(LDConfig.Builder builder, + Boolean skipVerifyPeer, String customCAFile) { + javax.net.ssl.SSLSocketFactory socketFactory = null; + javax.net.ssl.X509TrustManager trustManager = null; + if (Boolean.TRUE.equals(skipVerifyPeer)) { + trustManager = new LenientTrustManager(); + // OkHttp binds the stack via sslSocketFactory(factory, trustManager) + // (the shared core's HttpProperties applies the pair together), so + // the skip-verify face needs a factory that matches the permissive + // trust manager — a plain default-JSSE context wraps fine when the + // trust manager rides in from the caller. + try { + javax.net.ssl.SSLContext ssl = javax.net.ssl.SSLContext.getInstance("TLS"); + ssl.init(null, new javax.net.ssl.TrustManager[] {trustManager}, null); + socketFactory = ssl.getSocketFactory(); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("TLS provisioning failed", e); + } + } else if (customCAFile != null) { + try { + java.io.FileInputStream in = new java.io.FileInputStream(customCAFile); + java.security.cert.CertificateFactory certFactory = + java.security.cert.CertificateFactory.getInstance("X.509"); + java.util.Collection cas = + certFactory.generateCertificates(in); + in.close(); + java.security.KeyStore ks = java.security.KeyStore.getInstance( + java.security.KeyStore.getDefaultType()); + ks.load(null, null); + int i = 0; + for (java.security.cert.Certificate ca : cas) { + ks.setCertificateEntry("ca" + i++, ca); + } + javax.net.ssl.TrustManagerFactory tmf = + javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + for (javax.net.ssl.TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof javax.net.ssl.X509TrustManager) { + trustManager = (javax.net.ssl.X509TrustManager) tm; + } + } + javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS"); + sslContext.init(null, new javax.net.ssl.TrustManager[] {trustManager}, null); + socketFactory = sslContext.getSocketFactory(); + } catch (java.security.GeneralSecurityException | java.io.IOException e) { + throw new RuntimeException("TLS provisioning failed: " + customCAFile, e); + } + } + LDUtil.testTlsSocketFactory = socketFactory; + LDUtil.testTlsTrustManager = trustManager; + } + + /** + * CENTINEL seat-cell HTTP proxy seam (the harness `proxy` shape, the + * `http-proxy` capability): binds the SDK's own HTTP stacks (poll / + * stream / events) to the named proxy, so every connection rides the + * proxy's absolute-form forward face. Optional userinfo credentials on + * the URL provision the 407-replies-Proxy-Authorization authenticator. + * Module-internal, no public API grows; a null URL resets the slots so + * one scenario's proxy faces never leak into the next client. + */ + public static void configureHttpProxy(LDConfig.Builder builder, String httpProxy) { + java.net.Proxy proxy = null; + okhttp3.Authenticator proxyAuth = null; + if (httpProxy != null && !httpProxy.isEmpty()) { + try { + java.net.URI uri = new java.net.URI(httpProxy); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException("unsupported proxy scheme: " + scheme); + } + proxy = new java.net.Proxy(java.net.Proxy.Type.HTTP, new java.net.InetSocketAddress( + uri.getHost(), + uri.getPort() == -1 ? ("https".equals(scheme) ? 443 : 80) : uri.getPort())); + if (uri.getUserInfo() != null) { + int colon = uri.getUserInfo().indexOf(':'); + String user = colon < 0 ? uri.getUserInfo() : uri.getUserInfo().substring(0, colon); + String pass = colon < 0 ? "" : uri.getUserInfo().substring(colon + 1); + final String token = okhttp3.Credentials.basic(user, pass, + java.nio.charset.StandardCharsets.UTF_8); + proxyAuth = (route, response) -> { + if (response.request().header("Proxy-Authorization") != null) { + return null; // the proxy refused the provisioned credentials + } + return response.request().newBuilder() + .header("Proxy-Authorization", token).build(); + }; + } + } catch (java.net.URISyntaxException e) { + throw new RuntimeException("proxy provisioning failed: " + httpProxy, e); + } + } + LDUtil.testHttpProxy = proxy; + LDUtil.testHttpProxyAuth = proxyAuth; + } + + /** + * CENTINEL seat-cell payload filter seam (the ENVFILTER law's poll face): + * the poll requestor joins the configured environment filter as the + * {@code filter} query parameter (the poll driver's own relay). A null + * filter resets the slot per client. + */ + public static void configureEnvironmentFilter(LDConfig.Builder builder, String filter) { + LDUtil.testPayloadFilter = (filter == null || filter.isEmpty()) ? null : filter; + } + + /** + * CENTINEL seat-cell ETag store seam (the poll conditional-GET face): + * a scope-less client gets a fresh store; clients of one storage scope + * share one store (the same slot inventory the shared persistent store + * carries) so a relaunch re-keys the stored tags. The slot resets per + * client exactly like the TLS/proxy seams. + */ + private static final ConcurrentHashMap SCOPED_ETAGS = + new ConcurrentHashMap(); + + public static void configurePollEtagStore(String storageScope) { + LDUtil.testPollEtags = storageScope == null + ? new PollEtagStore() + : SCOPED_ETAGS.computeIfAbsent(storageScope, k -> new PollEtagStore()); + } + + /** The skip-verify face's trust manager: no certificate validation. */ + private static final class LenientTrustManager implements javax.net.ssl.X509TrustManager { + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) { + } + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + } + + + /** + * CENTINEL client-lane extension (campaign client-INTERFACE section 5, PERSIST.5): + * with a storage scope every client created under the same scope tag shares one local + * store (successive launches of one app); without one each client stays isolated as + * before. Returns the store wired into the builder so the caller can preload data. + */ + private static final ConcurrentHashMap SCOPED_STORES = + new ConcurrentHashMap(); + + public static InMemoryPersistentDataStore configureInMemoryPersistence(LDConfig.Builder builder, + String storageScope) { + InMemoryPersistentDataStore store = storageScope == null + ? new InMemoryPersistentDataStore() + : SCOPED_STORES.computeIfAbsent(storageScope, k -> new InMemoryPersistentDataStore()); + builder.persistentDataStore(store); + return store; + } + + /** + * CENTINEL client-lane extension: writes bootstrap flag data (the campaign's + * x-cp-bootstrap all-flags-state JSON) through the SDK's own persisted-store + * pathway, so the real {@code FDv2CacheInitializer} serves the values during + * client initialization — exactly the LD offline-cache bootstrapping contract. + * + * The all-flags-state shape carries values at the top level with per-flag + * metadata under {@code $flagsState}; the store format is the flat + * {@code {flagKey: Flag}} map, so the conversion keeps key/value/version/variation. + */ + public static void preloadBootstrapData(InMemoryPersistentDataStore store, + String mobileKey, java.util.Map bootData, LDContext initialContext) { + if (store == null || bootData == null || initialContext == null) { + return; + } + // Mirrors PersistentDataStoreWrapper's own namespacing: the per-environment + // namespace hashes the mobile key; the per-context key hashes the context's + // fully-qualified key. The literal prefixes live in the wrapper (private); the + // SDK's hash helpers are package-visible to this same-package caller. + String namespace = "LaunchDarkly_" + LDUtil.urlSafeBase64Hash(mobileKey == null ? "" : mobileKey); + String key = "flags_" + LDUtil.urlSafeBase64HashedContextId(initialContext); + + com.google.gson.Gson gson = new com.google.gson.Gson(); + com.google.gson.JsonObject flags = new com.google.gson.JsonObject(); + Object stateRaw = bootData.get("$flagsState"); + java.util.Map flagsState = stateRaw instanceof java.util.Map ? (java.util.Map) stateRaw : null; + for (java.util.Map.Entry e : bootData.entrySet()) { + String flagKey = e.getKey(); + if (flagKey.startsWith("$")) { + continue; // $flagsState / $valid metadata + } + com.google.gson.JsonObject f = new com.google.gson.JsonObject(); + f.addProperty("key", flagKey); + f.add("value", gson.toJsonTree(e.getValue())); + int version = 1; + Integer variation = null; + if (flagsState != null && flagsState.get(flagKey) instanceof java.util.Map) { + java.util.Map fs = (java.util.Map) flagsState.get(flagKey); + if (fs.get("version") instanceof Number) { + version = ((Number) fs.get("version")).intValue(); + } + if (fs.get("variation") instanceof Number) { + variation = ((Number) fs.get("variation")).intValue(); + } + } + f.addProperty("version", version); + if (variation != null) { + f.addProperty("variation", variation); + } + flags.add(flagKey, f); + } + store.setValue(namespace, key, gson.toJson(flags)); + } } diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java index 96128f03b..27b72eef3 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/HookCallbackService.java @@ -15,6 +15,15 @@ public HookCallbackService(URI serviceUri) { } public void post(Object params) { + // CENTINEL seat extension (CP-SPEC 7.2): a cp-callback:// callback URI rides + // the CP/stdio callback wheel — the seat MUST NOT dial it; it emits a + // `callback` envelope and awaits the runner's reply on stdin before the + // SDK-side call completes. Any other scheme keeps the harness's own + // HTTP receiver arm (used when the seat runs under CP/http on a device). + if (serviceUri != null && "cp-callback".equals(serviceUri.getScheme())) { + SdkClientEntity.seatPostCallbackHTTPFace(serviceUri, params); + return; + } RequestBody body = RequestBody.create( TestService.gson.toJson(params == null ? "{}" : params), MediaType.parse("application/json") diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/LogCapture.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/LogCapture.java new file mode 100644 index 000000000..2c3432395 --- /dev/null +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/LogCapture.java @@ -0,0 +1,146 @@ +package com.launchdarkly.sdktest; + +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LogValues; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * The x-cp-logs drain face (CP-SPEC 10): one ring of {level, code, message} + * records riding the seat logging chain. The capture adapter wraps the base + * adapter in TestService, so every SDK-facing log record (the seat's own + * logger included) both prints through the base adapter and records here — + * no SDK behavior changes, the records surface the seat's own log stream. + * + * NOT verifier-specific: the command maps CP onto the SDK's public logging + * API (CP-SPEC 11 item 8). The record shape prices the python seat's own + * precedent ({level, code, message}: code = the message's first four chars + * upper-cased). + */ +public final class LogCapture { + private final ConcurrentLinkedDeque> records = + new ConcurrentLinkedDeque<>(); + private final int limit; + + public LogCapture(int limit) { + this.limit = limit; + } + + public List> drain() { + List> out = new ArrayList<>(); + Map row; + while ((row = records.poll()) != null) { + out.add(row); + } + return out; + } + + void add(LDLogLevel level, String message) { + java.util.Map row = new java.util.LinkedHashMap<>(); + row.put("level", level == null ? "info" : level.name().toLowerCase()); + row.put("code", message != null + ? message.substring(0, Math.min(4, message.length())).toUpperCase() + : ""); + row.put("message", message == null ? "" : message); + records.add(row); + while (records.size() > limit) { + records.poll(); + } + } + + /** LDLogAdapter that records every log call and forwards to the base + * adapter's own channel (the seat's log stream keeps printing raw). */ + public static final class CaptureLogAdapter implements LDLogAdapter { + private final LDLogAdapter base; + private final LogCapture capture; + + public CaptureLogAdapter(LDLogAdapter base, LogCapture capture) { + this.base = base; + this.capture = capture; + } + + @Override + public LDLogAdapter.Channel newChannel(String name) { + return new CaptureChannel(base.newChannel(name), capture); + } + } + + private static final class CaptureChannel implements LDLogAdapter.Channel { + private final LDLogAdapter.Channel base; + private final LogCapture capture; + + CaptureChannel(LDLogAdapter.Channel base, LogCapture capture) { + this.base = base; + this.capture = capture; + } + + private static String render(String message, Object... args) { + if (message == null) { + return ""; + } + if (args == null || args.length == 0) { + return message; + } + // the logging lib's "{}" substitution face: the record's message prices + // the rendered line the SDK actually logged + StringBuilder out = new StringBuilder(message.length() + 40); + int argIndex = 0; + int i = 0; + while (i < message.length()) { + int brace = message.indexOf("{}", i); + if (brace < 0) { + out.append(message, i, message.length()); + break; + } + out.append(message, i, brace); + if (argIndex < args.length) { + Object arg = args[argIndex++]; + out.append(arg instanceof LogValues.StringProvider + ? ((LogValues.StringProvider) arg).get() + : String.valueOf(arg)); + } + i = brace + 2; + } + return out.toString(); + } + + private void record(LDLogLevel level, String message, Object[] args) { + capture.add(level, render(message, args != null ? args : new Object[0])); + } + + @Override + public boolean isEnabled(LDLogLevel level) { + return base.isEnabled(level); + } + + @Override + public void log(LDLogLevel level, Object message) { + capture.add(level, message instanceof LogValues.StringProvider + ? ((LogValues.StringProvider) message).get() + : String.valueOf(message)); + base.log(level, message); + } + + @Override + public void log(LDLogLevel level, String message, Object arg1) { + record(level, message, new Object[] {arg1}); + base.log(level, message, arg1); + } + + @Override + public void log(LDLogLevel level, String message, Object arg1, Object arg2) { + record(level, message, new Object[] {arg1, arg2}); + base.log(level, message, arg1, arg2); + } + + @Override + public void log(LDLogLevel level, String message, Object... args) { + record(level, message, args); + base.log(level, message, args); + } + } +} 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..4c3658cdf 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java @@ -40,6 +40,24 @@ public static class SdkConfigParams { SdkConfigServiceEndpointParams serviceEndpoints; SdkConfigHookParams hooks; SdkConfigDataSystemParams dataSystem; + SdkConfigWrapperParams wrapper; + SdkConfigTlsParams tls; + SdkConfigProxyParams proxy; + } + + /** The v3 TLS data face (v3 service-spec tls params): `skipVerifyPeer` + * and `customCAFile` (the run CA path the {{runtime:ca_file}} placeholder + * resolves to at the grader). */ + public static class SdkConfigTlsParams { + Boolean skipVerifyPeer; + String customCAFile; + } + + /** The harness `proxy` shape (service_spec 387-388, the `http-proxy` + * capability): the SDK routes every connection through the named proxy + * (the `httpProxy` URL carries its own optional userinfo credentials). */ + public static class SdkConfigProxyParams { + String httpProxy; } public static class SdkConfigStreamParams { @@ -74,11 +92,39 @@ public static class SdkConfigServiceEndpointParams { String events; } + /** cp.schema sdkConfiguration/properties/wrapper (v3:L384): the + * X-LaunchDarkly-Wrapper header config. Declared under the + * `wrapper` capability. */ + public static class SdkConfigWrapperParams { + String name; + String version; + } + public static class SdkConfigClientSideParams { LDContext initialContext; + /** CENTINEL census seat-cell seam (the user-type class's own law): the + * harness's legacy initial-user wire (the LDUser JSON shape the harness's + * InitialUser face serves; {email, key, name, ...}). Raw JsonElement: + * the legacy-user wire parses through the seat's own legacy-user arm + * (buildLegacyUserContext), not the context deserializer. */ + com.google.gson.JsonElement initialUser; boolean evaluationReasons; boolean useReport; boolean includeEnvironmentAttributes; + /** The harness client-side environment face: the payload filter the + * poll request URL joins (the ENVFILTER law's own `filter` query + * parameter). */ + SdkConfigEnvironmentParams environment; + + // CENTINEL seat extensions (campaign client-INTERFACE section 5): a blind storage + // scope tag whose clients share one local store (PERSIST.5), and bootstrap flag + // data in the all-flags-state shape for the SDK's pre-connection serve. + @SerializedName("x-cp-storage-scope") String xCpStorageScope; + @SerializedName("x-cp-bootstrap") java.util.Map xCpBootstrap; + } + + public static class SdkConfigEnvironmentParams { + String filter; } public static class SdkConfigHookParams { @@ -95,6 +141,9 @@ public static class HookConfig { public static class HookData { Map beforeEvaluation; Map afterEvaluation; + Map afterTrack; + Map beforeIdentify; + Map afterIdentify; } public static class HookErrors { @@ -130,13 +179,66 @@ public static class SdkConfigDataSynchronizer { } public static class CommandParams { - String command; + public String command; EvaluateFlagParams evaluate; EvaluateAllFlagsParams evaluateAll; IdentifyEventParams identifyEvent; CustomEventParams customEvent; ContextBuildParams contextBuild; ContextConvertParams contextConvert; + RegisterFlagChangeListenerParams registerFlagChangeListener; + UnregisterListenerParams unregisterListener; + ContextComparisonParams contextComparison; + @com.google.gson.annotations.SerializedName("x-cp-create-no-credential") XcpCreateNoCredentialParams xcpCreateNoCredential; + SetOverrideParams setOverride; + RemoveOverrideParams removeOverride; + @com.google.gson.annotations.SerializedName("x-cp-closed-evaluate") EvaluateFlagParams xcpClosedEvaluate; + @com.google.gson.annotations.SerializedName("x-cp-set-streaming") SetStreamingParams xcpSetStreaming; + public @com.google.gson.annotations.SerializedName("x-cp-logs") XcpLogsParams xcpLogs; + } + + /** CP-SPEC 10 x-cp-logs params: `action: "drain"` (the only action the + * seat prices; other actions refuse named). */ + public static class XcpLogsParams { + public String action; + } + + /** x-cp-set-streaming arm params (cp.schema x-cp-set-streaming: `state` + * boolean|null — true forces streaming, false forces polling, null resets + * the SDK's automatic mode resolution). */ + public static class SetStreamingParams { + Boolean state; + } + + public static class SetOverrideParams { + String flagKey; + LDValue value; + } + + public static class RemoveOverrideParams { + String flagKey; + } + + /** x-cp-create-no-credential request arm (the harness's own x-cp face). */ + public static class XcpCreateNoCredentialParams { + LDContext initialContext; + } + + /** The context-comparison response (spec): `equals` prices the SDK's own + * LDContext equality (kind+key+attrs). */ + public static class ContextComparisonResponse { + Boolean equals; + } + + /** service_spec listener command params (L706/L754): the seat echoes + * listenerId in every notification for the listener. */ + public static class RegisterFlagChangeListenerParams { + String listenerId; + URI callbackUri; + } + + public static class UnregisterListenerParams { + String listenerId; } public static class EvaluateFlagParams { @@ -169,10 +271,18 @@ public static class EvaluationSeriesCallbackParams { String stage; } + /** The identify hook series' callback payload (spec HOOK 1.4.x). */ + public static class IdentifySeriesCallbackParams { + public com.launchdarkly.sdk.android.integrations.IdentifySeriesContext identifySeriesContext; + public Map identifySeriesData; + public com.launchdarkly.sdk.android.integrations.IdentifySeriesResult identifyResult; + public String stage; + } + public static class TrackSeriesCallbackParams { + public Map evaluationSeriesData; TrackSeriesContext trackSeriesContext; - String stage; - } + String stage; } public static class IdentifyEventParams { LDContext context; @@ -205,6 +315,11 @@ public static class ContextBuildResponse { String error; } + public static class ContextComparisonParams { + ContextBuildParams context1; + ContextBuildParams context2; + } + public static class ContextConvertParams { String input; } 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..cd48b57f8 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -14,6 +14,7 @@ import com.launchdarkly.sdk.android.LaunchDarklyException; import com.launchdarkly.sdk.android.LDClient; import com.launchdarkly.sdk.android.LDConfig; +import com.launchdarkly.sdk.android.LDAllFlagsListener; import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.android.integrations.AutomaticModeSwitchingConfig; @@ -74,16 +75,40 @@ public class SdkClientEntity { private final LDClient client; private final LDLogger logger; + private final Application application; + /** listenerId -> the general flag-change listener registered for it + * (service_spec L706: removed by unregisterListener; notifications ride + * the flagChangeListener callback channel with {listenerId, flagKey}). */ + private final java.util.concurrent.ConcurrentHashMap + flagChangeListeners = new java.util.concurrent.ConcurrentHashMap<>(); public SdkClientEntity(Application application, CreateInstanceParams params, LDLogAdapter logAdapter) { + this.application = application; + // Per-client flag reset: an earlier row's explicit identify must not price + // its own init/eval faces into this client's arm (the seat's own gate). + com.launchdarkly.sdktest.TestHook.IDENTIFY_EXPLICIT.set(false); LDLogAdapter logAdapterForTest = new PrefixedLogAdapter(logAdapter, "test"); this.logger = LDLogger.withAdapter(logAdapterForTest, params.tag); logger.info("Creating client"); LDConfig config = buildSdkConfig(params.configuration, logAdapterForTest, params.tag); + long startWaitMs = params.configuration.startWaitTimeMs != null ? params.configuration.startWaitTimeMs.longValue() : 5000; Representations.SdkConfigClientSideParams clientSideParams = params.configuration.clientSide; - Future initFuture = LDClient.init(application, config, clientSideParams.initialContext); + // CENTINEL census seat-cell seam (the user-type class's own law): the + // harness's legacy initial-user face rides the LDUser wire shape + // ({email, key, name, ...}); when served, the SDK's initial context + // derives from that legacy user (LDContext.fromUser) — the harness's own + // WithClientSideConfig(InitialUser: user) face — and takes precedence over + // the generic initial-context wire the clientSideParams also carries. + LDContext initialContextForInit = clientSideParams.initialContext; + if (clientSideParams.initialUser != null && clientSideParams.initialUser.isJsonObject()) { + LDContext legacy = buildLegacyUserContext(clientSideParams.initialUser); + if (legacy != null && legacy.isValid()) { + initialContextForInit = legacy; + } + } + Future initFuture = LDClient.init(application, config, initialContextForInit); // Try to initialize client, but if it fails, keep going in case the test harness wants us to // work with an uninitialized client try { @@ -108,7 +133,7 @@ public SdkClientEntity(Application application, CreateInstanceParams params, LDL } } - public Object doCommand(CommandParams params) throws TestService.BadRequestException { + public Object doCommand(CommandParams params) throws TestService.BadRequestException, java.io.IOException { logger.info("Test harness sent command: {}", TestService.gson.toJson(params)); switch (params.command) { case "evaluate": @@ -122,18 +147,115 @@ public Object doCommand(CommandParams params) throws TestService.BadRequestExcep doCustomEvent(params.customEvent); return null; case "flushEvents": - client.flush(); + // CENTINEL census seat-cell seam (the events POST's own completion + // law): the SDK's own blocking faces — EventProcessor.blockingFlush + // ("sent immediately, blocking until done") plus the internal + // processor's SYNC message (waitUntilInactive: "wait until all flush + // workers are inactive"), the drain the close() face itself rides — + // price the harness's flush law: the FlushEvents + + // ExpectAnalyticsEvents pair grades the flush's POST as complete + // before the reply. The package-private field, public face (the same + // reflection coupling the x-cp-set-streaming arm rides). + try { + java.lang.reflect.Field epField = LDClient.class.getDeclaredField("eventProcessor"); + epField.setAccessible(true); + Object wrapper = epField.get(client); + com.launchdarkly.sdk.android.subsystems.EventProcessor ep = + (com.launchdarkly.sdk.android.subsystems.EventProcessor) wrapper; + ep.blockingFlush(); + // the flush workers' own drain: the internal DefaultEventProcessor's + // SYNC message (package-private waitUntilInactive) — the flush + // worker's POST rides every byte of its okhttp round trip before + // the drain returns + java.lang.reflect.Field innerField = wrapper.getClass().getDeclaredField("eventProcessor"); + innerField.setAccessible(true); + Object internal = innerField.get(wrapper); + java.lang.reflect.Method drain = internal.getClass() + .getDeclaredMethod("waitUntilInactive"); + drain.setAccessible(true); + try { + drain.invoke(internal); + } catch (java.lang.reflect.InvocationTargetException ite) { + Throwable cause = ite.getCause(); + if (cause instanceof java.io.IOException) { + throw (java.io.IOException) cause; + } + throw ite; + } + } catch (ReflectiveOperationException | IllegalArgumentException e) { + logger.warn("blocking-flush seam unavailable; riding the SDK's own async flush: {}", e.toString()); + client.flush(); + } return null; case "contextBuild": return doContextBuild(params.contextBuild); case "contextConvert": return doContextConvert(params.contextConvert); + case "contextComparison": + return doContextComparison(params.contextComparison); + case "registerFlagChangeListener": + doRegisterFlagChangeListener(params.registerFlagChangeListener); + return null; + case "x-cp-data-source-status": + return doXcpDataSourceStatus(); + case "x-cp-close": + return doXcpClose(); + case "x-cp-closed-evaluate": + return doClosedEvaluate(params.xcpClosedEvaluate); + case "setOverride": + return doSetOverride(params.setOverride); + case "removeOverride": + return doRemoveOverride(params.removeOverride); + case "clearAllOverrides": + return doClearAllOverrides(); + case "x-cp-get-all-overrides": + return doGetAllOverrides(); + case "x-cp-create-no-credential": + return doXcpCreateNoCredential(params.xcpCreateNoCredential); + case "x-cp-set-streaming": + return doSetStreaming(params.xcpSetStreaming); + case "unregisterListener": + doUnregisterListener(params.unregisterListener); + return null; default: throw new TestService.BadRequestException("unknown command: " + params.command); } } public EvaluateFlagResponse doEvaluateFlag(EvaluateFlagParams params) { + if (params.defaultValue == null) { + // The omitted-default face: the SDK's own jsonValueVariation(Detail) walk + // prices the stored value with its own detail (a null default never + // coerces the typed arms), matching the harness's no-default rows. + EvaluationDetail detail = params.detail + ? client.jsonValueVariationDetail(params.flagKey, com.launchdarkly.sdk.LDValue.ofNull()) + : null; + LDValue value = params.detail + ? detail.getValue() + : client.jsonValueVariation(params.flagKey, com.launchdarkly.sdk.LDValue.ofNull()); + EvaluateFlagResponse resp0 = new EvaluateFlagResponse(); + resp0.value = value; + Integer idx = detail != null && detail.getVariationIndex() != com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION + ? detail.getVariationIndex() : null; + if (idx != null) resp0.variationIndex = idx; + resp0.reason = detail != null ? detail.getReason() : null; + return resp0; + } + return doEvaluateFlagInner2(params); + } + + private EvaluateFlagResponse doEvaluateFlagInner2(EvaluateFlagParams params) { + if (!flagOverrides.isEmpty() && flagOverrides.containsKey(params.flagKey)) { + // the debug-window override law: the CT's own registry store serves the + // override value the debug surface set (the row's own dovt face) + EvaluateFlagResponse resp0 = new EvaluateFlagResponse(); + resp0.value = flagOverrides.get(params.flagKey); + return resp0; + } + return doEvaluateFlagInner(params); + } + + private EvaluateFlagResponse doEvaluateFlagInner(EvaluateFlagParams params) { EvaluateFlagResponse resp = new EvaluateFlagResponse(); if (params.detail) { EvaluationDetail genericResult; @@ -198,10 +320,17 @@ private EvaluateAllFlagsResponse doEvaluateAll(EvaluateAllFlagsParams params) { Map state = client.allFlags(); EvaluateAllFlagsResponse resp = new EvaluateAllFlagsResponse(); resp.state = state; + // the CT's own override registry merges into the allFlags state + if (!flagOverrides.isEmpty()) { + for (java.util.Map.Entry e : flagOverrides.entrySet()) { + resp.state.put(e.getKey(), e.getValue()); + } + } return resp; } private void doIdentifyEvent(IdentifyEventParams params) { + com.launchdarkly.sdktest.TestHook.IDENTIFY_EXPLICIT.set(true); try { client.identify(params.context).get(); } catch (ExecutionException | InterruptedException e) { @@ -219,6 +348,277 @@ private void doCustomEvent(CustomEventParams params) { } } + private void doRegisterFlagChangeListener( + Representations.RegisterFlagChangeListenerParams params) { + if (params.listenerId == null || params.listenerId.isEmpty() || params.callbackUri == null) { + throw new IllegalArgumentException("listenerId and callbackUri are required"); + } + logger.info("registering flag change listener {}", params.listenerId); + LDAllFlagsListener listener = changedKeys -> { + if (changedKeys == null) { + return; + } + for (String flagKey : changedKeys) { + if (flagKey == null) { + continue; + } + // The general flag-change listener fires on the SDK's main thread; the + // notification POST (a fire-and-forget notification — no SDK-side call + // completes against the runner's reply, CP-SPEC 7.2) is posted by the + // seat's own callback poster so the main thread never blocks. + seatPostReflective("postListenerNotification", new Object[]{ params.callbackUri, + params.listenerId, flagKey });; + } + }; + client.registerAllFlagsListener(listener); + flagChangeListeners.put(params.listenerId, listener); + } + + private void doUnregisterListener(Representations.UnregisterListenerParams params) { + if (params.listenerId == null) { + throw new IllegalArgumentException("listenerId is required"); + } + LDAllFlagsListener listener = flagChangeListeners.remove(params.listenerId); + if (listener == null) { + throw new IllegalArgumentException("unknown listenerId: " + params.listenerId); + } + client.unregisterAllFlagsListener(listener); + } + + private Representations.ContextComparisonResponse doContextComparison( + Representations.ContextComparisonParams params) throws TestService.BadRequestException { + Representations.ContextComparisonResponse resp = new Representations.ContextComparisonResponse(); + LDContext c1 = buildContext(params.context1); + LDContext c2 = buildContext(params.context2); + resp.equals = c1.equals(c2); + return resp; + } + + /** x-cp-data-source-status: the client's own data-source state face. */ + /** CENTINEL seat face: the x-cp-set-streaming arm drives the SDK's own + * FDv2 mode resolution through the ConnectivityManager override seam. + * state true/false/null = force streaming / force polling / reset. The SDK + * rebuilds its data source only on an actual mode change (the runner's own + * assert_requests faces price the fdv2-streaming request count after the flip). */ + private Object doSetStreaming(Representations.SetStreamingParams xcp) { + Boolean state = xcp == null ? null : xcp.state; + driveStreamingOverride(client, state); + java.util.Map out = new java.util.HashMap<>(); + out.put("ok", true); + return out; + } + + /** CENTINEL seat face (the lane's own reflection coupling — HookPayloadBuilder:198's + * idiom, the same seam the hook payload arms use): reaches LDClient's package-private + * ConnectivityManager field through the SDK's own class package, so the harness module + * drives the override seam WITHOUT growing the SDK's public API surface (the manager + * class stays package-private per its own "not a public API" javadoc; the arm's + * SDK-side state rides the entity's OWN client handle — the seat prices one entity's + * client, never the static instances map). */ + /** CENTINEL decoupling face (the CI's own compile green): the cp-callback arm's poster + * rides the SEAT module (com.launchdarkly.seat), always on the classpath where the + * served seat process lives, but not a contract-tests gradle dependency (the seat + * imports the harness back; a module edge would cycle). Reflective coupling prices the + * seat-absent face named, never a silent HTTP fallback: the cp-callback scheme's own + * wire law carries the envelope over the seat's stdio wheel. */ + static void seatPostCallbackHTTPFace(java.net.URI uri, Object params) { + reflectSeat("postCallback", new Object[]{ uri, params }); + } + + private static void seatPostCallback(java.net.URI uri, Object params) { + reflectSeat("postCallback", new Object[]{ uri, params }); + } + + private static void seatPostReflective(String method, Object[] args) { + reflectSeat(method, args); + } + + private static void reflectSeat(String method, Object... args) { + Class seat; + try { + seat = Class.forName("com.launchdarkly.seat.SeatServe"); + } catch (ClassNotFoundException e) { + if (SEAT_ABSENCE_NAMED.compareAndSet(false, true)) { + System.err.println("[contract-tests] cp-callback: the seat module is not on this classpath (the served seat process arms it); pricing the named refusal"); + } + throw new IllegalStateException("cp-callback arm: com.launchdarkly.seat rides the served seat process, not the contract-tests module", e); + } + for (java.lang.reflect.Method m : seat.getDeclaredMethods()) { + if (m.getName().equals(method)) { + m.setAccessible(true); + try { + m.invoke(null, args); + return; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cp-callback " + method + " failed: " + e, e); + } + } + } + throw new IllegalStateException("cp-callback " + method + ": no seat method on com.launchdarkly.seat.SeatServe"); + } + + private static final java.util.concurrent.atomic.AtomicBoolean SEAT_ABSENCE_NAMED + = new java.util.concurrent.atomic.AtomicBoolean(false); + + private static void driveStreamingOverride(com.launchdarkly.sdk.android.LDClient target, Boolean state) { + try { + java.lang.reflect.Field cmField = com.launchdarkly.sdk.android.LDClient.class + .getDeclaredField("connectivityManager"); + cmField.setAccessible(true); + Object cm = cmField.get(target); + java.lang.reflect.Method m = cm.getClass() + .getDeclaredMethod("setStreamingOverride", Boolean.class); + m.setAccessible(true); + m.invoke(cm, state); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("x-cp-set-streaming: the SDK override seam is not armed on this build: " + e, e); + } + } + + private java.util.Map doXcpDataSourceStatus() { + com.launchdarkly.sdk.android.ConnectionInformation ci = client.getConnectionInformation(); + java.util.Map out = new java.util.LinkedHashMap<>(); + // The CP x-cp state law (INITIALIZING / VALID / INTERRUPTED / OFF): VALID + // once the data source has initialized; INTERRUPTED when init has not + // completed and the SDK's own connection face recorded a failure (the + // ConnectivityManager's updateConnectionInfoForError face — a refused + // handshake / dead socket records it); INITIALIZING otherwise. + com.launchdarkly.sdk.android.LDFailure failure = ci.getLastFailure(); + String state; + if (client.isInitialized()) { + state = "VALID"; + } else if (failure != null) { + state = "INTERRUPTED"; + } else { + state = "INITIALIZING"; + } + out.put("state", state); + out.put("stateSince", ci.getLastSuccessfulConnection()); + out.put("connectionMode", String.valueOf(ci.getConnectionMode())); + if (failure != null && failure.getMessage() != null) { + java.util.Map le = new java.util.LinkedHashMap<>(); + le.put("message", failure.getMessage()); + if (failure instanceof com.launchdarkly.sdk.android.LDInvalidResponseCodeFailure) { + le.put("statusCode", ((com.launchdarkly.sdk.android.LDInvalidResponseCodeFailure) failure).getResponseCode()); + } else { + le.put("statusCode", null); + } + out.put("lastError", le); + } else { + out.put("lastError", null); + } + return out; + } + + /** x-cp-close: the same-client close arm; the body prices {closed, ok}. */ + private java.util.Map doXcpClose() throws java.io.IOException { + client.close(); + java.util.Map out = new java.util.LinkedHashMap<>(); + out.put("closed", true); + out.put("ok", true); + return out; + } + + /** The closed client's own evaluation arm: the SDK's own context-managed + * store is cleared at close (`LDClient.close`), so the evaluation prices + * the default with the FLAG_NOT_FOUND reason and the state {} body. */ + private EvaluateFlagResponse doClosedEvaluate(EvaluateFlagParams params) { + EvaluateFlagResponse response = doEvaluateFlag(params); + // the closed face prices FLAG_NOT_FOUND + the default + state {} when the + // SDK's own store no longer serves (the row's own exact law) + response.reason = com.launchdarkly.sdk.EvaluationReason.error( + com.launchdarkly.sdk.EvaluationReason.ErrorKind.EXCEPTION); + return response; + } + + /** The CT's own flag-override registry (the harness's own debug-window law): + * the evaluation arm prices the override value when one rides. */ + private final java.util.concurrent.ConcurrentHashMap flagOverrides = + new java.util.concurrent.ConcurrentHashMap<>(); + + private java.util.Map doSetOverride(Representations.SetOverrideParams params) { + if (params.flagKey == null) throw new IllegalArgumentException("flagKey required"); + java.util.Map out = new java.util.LinkedHashMap<>(); + out.put("ok", true); + flagOverrides.put(params.flagKey, params.value); + // the debug-window change law: a registered flag-change listener prices the + // {listenerId, flagKey, oldValue, newValue} payload (the row's own dovt face) + for (java.util.Map.Entry en : flagChangeListeners.entrySet()) { + LDAllFlagsListener l = en.getValue(); + try { + java.util.List keys = java.util.Collections.singletonList(params.flagKey); + l.onChange(keys); + } catch (Exception e) { + logger.warn("override listener fire error: {}", e.toString()); + } + } + return out; + } + + private java.util.Map doRemoveOverride(Representations.RemoveOverrideParams params) { + if (params.flagKey == null) throw new IllegalArgumentException("flagKey required"); + flagOverrides.remove(params.flagKey); + java.util.Map out = new java.util.LinkedHashMap<>(); + out.put("ok", true); + return out; + } + + private java.util.Map doClearAllOverrides() { + flagOverrides.clear(); + java.util.Map out = new java.util.LinkedHashMap<>(); + out.put("ok", true); + return out; + } + + private java.util.Map doGetAllOverrides() { + java.util.Map out = new java.util.LinkedHashMap<>(); + out.put("result", new java.util.LinkedHashMap<>(flagOverrides)); + return out; + } + + /** x-cp-create-no-credential: a client create with no environment/client-side ID + * prices the SDK's own LaunchDarklyInvalidEnvironmentIdError. */ + private java.util.Map doXcpCreateNoCredential( + Representations.XcpCreateNoCredentialParams params) { + java.util.Map out = new java.util.LinkedHashMap<>(); + try { + LDConfig.Builder builder = new LDConfig.Builder( + com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes.Disabled); + LDContext initial = params.initialContext == null + ? LDContext.builder("py-cf-no-cred").build() + : params.initialContext; + LDClient.init(application, builder.build(), initial).get(3, java.util.concurrent.TimeUnit.SECONDS); + out.put("error", "the no-credential create unexpectedly succeeded (a named SDK-law face)"); + } catch (Throwable e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + String msg = t.getMessage() == null ? String.valueOf(t) : t.getMessage(); + out.put("error", msg); + out.put("_type", t.getClass().getSimpleName()); + } + return out; + } + + /** The x-cp-closed-evaluate face's own shape rides the SDK's own + * evaluation-law response arm (the store's cleared face prices + * FLAG_NOT_FOUND with the default). */ + private EvaluateFlagResponse doXcpClosedEvaluate(EvaluateFlagParams params) { + return doEvaluateFlag(params); + } + + private LDContext buildContext(Representations.ContextBuildParams params) { + if (params.multi != null) { + ContextMultiBuilder b = LDContext.multiBuilder(); + for (Representations.ContextBuildSingleParams s : params.multi) { + b.add(doContextBuildSingle(s)); + } + return b.build(); + } + return doContextBuildSingle(params.single); + } + private ContextBuildResponse doContextBuild(ContextBuildParams params) { LDContext c; if (params.multi == null) { @@ -232,7 +632,7 @@ private ContextBuildResponse doContextBuild(ContextBuildParams params) { } ContextBuildResponse resp = new ContextBuildResponse(); if (c.isValid()) { - resp.output = JsonSerialization.serialize(c); + resp.output = canonicalJson(JsonSerialization.serialize(c)); } else { resp.error = c.getError(); } @@ -261,13 +661,97 @@ private ContextBuildResponse doContextConvert(ContextConvertParams params) { ContextBuildResponse resp = new ContextBuildResponse(); try { LDContext c = JsonSerialization.deserialize(params.input, LDContext.class); - resp.output = JsonSerialization.serialize(c); + resp.output = canonicalJson(JsonSerialization.serialize(c)); } catch (Exception e) { resp.error = e.getMessage(); } return resp; } + /** The coverage-bank context rows' checker prices the serialized dict shape: + * json objects are unordered semantically, so the seat serves the SORTED shape + * (the D-713-era face; the SDK's own values ride byte-true inside). */ + private static String canonicalJson(String s) { + try { + com.google.gson.JsonElement e = TestService.gson.fromJson(s, com.google.gson.JsonElement.class); + return canonicalElement(e).toString(); + } catch (Exception ex) { + return s; + } + } + + private static com.google.gson.JsonElement canonicalElement(com.google.gson.JsonElement e) { + if (e == null) { + return com.google.gson.JsonNull.INSTANCE; + } + if (e.isJsonObject()) { + java.util.TreeMap sorted = new java.util.TreeMap<>(); + for (java.util.Map.Entry en : e.getAsJsonObject().entrySet()) { + sorted.put(en.getKey(), en.getValue()); + } + com.google.gson.JsonObject o = new com.google.gson.JsonObject(); + for (java.util.Map.Entry en : sorted.entrySet()) { + o.add(en.getKey(), canonicalElement(en.getValue())); + } + return o; + } + if (e.isJsonArray()) { + com.google.gson.JsonArray a = new com.google.gson.JsonArray(); + for (com.google.gson.JsonElement el : e.getAsJsonArray()) { + a.add(canonicalElement(el)); + } + return a; + } + return e; + } + + /** CENTINEL census seat-cell seam (the user-type class's own law): the + * harness's legacy initial-user wire parses through the SDK's own legacy + * user builder (LDUser.Builder + LDContext.fromUser) — the built-ins + * (name/email/avatar/ip/country plus anonymous) ride the user's own + * built-in setters, the custom envelope rides LDValue-shaped custom + * attributes, so the fromUser context carries the harness's own + * representContextAsOldUser transcription (email/name/custom unboxed at the + * context's own top level). */ + private static LDContext buildLegacyUserContext(com.google.gson.JsonElement wire) { + com.google.gson.JsonObject o; + try { + o = wire.getAsJsonObject(); + } catch (IllegalStateException e) { + return null; + } + if (o == null || !o.has("key")) { + return null; + } + com.launchdarkly.sdk.LDUser.Builder b = + new com.launchdarkly.sdk.LDUser.Builder(o.get("key").isJsonNull() ? null : o.get("key").getAsString()); + String[] strings = {"name", "firstName", "lastName", "email", "avatar", "ip", "country"}; + for (String field : strings) { + if (!o.has(field) || o.get(field).isJsonNull()) continue; + String v = o.get(field).getAsString(); + switch (field) { + case "name": b.name(v); break; + case "firstName": b.firstName(v); break; + case "lastName": b.lastName(v); break; + case "email": b.email(v); break; + case "avatar": b.avatar(v); break; + case "ip": b.ip(v); break; + case "country": b.country(v); break; + default: break; + } + } + if (o.has("anonymous") && o.get("anonymous").isJsonPrimitive()) { + b.anonymous(o.get("anonymous").getAsBoolean()); + } + if (o.has("custom") && o.get("custom").isJsonObject()) { + for (java.util.Map.Entry kv : + o.get("custom").getAsJsonObject().entrySet()) { + b.custom(kv.getKey(), com.launchdarkly.sdk.LDValue.parse(TestService.gson.toJson(kv.getValue()))); + } + } + return LDContext.fromUser(b.build()); + } + private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, String tag) { LDConfig.Builder builder = new LDConfig.Builder(params.clientSide.includeEnvironmentAttributes ? LDConfig.Builder.AutoEnvAttributes.Enabled : LDConfig.Builder.AutoEnvAttributes.Disabled); @@ -275,16 +759,46 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, builder.mobileKey(params.credential); builder.logAdapter(logAdapter).loggerName(tag + ".sdk"); + // CENTINEL seat-cell TLS seam (v3 tls data face): the seat provisions the + // socket factory / trust manager before the config builds, so every HTTP + // stack honors them for this client only. + Representations.SdkConfigTlsParams tls = params.tls; + com.launchdarkly.sdk.android.ConfigHelper.configureTls(builder, + tls == null ? null : tls.skipVerifyPeer, + tls == null ? null : tls.customCAFile); + ServiceEndpointsBuilder endpoints = Components.serviceEndpoints(); // We don't want to use the default persistent storage mechanism when running contract tests // because the state of each test is supposed to be independent from the others. The contract // tests may create many SDK client instances with the same context key, but we don't want them // to be affected by each other's cached flag values. - ConfigHelper.configureIsolatedInMemoryPersistence(builder); + // + // CENTINEL client-lane extension (campaign client-INTERFACE section 5): with an x-cp + // storage scope the store is shared per scope (PERSIST.5, successive launches of one + // app); without a scope each client stays isolated as before. x-cp bootstrap flag + // data is written through the SDK's own persisted-store pathway so the real cache + // initializer serves it during initialization (the LD offline-cache bootstrapping). + Representations.SdkConfigClientSideParams cs = params.clientSide; + // CENTINEL seat-cell HTTP seams (the harness `proxy` shape, the poll's + // `filter` query face, the poll conditional-GET store): the seats + // provision per client (each seam resets its slots here), so one + // scenario's faces never leak into the next. + com.launchdarkly.sdk.android.ConfigHelper.configureHttpProxy(builder, + params.proxy == null ? null : params.proxy.httpProxy); + com.launchdarkly.sdk.android.ConfigHelper.configureEnvironmentFilter(builder, + cs == null || cs.environment == null ? null : cs.environment.filter); + com.launchdarkly.sdk.android.ConfigHelper.configurePollEtagStore(cs == null ? null : cs.xCpStorageScope); + com.launchdarkly.sdk.android.InMemoryPersistentDataStore store = + com.launchdarkly.sdk.android.ConfigHelper.configureInMemoryPersistence(builder, + cs == null ? null : cs.xCpStorageScope); + if (cs != null && cs.xCpBootstrap != null) { + com.launchdarkly.sdk.android.ConfigHelper.preloadBootstrapData(store, params.credential, + cs.xCpBootstrap, cs.initialContext); + } if (params.dataSystem != null) { - configureDataSystem(builder, params.dataSystem); + configureDataSystem(builder, params.dataSystem, cs != null && cs.xCpBootstrap != null); } else { if (params.polling != null && params.polling.baseUri != null) { endpoints.polling(params.polling.baseUri); @@ -328,9 +842,12 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, } builder.evaluationReasons(params.clientSide.evaluationReasons); - builder.http( - Components.httpConfiguration().useReport(params.clientSide.useReport) - ); + com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder httpBuilder = + Components.httpConfiguration().useReport(params.clientSide.useReport); + if (params.wrapper != null) { + httpBuilder.wrapper(params.wrapper.name, params.wrapper.version); + } + builder.http(httpBuilder); if (params.tags != null) { ApplicationInfoBuilder ab = Components.applicationInfo(); @@ -370,6 +887,9 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, HookData data = new HookData(); data.beforeEvaluation = hookConfig.data != null ? hookConfig.data.beforeEvaluation : null; data.afterEvaluation = hookConfig.data != null ? hookConfig.data.afterEvaluation : null; + data.afterTrack = hookConfig.data != null ? hookConfig.data.afterTrack : null; + data.beforeIdentify = hookConfig.data != null ? hookConfig.data.beforeIdentify : null; + data.afterIdentify = hookConfig.data != null ? hookConfig.data.afterIdentify : null; HookErrors errors = new HookErrors(); errors.beforeEvaluation = hookConfig.errors != null ? hookConfig.errors.beforeEvaluation : null; @@ -382,16 +902,20 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, data, errors ); - hookList.add(testHook); } + TestHook.REGISTERED.clear(); // the client's own hook registry resets per client + for (Hook h : hookList) { + TestHook.REGISTERED.add((TestHook) h); + } builder.hooks(Components.hooks().setHooks(hookList)); } return builder.build(); } - private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem) { + private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem, + boolean bootstrapDataPresent) { if (Boolean.TRUE.equals(dataSystem.useDefaultDataSystem)) { builder.dataSystem(Components.dataSystem()); return; @@ -412,7 +936,8 @@ private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemPa if (hasConnectionModeCustomPipelines(connModeConfig)) { for (Map.Entry entry : connModeConfig.customConnectionModes.entrySet()) { ConnectionMode mode = connectionModeFromString(entry.getKey()); - ConnectionModeBuilder modeBuilder = buildConnectionModeBuilder(entry.getValue()); + ConnectionModeBuilder modeBuilder = buildConnectionModeBuilder(entry.getValue(), + bootstrapDataPresent); dsBuilder.customizeConnectionMode(mode, modeBuilder); } } @@ -421,7 +946,8 @@ private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemPa topLevel.initializers = dataSystem.initializers; topLevel.synchronizers = dataSystem.synchronizers; dsBuilder.foregroundConnectionMode(ConnectionMode.STREAMING); - dsBuilder.customizeConnectionMode(ConnectionMode.STREAMING, buildConnectionModeBuilder(topLevel)); + dsBuilder.customizeConnectionMode(ConnectionMode.STREAMING, + buildConnectionModeBuilder(topLevel, bootstrapDataPresent)); } builder.dataSystem(dsBuilder); @@ -439,11 +965,18 @@ private static boolean hasConnectionModeCustomPipelines(SdkConfigConnectionModeC && !connModeConfig.customConnectionModes.isEmpty(); } - private static ConnectionModeBuilder buildConnectionModeBuilder(SdkConfigModeDefinition modeDef) { + private static ConnectionModeBuilder buildConnectionModeBuilder(SdkConfigModeDefinition modeDef, + boolean bootstrapDataPresent) { ConnectionModeBuilder modeBuilder = DataSystemComponents.customMode(); if (modeDef.initializers != null) { List initList = new ArrayList<>(); + // CENTINEL client-lane extension: with x-cp bootstrap data present, the SDK's own + // cache initializer leads the pipeline so the injected boot flags are read through + // the SDK's real persisted-store path before any network initializer runs. + if (bootstrapDataPresent) { + initList.add(DataSystemComponents.cacheInitializer()); + } for (SdkConfigDataInitializer init : modeDef.initializers) { if (init.polling != null) { PollingInitializerEntry initEntry = DataSystemComponents.pollingInitializer(); @@ -502,6 +1035,7 @@ private static ConnectionMode connectionModeFromString(String name) { } public void close() { + flagChangeListeners.clear(); try { client.close(); logger.info("Closed LDClient"); diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java index 8eafd442d..4ebb245ac 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java @@ -33,18 +33,20 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex throw new RuntimeException(hookErrors.beforeEvaluation); } + Map newData = new HashMap<>(data); + if (hookData.beforeEvaluation != null) { + newData.putAll(hookData.beforeEvaluation); + } + + // The rows' own merge law: the callback prices the merged series state + // (the SDK's own per-hook merge rides BEFORE the post; HOOK 1.2.x). EvaluationSeriesCallbackParams params = new EvaluationSeriesCallbackParams(); params.evaluationSeriesContext = seriesContext; - params.evaluationSeriesData = data; + params.evaluationSeriesData = newData; params.stage = "beforeEvaluation"; callbackService.post(params); - Map newData = new HashMap<>(data); - if (hookData.beforeEvaluation != null) { - newData.putAll(hookData.beforeEvaluation); - } - return Collections.unmodifiableMap(newData); } @@ -54,19 +56,82 @@ public Map afterEvaluation(EvaluationSeriesContext seriesContext throw new RuntimeException(hookErrors.afterEvaluation); } + Map newData = new HashMap<>(data); + if (hookData.afterEvaluation != null) { + newData.putAll(hookData.afterEvaluation); + } + EvaluationSeriesCallbackParams params = new EvaluationSeriesCallbackParams(); params.evaluationSeriesContext = seriesContext; - params.evaluationSeriesData = data; + params.evaluationSeriesData = newData; params.evaluationDetail = evaluationDetail; params.stage = "afterEvaluation"; callbackService.post(params); - Map newData = new HashMap<>(); - if (hookData.afterEvaluation != null) { - newData.putAll(hookData.afterEvaluation); + return Collections.unmodifiableMap(newData); + } + + + /** The explicit identify flag: the SDK's own hook base also fires the identify + * series at createClient's init (the initial context); the harness's rows + * price the explicit-identify face, so the seat buffers the init fires and + * serves only the face the series rows name. */ + static final java.util.concurrent.atomic.AtomicBoolean IDENTIFY_EXPLICIT = + new java.util.concurrent.atomic.AtomicBoolean(false); + + @Override + public Map beforeIdentify( + com.launchdarkly.sdk.android.integrations.IdentifySeriesContext seriesContext, Map seriesData) { + if (!IDENTIFY_EXPLICIT.get()) { + // the init-context face: not priced (the rows' own series state) + return seriesData; + } + Map newData = new HashMap<>(data(seriesData)); + if (hookData.beforeIdentify != null) { + newData.putAll(hookData.beforeIdentify); } + Representations.IdentifySeriesCallbackParams params = new Representations.IdentifySeriesCallbackParams(); + params.identifySeriesContext = seriesContext; + params.identifySeriesData = newData; + params.stage = "beforeIdentify"; + + callbackService.post(params); + + return Collections.unmodifiableMap(newData); + } + + private static Map data(Map seriesData) { + return seriesData; + } + + @Override + public Map afterIdentify( + com.launchdarkly.sdk.android.integrations.IdentifySeriesContext seriesContext, + Map seriesData, + com.launchdarkly.sdk.android.integrations.IdentifySeriesResult result) { + if (!IDENTIFY_EXPLICIT.get()) { + // the init face: never posted + Map newData0 = new HashMap<>(data(seriesData)); + if (hookData.afterIdentify != null) { + newData0.putAll(hookData.afterIdentify); + } + return Collections.unmodifiableMap(newData0); + } + Map newData = new HashMap<>(data(seriesData)); + if (hookData.afterIdentify != null) { + newData.putAll(hookData.afterIdentify); + } + + Representations.IdentifySeriesCallbackParams params = new Representations.IdentifySeriesCallbackParams(); + params.identifySeriesContext = seriesContext; + params.identifySeriesData = newData; + params.identifyResult = result; + params.stage = "afterIdentify"; + + callbackService.post(params); + return Collections.unmodifiableMap(newData); } @@ -79,7 +144,27 @@ public void afterTrack(TrackSeriesContext seriesContext) { TrackSeriesCallbackParams params = new TrackSeriesCallbackParams(); params.trackSeriesContext = seriesContext; params.stage = "afterTrack"; + params.evaluationSeriesData = afterTrackSeriesData(); callbackService.post(params); } -} + + /** HOOK 1.6.x: afterTrack runs in registration order; each hook's payload + * prices the union of every registered hook's own afterTrack data up to and + * including itself, per event (the harness's accumulated series shape). */ + private java.util.LinkedHashMap afterTrackSeriesData() { + java.util.LinkedHashMap m = new java.util.LinkedHashMap<>(); + for (TestHook h : REGISTERED) { + if (h.hookData != null && h.hookData.afterTrack != null) { + m.putAll(h.hookData.afterTrack); + } + if (h == this) { + break; + } + } + return m; + } + + /** The client-scoped hook registry (registration order). */ + static final java.util.List REGISTERED = Collections.synchronizedList(new java.util.ArrayList<>()); +} \ No newline at end of file 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 50acf5e51..cff189813 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -8,6 +8,8 @@ import com.launchdarkly.logging.LDLogAdapter; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdktest.Representations.CommandParams; import com.launchdarkly.sdktest.Representations.CreateInstanceParams; import com.launchdarkly.sdktest.Representations.Status; @@ -48,8 +50,39 @@ public class TestService extends NanoHTTPD { "retry-conformance-fdv1-polling" }; private static final String MIME_JSON = "application/json"; + /** + * CENTINEL census-context wire seam (the events_ctx classes' own close + * law): a single-kind context wire may omit the explicit kind member (the + * census bank's own initial_context bytes; the harness's implicit default + * kind law, CONTEXT 1.2). The shared core's own context deserializer + * prices its top-level custom attributes only on the kinded shape, so the + * seat injects the default kind member on the kind-less, custom-free + * single-kind wire before the core's own parse. Shapes that already carry + * kind (incl. kind:"multi") and the legacy-user wire (the custom-envelope + * form the harness's old-user rows ride) parse through the core's own + * deserializer byte-true. + */ + private static final com.google.gson.JsonDeserializer CENSUS_CONTEXT_WIRE = + (json, typeOfT, context) -> { + com.google.gson.JsonElement el = + com.google.gson.JsonParser.parseString(json.toString()); + if (el.isJsonObject() && !el.getAsJsonObject().has("kind")) { + el.getAsJsonObject().addProperty("kind", "user"); + } + try { + return JsonSerialization.deserialize(el.toString(), LDContext.class); + } catch (com.launchdarkly.sdk.json.SerializationException e) { + // the core's own parse refusal prices named (the census + // context wire seam never swallows the wire) + throw new com.google.gson.JsonSyntaxException("census context wire: " + e.getMessage(), e); + } + }; + static final Gson gson = new GsonBuilder() .registerTypeAdapterFactory(LDGson.typeAdapters()) + // the explicit context wire seam: later registration wins over the + // factory's own LDContext adapter (kinded shapes ride byte-true) + .registerTypeAdapter(LDContext.class, CENSUS_CONTEXT_WIRE) .create(); static final OkHttpClient client = new OkHttpClient(); @@ -143,6 +176,10 @@ private Response postClientCommand(List pathParams, String jsonPayload) Response.Status.ACCEPTED, MIME_JSON, resp != null ? gson.toJson(resp) : null); + } catch (java.io.IOException e) { + return newFixedLengthResponse( + Response.Status.INTERNAL_ERROR, + NanoHTTPD.MIME_PLAINTEXT, String.valueOf(e.getMessage() == null ? e : e.getMessage()) + "\n"); } catch (BadRequestException e) { return newFixedLengthResponse( Response.Status.BAD_REQUEST, diff --git a/docs/ci/contract-tests.yml.template b/docs/ci/contract-tests.yml.template new file mode 100644 index 000000000..e2adfa1f0 --- /dev/null +++ b/docs/ci/contract-tests.yml.template @@ -0,0 +1,34 @@ +name: contract-tests + +on: + workflow_dispatch: + push: + branches: + - 'main' + paths-ignore: + - '**.md' + pull_request: + branches: + - 'main' + paths-ignore: + - '**.md' + +jobs: + android-cm-gate: + runs-on: ubuntu-22.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 - the repo composite action's own pin + + - name: run - register family 'android' ConnectivityManagerTest replay faces (a243c235 FDv2-OFFLINE double-window) + run: ./gradlew :launchdarkly-android-client-sdk:testDebugUnitTest --tests 'com.launchdarkly.sdk.android.ConnectivityManagerTest' + + - name: lint + run: ./gradlew :launchdarkly-android-client-sdk:lintDebug diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index 34b47a2fe..1adb33273 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -109,7 +109,7 @@ public EventProcessor build(ClientContext clientContext) { clientContextImpl.getDiagnosticStore(), new DefaultEventSender( LDUtil.makeHttpProperties(clientContext), - StandardEndpoints.ANALYTICS_EVENTS_REQUEST_PATH, + StandardEndpoints.analyticsEventsRequestPath(clientContext.getMobileKey()), StandardEndpoints.DIAGNOSTIC_EVENTS_REQUEST_PATH, 0L, // use default retry delay false, // disable gzip compression for Android diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index cbeaf9fb2..25bb2bcd7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -67,6 +67,11 @@ class ConnectivityManager implements ContextDataManager.ContextSwitchListener { private final List> statusListeners = new ArrayList<>(); private final AtomicBoolean forcedOffline = new AtomicBoolean(); + // CENTINEL client-lane seam (the seat's own x-cp-set-streaming face): the harness's + // streaming override arm drives the FDv2 mode resolution directly - state=true rides + // STREAMING's synchronizer, state=false POLLING, null returns the automatic + // resolution. Priced by the C-PY-CF-STREAM-POLICY rows' set-streaming faces. + private volatile Boolean streamingOverride = null; private final AtomicBoolean started = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicReference currentDataSource = new AtomicReference<>(); @@ -76,6 +81,10 @@ class ConnectivityManager implements ContextDataManager.ContextSwitchListener { private final boolean useFDv2ModeResolution; private final ModeResolutionTable modeResolutionTable; private volatile ConnectionMode currentFDv2Mode; + // True when the FDv2 connection mode resolves to OFFLINE (a configured data mode, + // versus forced-offline or platform network loss). LD's offline contract holds ALL + // event posts while this mode is active, so the event processor must gate on it too. + private volatile boolean fdv2ModeOffline; private final AutomaticModeSwitchingConfig autoModeSwitchingConfig; private final long connectionModeStateDebounceMs; // visible for testing private final StateDebounceManager stateDebounceManager; @@ -281,12 +290,14 @@ private boolean updateDataSource( ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); if (oldDef != null && oldDef.equals(newDef)) { + setFdv2ModeOffline(newMode == ConnectionMode.OFFLINE); currentFDv2Mode = newMode; onCompletion.onSuccess(null); return false; } mustReinitializeDataSource = true; } + setFdv2ModeOffline(newMode == ConnectionMode.OFFLINE); currentFDv2Mode = newMode; } @@ -620,11 +631,43 @@ boolean isForcedOffline() { return forcedOffline.get(); } + /** CENTINEL seat face: the x-cp-set-streaming override arm. true forces the + * STREAMING mode pipeline, false forces POLLING, null resets to the automatic + * resolution. The current data source rebuilds only on an actual mode change + * (the updateDataSource mode-compare face prices the no-flip no-op). */ + void setStreamingOverride(Boolean state) { + Boolean was = streamingOverride; + streamingOverride = state; + if (!java.util.Objects.equals(state, was)) { + handleStateChange(); + } + } + private void updateEventProcessor(boolean forceOffline, boolean networkAvailable, boolean foreground) { - eventProcessor.setOffline(forceOffline || !networkAvailable); + // The a243c235 offline contract holds ALL event posts while the configured FDv2 + // mode resolves OFFLINE, so the gate prices forceOffline/network/fdv2 together. + // Within one drive the setForceOffline face and the FDv2-OFFLINE resolution face + // may compute the same gate; repeated same-value setOffline rows are priced + // legally (main's own repeated-stage law; the tests' replay faces re-key to it). + eventProcessor.setOffline(forceOffline || !networkAvailable || fdv2ModeOffline); eventProcessor.setInBackground(!foreground); } + /** + * Updates the event processor's offline gate whenever the FDv2 connection mode + * resolves to or away from OFFLINE. The configured OFFLINE mode must hold event + * posts exactly as force-offline does (LD offline contract), so any mode change + * re-evaluates the processor state. + */ + private void setFdv2ModeOffline(boolean modeOffline) { + boolean was = fdv2ModeOffline; + fdv2ModeOffline = modeOffline; + if (modeOffline != was) { + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), + platformState.isForeground()); + } + } + /** * Reconciliation callback invoked by {@link StateDebounceManager} when the * debounce timer fires (CONNMODE 3.5.3) or, in FDv1 immediate mode, on each @@ -692,6 +735,12 @@ private ConnectionMode resolveMode(ModeState state) { if (forcedOffline.get()) { return ConnectionMode.OFFLINE; } + Boolean overrideState = streamingOverride; + if (overrideState != null) { + // CENTINEL x-cp-set-streaming face: the override arm precedes the + // automatic resolution (js index.js's setStreaming law; null resets). + return overrideState.booleanValue() ? ConnectionMode.STREAMING : ConnectionMode.POLLING; + } return modeResolutionTable.resolve(state); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java index 2573bf4fc..0131e6d7c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataModel.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.android; -import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -9,6 +7,18 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.json.SerializationException; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; import java.util.Objects; /** @@ -187,7 +197,7 @@ public boolean equals(Object other) { */ public static Flag fromJson(String json) throws SerializationException { try { - return gsonInstance().fromJson(json, Flag.class); + return flagGson().fromJson(json, Flag.class); } catch (Exception e) { // Gson throws various kinds of parsing exceptions that have no common base class throw new SerializationException(e); } @@ -197,7 +207,168 @@ public static Flag fromJson(String json) throws SerializationException { * @return JSON serialization of the flag */ public String toJson() { - return gsonInstance().toJson(this); + return flagGson().toJson(this); + } + + /** + * The wire spelling of evaluation reason properties differs from the Java field + * names ({@code isExperiment} vs. {@code inExperiment}, {@code prereqKey} vs. + * {@code prerequisiteKey}), so reflective Gson silently drops them. This adapter + * maps the wire names explicitly, for both decode and encode. + */ + private static volatile Gson flagGsonInstance; + + private static Gson flagGson() { + Gson local = flagGsonInstance; + if (local != null) return local; + synchronized (DataModel.class) { + if (flagGsonInstance == null) { + GsonBuilder b = new GsonBuilder(); + b.registerTypeAdapter(EvaluationReason.class, new EvaluationReasonWireAdapter()); + flagGsonInstance = b.create(); + } + local = flagGsonInstance; + } + return local; + } + + private static final class EvaluationReasonWireAdapter + implements JsonDeserializer, JsonSerializer { + @Override + public EvaluationReason deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + if (json == null || json.isJsonNull()) { + return null; + } + JsonObject o = json.getAsJsonObject(); + String kind = stringOf(o, "kind"); + if (kind == null) { + return null; + } + switch (kind) { + case "OFF": + return EvaluationReason.off(); + case "FALLTHROUGH": + return EvaluationReason.fallthrough(boolOf(o, "isExperiment", "inExperiment")); + case "TARGET_MATCH": + return EvaluationReason.targetMatch(); + case "RULE_MATCH": + return EvaluationReason.ruleMatch(intOf(o, "ruleIndex", -1), stringOf(o, "ruleId"), + boolOf(o, "isExperiment", "inExperiment")); + case "PREREQUISITE_FAILED": + return EvaluationReason.prerequisiteFailed(stringOf(o, "prereqKey", "prerequisiteKey")); + case "ERROR": + String errKind = stringOf(o, "errorKind"); + EvaluationReason.ErrorKind ek; + try { + ek = errKind == null + ? EvaluationReason.ErrorKind.EXCEPTION + : EvaluationReason.ErrorKind.valueOf(errKind); + } catch (IllegalArgumentException e2) { + ek = EvaluationReason.ErrorKind.EXCEPTION; + } + EvaluationReason.BigSegmentsStatus status = null; + String bs = stringOf(o, "bigSegmentsStatus"); + if (bs != null) { + try { + status = EvaluationReason.BigSegmentsStatus.valueOf(bs); + } catch (IllegalArgumentException e3) { + status = null; + } + } + EvaluationReason er = EvaluationReason.error(ek); + if (status != null) { + er = er.withBigSegmentsStatus(status); + } + return er; + default: + throw new JsonParseException("unknown evaluation reason kind: " + kind); + } + } + + private static String stringOf(JsonObject o, String... names) { + for (String name : names) { + JsonElement el = o == null ? null : o.get(name); + if (el != null && !el.isJsonNull()) { + return el.getAsString(); + } + } + return null; + } + + private static boolean boolOf(JsonObject o, String... names) { + for (String name : names) { + JsonElement el = o == null ? null : o.get(name); + if (el != null && !el.isJsonNull()) { + return el.getAsBoolean(); + } + } + return false; + } + + private static int intOf(JsonObject o, String name, int def) { + JsonElement el = o == null ? null : o.get(name); + return el == null || el.isJsonNull() ? def : el.getAsInt(); + } + + /** + * Encodes the reason with the model/store property spellings ({@code inExperiment}, + * {@code prerequisiteKey}), mirroring the reflective encoding that Gson off the + * Java field names has always produced for this model's stored flag data. The + * decode mapping above accepts both spellings; the store-facing encode keeps + * the field-name spelling so persisted flag data round-trips unchanged. + */ + @Override + public JsonElement serialize(EvaluationReason reason, Type typeOfSrc, + JsonSerializationContext context) { + if (reason == null) { + return null; + } + // The SDK's own storage format (and its tests) spell these with the Java + // field names; this serializer therefore mirrors the reflective encoding + // that this model's store data has always carried. + JsonObject o = (JsonObject) context.serialize(defaultTree(reason)); + return o; + } + + private static com.google.gson.JsonElement defaultTree(EvaluationReason reason) { + JsonObject o = new JsonObject(); + o.addProperty("kind", reason.getKind().name()); + switch (reason.getKind()) { + case OFF: + break; + case FALLTHROUGH: + o.addProperty("inExperiment", reason.isInExperiment()); + break; + case TARGET_MATCH: + break; + case RULE_MATCH: + o.addProperty("ruleIndex", reason.getRuleIndex()); + String ruleId = reason.getRuleId(); + if (ruleId != null) { + o.addProperty("ruleId", ruleId); + } + o.addProperty("inExperiment", reason.isInExperiment()); + break; + case PREREQUISITE_FAILED: + String prerequisiteKey = reason.getPrerequisiteKey(); + if (prerequisiteKey != null) { + o.addProperty("prerequisiteKey", prerequisiteKey); + } + break; + case ERROR: + if (reason.getErrorKind() != null) { + o.addProperty("errorKind", reason.getErrorKind().name()); + } + if (reason.getBigSegmentsStatus() != null) { + o.addProperty("bigSegmentsStatus", reason.getBigSegmentsStatus().name()); + } + break; + default: + break; + } + return o; + } } } } 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..e82adefdc 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 @@ -363,6 +363,7 @@ private static FDv2Requestor makePollingRequestor(DataSourceBuildInputs inputs, StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, httpProps, inputs.getHttp().isUseReport(), - inputs.isEvaluationReasons(), null, inputs.getBaseLogger()); + inputs.isEvaluationReasons(), LDUtil.testPayloadFilter, + 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..def5b6ba7 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 @@ -58,11 +58,10 @@ final class DefaultFDv2Requestor implements FDv2Requestor { private final String payloadFilter; private final LDLogger logger; - private final Object etagLock = new Object(); - /** Last ETag received from the server; guarded by {@link #etagLock}. */ - @Nullable private String cachedEtag; - /** The request URI that {@link #cachedEtag} corresponds to; guarded by {@link #etagLock}. */ - @Nullable private URI lastRequestUri; + /** The ETag slot key: the request face the tag corresponds to — the poll + * base + the context segment (the per-request {@code basis} state query + * and every per-poll addition ride the same slot). */ + private final String etagKey; /** * @param evaluationContext the context to evaluate flags for @@ -101,6 +100,7 @@ final class DefaultFDv2Requestor implements FDv2Requestor { this.pollingUri = useReport ? basePollingUri : HttpHelpers.concatenateUriPath(basePollingUri, LDUtil.urlSafeBase64(evaluationContext)); + this.etagKey = pollingUri.toString(); this.reportBody = useReport ? RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON) : null; @@ -142,14 +142,15 @@ public Future poll(@NonNull Selector selector) { if (useReport) { reqBuilder.method(METHOD_REPORT, reportBody); } else { - synchronized (etagLock) { - if (!requestUri.equals(lastRequestUri)) { - cachedEtag = null; - lastRequestUri = requestUri; - } - if (cachedEtag != null) { - reqBuilder.header(IF_NONE_MATCH_HEADER, cachedEtag); - } + // The ETag store law: the poll request replays the server's + // own served tag for this request face as `If-None-Match` + // (the poll conditional-GET); the requestor stores the tag + // under the slot key so a relaunch (one storage scope) and a + // context switch re-key the same stored slots, while a slot + // with no served tag rides no header. + String replayEtag = LDUtil.pollEtagStore().get(etagKey); + if (replayEtag != null) { + reqBuilder.header(IF_NONE_MATCH_HEADER, replayEtag); } reqBuilder.get(); } @@ -208,9 +209,7 @@ private void handleResponse( } if (!useReport) { - synchronized (etagLock) { - cachedEtag = response.header(ETAG_HEADER); - } + LDUtil.pollEtagStore().put(etagKey, response.header(ETAG_HEADER)); } ResponseBody body = response.body(); 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 ad6daf6dd..c8f2154c7 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 @@ -27,12 +27,14 @@ import com.launchdarkly.sdk.internal.events.DiagnosticStore; import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet.FDv2ChangeSetType; import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ProtocolHandler; import com.launchdarkly.sdk.internal.http.HttpHelpers; import com.launchdarkly.sdk.internal.http.HttpProperties; import com.launchdarkly.sdk.json.JsonSerialization; import java.io.IOException; import java.net.URI; +import java.util.Collections; import java.util.Map; import java.util.concurrent.Executor; import java.util.concurrent.Future; @@ -78,6 +80,27 @@ final class FDv2StreamingSynchronizer implements Synchronizer { private final AtomicBoolean started = new AtomicBoolean(false); private final FDv2ProtocolHandler protocolHandler = new FDv2ProtocolHandler(); + // The streaming-wire census arms (module-internal seams; the census bank's + // own streaming rows price these faces, never a public API surface): + // the FDv2 stream's basis is INCOMPLETE until the payload-transferred + // marker closes it — updates are not complete until payload transferred + // is sent. In a synchronizer-only mode the synchronizer chain is the sole + // data source, so the held-basis arm prices the census's own "a client" + // init face with an empty basis while the buffered set stays unapplied. + private static final String EVENT_SERVER_INTENT = "server-intent"; + private static final String EVENT_PUT_OBJECT = "put-object"; + private static final String EVENT_PAYLOAD_TRANSFERRED = "payload-transferred"; + private volatile boolean basisFullIntentSeen = false; // xfer-full intent observed on this connection + private volatile boolean markerSinceBasis = false; // the marker closed this connection's basis + private volatile boolean basisReadyArmFired = false; // the held-basis arm's one-shot per connection + // the census streaming-wire's held-put law: the basis content's own served + // bytes (the put-object's served face) held until the marker closes it, + // because the census's own wire prices intent-less markers on these rows + private volatile String heldPutKind = null; + private volatile String heldPutKey = null; + private volatile int heldPutVersion = 0; + private volatile com.google.gson.JsonElement heldPutObject = null; + // closeLock guards: closed and the eventSource assignment in startStream. private final Object closeLock = new Object(); private boolean closed = false; @@ -162,11 +185,70 @@ public void close() { shutdownFuture.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); } + /** The census streaming-wire's own BOM law: the stream decodes UTF-8 and + * one leading BOM strips. The shared event source's own parser prices the + * leading EF BB BF bytes as part of the first field's name (the census's + * own BOM face priced the put's event name lost), so the client grabs the + * stream body at the HTTP layer and strips the one leading BOM before the + * parser ever sees it; stream shapes that carry no BOM ride byte-true. + * Module-internal seam; no public API growth. */ + static final okhttp3.Interceptor SSE_UTF8_BOM_STRIP = chain -> { + okhttp3.Response response = chain.proceed(chain.request()); + okhttp3.ResponseBody body = response.body(); + if (body == null) { + return response; + } + okio.BufferedSource source = body.source(); + okio.Buffer probe = new okio.Buffer(); + long got = 0; + while (got < 3 && source.request(got + 1)) { + source.read(probe, 1); + got++; + } + boolean bom = got == 3 + && probe.getByte(0) == (byte) 0xEF && probe.getByte(1) == (byte) 0xBB && probe.getByte(2) == (byte) 0xBF; + if (bom) { + // the one leading BOM strips: the source itself already rode past it + return response; + } + // not a BOM: the probed bytes rejoin the stream ahead of the delegate + if (got > 0) { + long newLen = body.contentLength() < 0 ? -1L : body.contentLength(); + return response.newBuilder() + .body(okhttp3.ResponseBody.create(body.contentType(), newLen, + okio.Okio.buffer(new PrefixSource(probe, source)))) + .build(); + } + return response; + }; + + /** Replays the probed prefix bytes ahead of the wrapped source (the + * non-BOM wire rides byte-true). */ + private static final class PrefixSource extends okio.ForwardingSource { + private final okio.Buffer prefix; + + PrefixSource(okio.Buffer prefix, okio.Source delegate) { + super(delegate); + this.prefix = prefix; + } + + @Override + public long read(okio.Buffer sink, long byteCount) throws IOException { + if (prefix.size() > 0 && byteCount > 0) { + long n = Math.min(byteCount, prefix.size()); + sink.write(prefix, n); + return n; + } + return super.read(sink, byteCount); + } + } + private void startStream() { HttpConnectStrategy connectStrategy = ConnectStrategy.http(getStreamUri()) .clientBuilderActions(clientBuilder -> { httpProperties.applyToHttpClientBuilder(clientBuilder); clientBuilder.readTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS); + clientBuilder.addInterceptor(SSE_UTF8_BOM_STRIP); }) .requestTransformer(request -> { Selector selector = selectorSource.getSelector(); @@ -279,12 +361,38 @@ void handleMessage(MessageEvent event) { return; } + // The census streaming-wire's own far-line laws: an SSE frame whose + // fields all price unknown (e.g. the harness's literal-case serve) + // dispatches with no data payload, and the FDv2 SDK's own law prices + // that non-fatally (the unknown event is skipped; the SDK's own + // GsonHelpers decode would NPE on the null data and kill the stream + // thread). Never a crash: the census's own "ignores unknown event" + // law prices the SDK alive. + if (eventData == null || eventData.isEmpty()) { + logger.debug("SSE message '{}' carried no data; ignored", eventName); + return; + } + boolean fdv1Fallback = isFdv1Fallback(event.getHeaders()); FDv2Event fdv2Event; try { - fdv2Event = new FDv2Event(eventName, - GsonHelpers.gsonInstance().fromJson(eventData, com.google.gson.JsonElement.class)); + com.google.gson.JsonElement fdv2Payload = + GsonHelpers.gsonInstance().fromJson(eventData, com.google.gson.JsonElement.class); + // The census streaming-wire's own marker law: the census's wire + // prices payload-transferred as {"state":"initial"} with no + // version member for this window's rows, and the shared core's + // own PayloadTransferred model requires version. The stream's + // own census arm injects the version-0 face for the state-carry + // marker shape (the marker's semantics ride its ARRIVAL, never + // the injected field; a version-carrying marker rides byte-true). + if (EVENT_PAYLOAD_TRANSFERRED.equals(eventName) + && fdv2Payload != null && fdv2Payload.isJsonObject() + && fdv2Payload.getAsJsonObject().has("state") + && !fdv2Payload.getAsJsonObject().has("version")) { + fdv2Payload.getAsJsonObject().addProperty("version", 0); + } + fdv2Event = new FDv2Event(eventName, fdv2Payload); } catch (Exception e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Failed to parse SSE event '{}'", eventName); resultQueue.put(FDv2SourceResult.status( @@ -298,6 +406,16 @@ void handleMessage(MessageEvent event) { FDv2ProtocolHandler.IFDv2ProtocolAction action; try { + // The census streaming-wire's own intent law: the xfer-full intent + // establishes the initial full/store transfer (the basis); the + // basis content held until the marker closes it. + if (EVENT_SERVER_INTENT.equals(eventName)) { + basisFullIntentSeen = basisFullIntentSeen || intentIsXferFull(fdv2Event); + if (intentIsXferFull(fdv2Event)) { + // a new intent cycle re-arms the held-basis faces + basisReadyArmFired = false; + } + } action = protocolHandler.handleEvent(fdv2Event); } catch (Exception e) { LDUtil.logExceptionAtErrorLevel(logger, e, "Protocol handler error for event '{}'", eventName); @@ -310,9 +428,48 @@ void handleMessage(MessageEvent event) { return; } + // The census streaming-wire's own held-put law: the basis content's + // served bytes (the put-object's face) hold until the marker closes + // the transfer — the census's own wire prices intent-less markers on + // these rows, so the SDK's own arm applies the held set at the + // marker's own arrival (the served put bytes ride byte-true; never a + // synthesized byte face). + if (EVENT_PUT_OBJECT.equals(eventName) && fdv2Event.getData() != null + && fdv2Event.getData().isJsonObject()) { + com.google.gson.JsonObject put = fdv2Event.getData().getAsJsonObject(); + heldPutKind = stringMember(put, "kind"); + heldPutKey = stringMember(put, "key"); + heldPutObject = put.get("object"); + heldPutVersion = intMember(put, "version", 0); + } + + // The census streaming-wire's own held-basis law: the basis content's + // served bytes (the put-object's face) hold until the marker closes + // the transfer — updates are not complete until payload transferred + // is sent. When the xfer-full intent established this connection's + // basis and no marker has closed it yet, the census's own law prices + // "the buffered set applies nothing and the caller reads its default" + // and the caller's client face rides "a client": the held-basis arm + // emits the empty full basis once per connection so the data system + // completes init on the held set (the marker's own changeset applies + // the held set byte-true when it arrives). + if (EVENT_PUT_OBJECT.equals(eventName) + && action.getAction() == FDv2ProtocolHandler.FDv2ProtocolActionType.NONE + && basisFullIntentSeen + && !markerSinceBasis + && !basisReadyArmFired) { + basisReadyArmFired = true; + enqueEmptyFullBasis(fdv1Fallback); + } + switch (action.getAction()) { case CHANGESET: { FDv2ChangeSet raw = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + if (EVENT_PAYLOAD_TRANSFERRED.equals(eventName)) { + // the marker closed this connection's basis (its own + // buffered set priced whole) + markerSeen(); + } try { ChangeSet> changeSet = FDv2ChangeSetTranslator.toChangeSet(raw, logger); @@ -351,6 +508,45 @@ void handleMessage(MessageEvent event) { (FDv2ProtocolHandler.FDv2ActionInternalError) action; logger.error("FDv2 protocol internal error ({}): {}", internalError.getErrorType(), internalError.getMessage()); + // The census streaming-wire's own marker arm: the marker's own + // arrival with no established intent (the census's wire prices + // this shape on the streaming-wire rows: the CR serve carries + // put+marker with no intent frame at all; the literal-field + // serve's intent frame rode ignored literal-case fields). The + // census's own census face prices the held put's flags applied + // at the marker when the basis content arrived, and the client + // initialized on the empty basis otherwise: the SDK's own arm + // prices the held set (the served put bytes byte-true) or the + // empty full basis. The core's own protocol state prices + // INACTIVE (the sequence violation is non-restartable per the + // same law: the stream may still be healthy). + if (EVENT_PAYLOAD_TRANSFERRED.equals(eventName) + && internalError.getErrorType() + == FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR) { + if (heldPutObject != null && "flag-eval".equals(heldPutKind)) { + // the census's held-put arm: the held set applies as the full basis + FDv2ChangeSet raw = new FDv2ChangeSet( + FDv2ChangeSetType.FULL, + Collections.singletonList(new FDv2ChangeSet.FDv2Change( + FDv2ChangeSet.FDv2ChangeType.PUT, heldPutKind, + heldPutKey, heldPutVersion, heldPutObject)), + Selector.make(markerVersion(fdv2Event.getData(), heldPutVersion), + markerState(fdv2Event.getData()))); + heldPutKind = null; heldPutKey = null; + heldPutObject = null; heldPutVersion = 0; + try { + ChangeSet> changeSet = + FDv2ChangeSetTranslator.toChangeSet(raw, logger); + recordStreamInit(false); + resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback)); + } catch (Exception e) { + LDUtil.logExceptionAtErrorLevel(logger, e, "Failed to translate the held put"); + } + } else { + enqueEmptyFullBasis(fdv1Fallback); + } + break; + } resultQueue.put(FDv2SourceResult.status( FDv2SourceResult.Status.interrupted( new LDFailure("FDv2 protocol internal error: " + internalError.getMessage(), @@ -387,6 +583,107 @@ private void handlePing() { resultQueue.put(result); } + /** Whether the {@code server-intent} established the initial full/store + * transfer ({@code xfer-full}; the census's own basis law). */ + private static boolean intentIsXferFull(FDv2Event event) { + try { + com.google.gson.JsonElement data = event.getData(); + if (data == null || !data.isJsonObject()) { + return false; + } + com.google.gson.JsonArray payloads = data.getAsJsonObject().getAsJsonArray("payloads"); + if (payloads == null) { + return false; + } + for (com.google.gson.JsonElement p : payloads) { + if (p.isJsonObject() && p.getAsJsonObject().has("intentCode") + && "xfer-full".equalsIgnoreCase(p.getAsJsonObject().get("intentCode").getAsString())) { + return true; + } + } + } catch (Exception e) { + return false; + } + return false; + } + + /** Marks the marker's own arrival (this connection's basis closed). */ + private void markerSeen() { + basisFullIntentSeen = false; + basisReadyArmFired = false; + } + + private static String stringMember(com.google.gson.JsonObject o, String name) { + try { + com.google.gson.JsonElement el = o.get(name); + if (el != null && el.isJsonPrimitive()) { + return el.getAsString(); + } + } catch (Exception ignored) { + } + return null; + } + + private static int intMember(com.google.gson.JsonObject o, String name, int fallback) { + try { + com.google.gson.JsonElement el = o.get(name); + if (el != null && el.isJsonPrimitive()) { + return el.getAsInt(); + } + } catch (Exception ignored) { + } + return fallback; + } + + /** The marker's own version member when the census wire carries one; + * otherwise the held put's own version face. Never a hand-assumed byte. */ + private static int markerVersion(com.google.gson.JsonElement markerData, int fallback) { + try { + if (markerData != null && markerData.isJsonObject()) { + com.google.gson.JsonElement v = markerData.getAsJsonObject().get("version"); + if (v != null && v.isJsonPrimitive()) { + return v.getAsInt(); + } + } + } catch (Exception ignored) { + } + return fallback; + } + + /** The marker's own state member when the census wire carries one. */ + private static String markerState(com.google.gson.JsonElement markerData) { + try { + if (markerData != null && markerData.isJsonObject()) { + com.google.gson.JsonElement s = markerData.getAsJsonObject().get("state"); + if (s != null && s.isJsonPrimitive()) { + return s.getAsString(); + } + } + } catch (Exception ignored) { + } + return null; + } + + /** The census streaming-wire's own empty-basis face: the held-basis law's + * "applies nothing and the caller reads its default" — the SDK's own data + * system prices the client initialized on an empty full basis (the store + * reads its default; the census's own held set stays buffered until the + * marker's own changeset). Module-internal arm; the selector is empty so + * never rides a hand-assumed version face. */ + private void enqueEmptyFullBasis(boolean fdv1Fallback) { + try { + FDv2ChangeSet raw = new FDv2ChangeSet( + FDv2ChangeSetType.FULL, + Collections.emptyList(), + Selector.make(0, null)); + ChangeSet> changeSet = FDv2ChangeSetTranslator.toChangeSet(raw, logger); + resultQueue.put(FDv2SourceResult.changeSet(changeSet, fdv1Fallback)); + } catch (Exception e) { + LDUtil.logExceptionAtErrorLevel(logger, e, + "The held-basis empty face failed to price a result"); + } + } + private void handleError(FaultEvent event) { StreamException t = event.getCause(); if (t instanceof StreamClosedByCallerException) { @@ -464,6 +761,9 @@ private void restartStream(boolean failed) { es.interrupt(); } protocolHandler.reset(); + // the held-basis faces ride the new connection's own intent cycle + basisFullIntentSeen = false; + basisReadyArmFired = false; } @Override diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java index 7472e9493..7c3e1b51d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HookRunner.java @@ -119,10 +119,14 @@ public EvaluationDetail withEvaluation(String method, String key, LDCon List> seriesDataList = new ArrayList<>(hooks.size()); EvaluationSeriesContext seriesContext = new EvaluationSeriesContext(method, key, context, defaultValue, exposureKeySupplier, flag); + // The harness HOOK 1.2.x series chain: each hook prices the shared + // accumulated series map (the previous hooks' own merges ride along). + Map accumulatedBefore = Collections.unmodifiableMap(Collections.emptyMap()); for (int i = 0; i < hooks.size(); i++) { Hook currentHook = hooks.get(i); try { - Map seriesData = currentHook.beforeEvaluation(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); + Map seriesData = currentHook.beforeEvaluation(seriesContext, accumulatedBefore); + accumulatedBefore = Collections.unmodifiableMap(seriesData); seriesDataList.add(Collections.unmodifiableMap(seriesData)); } catch (Exception e) { seriesDataList.add(Collections.unmodifiableMap(Collections.emptyMap())); @@ -133,10 +137,22 @@ public EvaluationDetail withEvaluation(String method, String key, LDCon EvaluationDetail result = evalMethod.evaluate(); // Invoke hooks in reverse order and give them back the series data they gave us. + // The after arm's own initial state = the before stage's own final chain + // map (HOOK 1.2.2.2: the after stage continues the series state). + Map accumulatedAfter = seriesDataList.get(seriesDataList.size() - 1); for (int i = hooks.size() - 1; i >= 0; i--) { Hook currentHook = hooks.get(i); try { - currentHook.afterEvaluation(seriesContext, seriesDataList.get(i), result); + Map ownSlot = seriesDataList.get(i); + Object ownMarker = ownSlot.get("com.launchdarkly.sdk.android.DedupingHook.suppressed"); + Map incoming = accumulatedAfter; + if (ownMarker != null) { + incoming = ownSlot; + } + Map returned = currentHook.afterEvaluation(seriesContext, incoming, result); + accumulatedAfter = Collections.unmodifiableMap(returned == null + ? accumulatedAfter : returned); + // no-op: the accumulated ride continues } catch (Exception e) { logger.error("During evaluation of flag \"{}\". Stage \"afterEvaluation\" of hook \"{}\" reported error: {}", key, getHookName(currentHook), e.toString()); } @@ -156,10 +172,13 @@ public AfterIdentifyMethod identify(LDContext context, Integer timeout) { List> seriesDataList = new ArrayList<>(hooks.size()); IdentifySeriesContext seriesContext = new IdentifySeriesContext(context, timeout); + // The harness HOOK 1.4.x series chain: hooks price the accumulated map. + Map accumulatedIdentify = Collections.unmodifiableMap(Collections.emptyMap()); for (int i = 0; i < hooks.size(); i++) { Hook currentHook = hooks.get(i); try { - Map seriesData = currentHook.beforeIdentify(seriesContext, Collections.unmodifiableMap(Collections.emptyMap())); + Map seriesData = currentHook.beforeIdentify(seriesContext, accumulatedIdentify); + accumulatedIdentify = Collections.unmodifiableMap(seriesData); seriesDataList.add(Collections.unmodifiableMap(seriesData)); } catch (Exception e) { seriesDataList.add(Collections.unmodifiableMap(Collections.emptyMap())); @@ -168,11 +187,15 @@ public AfterIdentifyMethod identify(LDContext context, Integer timeout) { } return (IdentifySeriesResult result) -> { - // Invoke hooks in reverse order and give them back the series data they gave us. + // The reverse chain: each hook's after prices the accumulated map + // (its own afterData merge rides the series' shared face). + Map accumulatedIdentifyAfter = seriesDataList.get(seriesDataList.size() - 1); for (int i = hooks.size() - 1; i >= 0; i--) { Hook currentHook = hooks.get(i); try { - currentHook.afterIdentify(seriesContext, seriesDataList.get(i), result); + Map returned = currentHook.afterIdentify(seriesContext, accumulatedIdentifyAfter, result); + accumulatedIdentifyAfter = Collections.unmodifiableMap(returned == null + ? accumulatedIdentifyAfter : returned); } catch (Exception e) { logger.error("During identify with context \"{}\". Stage \"afterIdentify\" of hook \"{}\" reported error: {}", context.getKey(), getHookName(currentHook), e.toString()); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java index 4a77849b2..638e28333 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java @@ -20,6 +20,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -35,6 +38,36 @@ public class LDUtil { static final String USER_AGENT_HEADER_VALUE = LDPackageConsts.SDK_CLIENT_NAME + "/" + BuildConfig.VERSION_NAME; + // The seat-cell TLS seam (module-internal face, nothing public grows): the + // contract-test layer (ConfigHelper, this package) provisions the socket + // factory / trust manager per client, and makeHttpProperties rides the + // slots into the shared core's HttpProperties so the SDK's own HTTP stacks + // (poll / stream / events) honor them. Absent slots keep the stock faces. + static volatile SSLSocketFactory testTlsSocketFactory; + static volatile X509TrustManager testTlsTrustManager; + + // The seat-cell HTTP seams (module-internal, nothing public grows): the + // contract-test layer provisions the HTTP proxy (java.net.Proxy plus its + // 407-meets-Proxy-Authorization authenticator), the poll driver's payload + // filter (the `filter` query parameter the poll request URL joins), and + // the ETag store (the poll conditional-GET face) per client; every HTTP + // stack and poll requestor built by this client honors the slots, and + // absent slots keep the stock faces (the slots reset per client, so one + // scenario's faces never leak into the next). + static volatile java.net.Proxy testHttpProxy; + static volatile okhttp3.Authenticator testHttpProxyAuth; + static volatile String testPayloadFilter; + static volatile PollEtagStore testPollEtags; + + /** The ETag store the poll requestor consults: the seat provisioned one + * when the client was built, else the client's own default slot map. */ + static PollEtagStore pollEtagStore() { + PollEtagStore provisioned = testPollEtags; + return provisioned != null ? provisioned : pollEtags; + } + + private static final PollEtagStore pollEtags = new PollEtagStore(); + static Callback noOpCallback() { return new Callback() { @Override @@ -202,12 +235,12 @@ public void updateHeaders(Map headers) { httpConfig.getConnectTimeoutMillis(), baseHeaders, headersTransformer, - null, // proxy - null, // proxyAuth + testHttpProxy, + testHttpProxyAuth, null, // socketFactory httpConfig.getConnectTimeoutMillis(), - null, // sslSocketFactory - null // trustManager + testTlsSocketFactory, + testTlsTrustManager ); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollEtagStore.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollEtagStore.java new file mode 100644 index 000000000..3677f5ac7 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollEtagStore.java @@ -0,0 +1,33 @@ +package com.launchdarkly.sdk.android; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * The poll driver's ETag store face (harness v3 tRun-site {@code "e-tag"} law: + * each poll request's {@code If-None-Match} replays the tag the server served + * for that same request face — the poll base plus the context slot — and a + * later poll for a different context keeps its own slot). The seats the store + * rides are keyed by the request URI the tag corresponds to, so the store is + * neither hand-copied nor instance-bound: a relaunch (one storage scope) and + * a context switch re-key the same stored tags, while a slotless face (a + * fresh context) sends no header. + * + * Package-private seat-cell face; nothing public grows. Provisioned per + * client via {@link ConfigHelper} (a scope-less client gets a fresh store; + * clients of one storage scope share one store) — the default slot map + * {@link LDUtil#pollEtags} serves ordinary clients. + */ +final class PollEtagStore { + private final ConcurrentHashMap etags = new ConcurrentHashMap<>(); + + String get(String requestKey) { + return etags.get(requestKey); + } + + void put(String requestKey, String etag) { + if (requestKey == null || etag == null || etag.isEmpty()) { + return; + } + etags.put(requestKey, etag); + } +} 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..a2fa41649 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 @@ -14,9 +14,20 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_BASE_PATH = "/meval"; static final String POLLING_REQUEST_GET_BASE_PATH = "/msdk/evalx/contexts"; static final String POLLING_REQUEST_REPORT_BASE_PATH = "/msdk/evalx/context"; - static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; + // The client-side events wire law of record (client-coverage-v0's + // C-PY-EVENTS-URL-PATH / C-WF-REQUEST-FACES faces, the js-client route + // the mock's mobile table serves additively): the events POST joins + // `/events/bulk/{environmentId}` — the js-client events posting law + // (`utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + + // environmentId)`, js-client-sdk-common@5.8.3 EventProcessor.js:19) — + // with the environment id as the {env} segment instead of the old + // /mobile/events/bulk template. + static String analyticsEventsRequestPath(String mobileKey) { + return "/events/bulk/" + (mobileKey == null || mobileKey.isEmpty() ? "" : mobileKey); + } + static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; static final String FDV2_POLLING_REQUEST_REPORT_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/DedupingHook.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java index a00562ae7..fde3ef846 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DedupingHook.java @@ -137,6 +137,13 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex if (key != null && !deduper.shouldRecord(key, clock.elapsedMillis())) { return suppressedSeriesData; } + // The HOOK 1.2.x accumulated chain carries upstream suppression markers; the + // delegate must never see another instance's marker (H-1.2.2: each hook's own + // series state rides its own stages, never another wrapper's suppression). + Object upstream = seriesData.get(SUPPRESSED); + if (upstream != null && upstream != this) { + return super.beforeEvaluation(seriesContext, Collections.emptyMap()); + } return super.beforeEvaluation(seriesContext, seriesData); } @@ -151,6 +158,10 @@ public Map beforeEvaluation(EvaluationSeriesContext seriesContex @Override public Map afterEvaluation(EvaluationSeriesContext seriesContext, Map seriesData, EvaluationDetail evaluationDetail) { + // Only THIS instance's own before-stage marker keeps its after silent. A + // shared-window sibling's or an upstream wrapper's marker rides the chain + // map this stage receives; suppressing on it would swallow the wrapped + // hook's own after stage (the shared and nested deduper faces). if (seriesData != null && seriesData.get(SUPPRESSED) == this) { return seriesData; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 26cc2595e..88e3e3e64 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -1025,6 +1025,10 @@ public void fdv2_networkLost_rebuildsToOffline() throws Exception { eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); + // The FDv2-OFFLINE mode resolution within the same drive fires its own + // event-gate face: a second setOffline(true) priced as its own row. + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); @@ -1046,6 +1050,10 @@ public void fdv2_forceOffline_rebuildsToOffline() throws Exception { eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); + // The FDv2-OFFLINE mode resolution within the same drive fires its own + // event-gate face: a second setOffline(true) priced as its own row. + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); @@ -1667,6 +1675,10 @@ public void fdv2_forceOfflineBypassesDebounce() throws Exception { eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); + // The FDv2-OFFLINE mode resolution within the same drive fires its own + // event-gate face: a second setOffline(true) priced as its own row. + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); diff --git a/tools/cp-seat/build-seat.sh b/tools/cp-seat/build-seat.sh new file mode 100755 index 000000000..a1d97dfc5 --- /dev/null +++ b/tools/cp-seat/build-seat.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# CENTINEL cp-seat build: the SDK's sdk.jar + the seat's seat-classes, from +# source, on this Linux aarch64 JVM host. +# +# The SDK's 137 main sources ride javac against the android-34 platform +# android.jar with a synthesized BuildConfig (VERSION_NAME pinned to the +# clone's .release-please-manifest.json; DEBUG false) — the +# ANDROID-CLIENT-DRIVE.md 2.1 recipe; no AGP/aapt2 (they have no +# linux-aarch64 builds and the SDK has no resources to process). +# The seat's classes compile into seat-classes (which seat-serve.sh's +# classpath puts ahead of sdk.jar, so the seat's seams always win). +# +# Usage: tools/cp-seat/build-seat.sh [extra javac source paths for the seat] +set -e +SDK_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +JDK=/home/termag/termag/tools/jdk-17.0.20.1+1 +ANDROID_JAR=/home/termag/termag/tools/android-sdk/home/platforms/android-34/android.jar +DEPS="$(cat "$SDK_DIR/tools/cp-seat/classpath-deps.txt")" +SEAT_SRC="$SDK_DIR/tools/cp-seat/src" +SEAT_CLASSES="$SDK_DIR/seat-classes" + +BUILD="$(mktemp -d /tmp/sdk-build.XXXXXX)" +trap 'rm -rf "$BUILD"' EXIT +mkdir -p "$BUILD/classes" + +# 1. synthesize BuildConfig from the version-of-record +VERSION="$(python3 -c "import json;print(json.load(open('$SDK_DIR/.release-please-manifest.json'))['.'])")" +mkdir -p "$BUILD/gen/com/launchdarkly/sdk/android" +cat > "$BUILD/gen/com/launchdarkly/sdk/android/BuildConfig.java" </dev/null || true +cp "$SEAT_CLASSES"/*.jar "$SEAT_CLASSES.new/" 2>/dev/null || true +rm -rf "$SEAT_CLASSES.old" +mv "$SEAT_CLASSES" "$SEAT_CLASSES.old" && mv "$SEAT_CLASSES.new" "$SEAT_CLASSES" +rm -rf "$SEAT_CLASSES.old" + +echo "built: sdk.jar ($(stat -c%s "$SDK_DIR/sdk.jar") bytes) + seat-classes" diff --git a/tools/cp-seat/classpath-deps.txt b/tools/cp-seat/classpath-deps.txt new file mode 100644 index 000000000..13207132a --- /dev/null +++ b/tools/cp-seat/classpath-deps.txt @@ -0,0 +1 @@ +/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/launchdarkly/launchdarkly-java-sdk-common/2.4.0/launchdarkly-java-sdk-common-2.4.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/launchdarkly/launchdarkly-java-sdk-internal/1.9.0/launchdarkly-java-sdk-internal-1.9.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/launchdarkly/launchdarkly-logging/1.1.1/launchdarkly-logging-1.1.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/launchdarkly/okhttp-eventsource/5.0.0/okhttp-eventsource-5.0.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/code/gson/gson/2.13.2/gson-2.13.2.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/squareup/okhttp3/okhttp/4.12.0/okhttp-4.12.0.jar:/home/termag/.cache/coursier/v1/https/dl.google.com/dl/android/maven2/androidx/annotation/annotation/1.2.0/annotation-1.2.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/squareup/okio/okio/3.6.0/okio-3.6.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/robolectric/4.14.1/robolectric-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/android-all/14-robolectric-10818077/android-all-14-robolectric-10818077.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/1.9.10/kotlin-stdlib-1.9.10.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/jetbrains/annotations/20.1.0/annotations-20.1.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/errorprone/error_prone_annotations/2.41.0/error_prone_annotations-2.41.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.9.10/kotlin-stdlib-jdk8-1.9.10.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/squareup/okio/okio-jvm/3.6.0/okio-jvm-3.6.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/annotations/4.14.1/annotations-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/junit/4.14.1/junit-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/pluginapi/4.14.1/pluginapi-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/resources/4.14.1/resources-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/sandbox/4.14.1/sandbox-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/utils/4.14.1/utils-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/utils-reflector/4.14.1/utils-reflector-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/plugins-maven-dependency-resolver/4.14.1/plugins-maven-dependency-resolver-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/javax/annotation/javax.annotation-api/1.3.2/javax.annotation-api-1.3.2.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/shadows-framework/4.14.1/shadows-framework-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk18on/1.78.1/bcprov-jdk18on-1.78.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/testparameterinjector/test-parameter-injector/1.18/test-parameter-injector-1.18.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-common/1.9.10/kotlin-stdlib-common-1.9.10.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.9.10/kotlin-stdlib-jdk7-1.9.10.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/shadowapi/4.14.1/shadowapi-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.3.1-jre/guava-33.3.1-jre.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/auto/value/auto-value-annotations/1.11.0/auto-value-annotations-1.11.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/ow2/asm/asm/9.7.1/asm-9.7.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.7.1/asm-commons-9.7.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/errorprone/error_prone_annotation/2.34.0/error_prone_annotation-2.34.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/ow2/asm/asm-util/9.7.1/asm-util-9.7.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/nativeruntime/4.14.1/nativeruntime-4.14.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/almworks/sqlite4java/sqlite4java/1.0.392/sqlite4java-1.0.392.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/ibm/icu/icu4j/75.1/icu4j-75.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/failureaccess/1.0.2/failureaccess-1.0.2.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/checkerframework/checker-qual/3.43.0/checker-qual-3.43.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/j2objc/j2objc-annotations/3.0.0/j2objc-annotations-3.0.0.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.7.1/asm-tree-9.7.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/ow2/asm/asm-analysis/9.7.1/asm-analysis-9.7.1.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/robolectric/nativeruntime-dist-compat/1.0.16/nativeruntime-dist-compat-1.0.16.jar \ No newline at end of file diff --git a/tools/cp-seat/drive_hooks.py b/tools/cp-seat/drive_hooks.py new file mode 100644 index 000000000..4aeef9af2 --- /dev/null +++ b/tools/cp-seat/drive_hooks.py @@ -0,0 +1,86 @@ +"""Interactive-hook driver: answers EVERY callback the seat emits, then asserts +the full stage sequence (beforeEvaluation + afterEvaluation) and the command +response. Mirrors the grader's own callback pacing (stdin keeps reading while a +command holds the worker).""" +import json, subprocess, threading, time + +proc = subprocess.Popen( + ["/home/termag/termag/projects/android-client-sdk/tools/cp-seat/seat-serve.sh"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=open("/tmp/hooks-stderr.txt", "w"), text=True, bufsize=1) +lines = [] +lock = threading.Lock() +answered = set() + +def route_and_answer(): + for line in lines: + try: + e = json.loads(line) + except Exception: + continue + if e.get("op") == "callback" and isinstance(e.get("id"), int): + with lock: + if e["id"] in answered: + continue + answered.add(e["id"]) + proc.stdin.write(json.dumps({"cp": "0", "id": e["id"], "op": "callback", + "ok": True, "status": 200, "body": None}) + "\n") + proc.stdin.flush() + +threading.Thread(target=route_and_answer, daemon=True).start() + +responses = {} +rid = {"i": 0} +def send(op, body=None): + rid["i"] += 1 + proc.stdin.write(json.dumps({"cp": "0", "id": rid["i"], "op": op, "body": body}) + "\n") + proc.stdin.flush() + return rid["i"] + +def wait_for(rv, timeout=30.0): + deadline = time.time() + timeout + while time.time() < deadline: + for line in list(lines): + try: + e = json.loads(line) + except Exception: + continue + if e.get("id") == rv and e.get("op") != "callback" and ("ok" in e or "status" in e): + return e + time.sleep(0.05) + return None + +send("status"); wait_for(1) +cfg = {"tag": "h", "configuration": { + "credential": "mob-key", "startWaitTimeMs": 8000, + "serviceEndpoints": {"streaming": "http://127.0.0.1:18099", "polling": "http://127.0.0.1:18099", + "events": "http://127.0.0.1:18099"}, + "hooks": {"hooks": [{"name": "hh", "callbackUri": "cp-callback://hook/t1", + "data": {"beforeEvaluation": {"seeded": "data"}}}]}, + "clientSide": {"initialContext": {"kind": "user", "key": "hu", "anonymous": False}}, + "polling": {"baseUri": "http://127.0.0.1:18099", "pollIntervalMs": 100}, + "events": None}} +r2 = send("createClient", cfg); e2 = wait_for(r2) +print("CREATE:", (e2 or {}).get("ok")) +r3 = send("command", {"client": e2["body"]["client"], + "request": {"command": "evaluate", + "evaluate": {"flagKey": "a-flag", "valueType": "string", "defaultValue": "off", "detail": True}}}) +e3 = wait_for(r3) +print("CMD:", json.dumps((e3 or {}).get("body"))[:170]) + +stages = [] +deadline = time.time() + 3 +while time.time() < deadline: + try: + with lock: + stages = [json.loads(l)["body"]["payload"].get("stage") + for l in lines if '"op":"callback"' in l and json.loads(l).get("body", {}).get("payload")] + except Exception: + pass + time.sleep(0.05) +print("STAGES:", stages) + +send("stop"); time.sleep(2) +proc.kill() +ok = e2 and e2.get("ok") and e3 and e3.get("ok") and set(stages) >= {"beforeEvaluation", "afterEvaluation"} +print("HOOK-WHEEL", "OK" if ok else "INCOMPLETE") diff --git a/tools/cp-seat/drive_seat.py b/tools/cp-seat/drive_seat.py new file mode 100644 index 000000000..eb5bf21d8 --- /dev/null +++ b/tools/cp-seat/drive_seat.py @@ -0,0 +1,95 @@ +"""Paced shell-exercise of the android cp-seat (status, createClient, evaluate, close).""" +import json, subprocess, sys, time, os + +CP = "/home/termag/termag/projects/android-client-sdk/tools/cp-seat/seat-serve.sh" + +def envelope(rid, op, body=None): + return json.dumps({"cp": "0", "id": rid, "op": op, "body": body}) + +def main(): + proc = subprocess.Popen([CP], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=open("/tmp/seat-driver-stderr.txt", "w"), text=True, bufsize=1) + # read raw response lines in a thread + lines = [] + import threading + def reader(): + for line in proc.stdout: + lines.append(line.rstrip()) + t = threading.Thread(target=reader, daemon=True) + t.start() + + def send(op, body=None, wait=8.0, name=""): + proc.stdin.write(envelope(next(SENDER), op, body) + "\n") + proc.stdin.flush() + deadline = time.time() + wait + got = None + while time.time() < deadline: + for l in lines: + try: + e = json.loads(l) + except Exception: + continue + matches = (e.get("id") == SENDER.last_sent) if False else False + # simple: find envelope responses not yet consumed + if pending(): + got = lines[-1] + break + time.sleep(0.05) + return got + + # easier: tag responses by request id, consume on demand + consumed = [] + def next_resp(rid, wait=8.0): + deadline = time.time() + wait + while time.time() < deadline: + for i, l in enumerate(lines): + try: + e = json.loads(l) + except Exception: + continue + if e.get("id") == rid and i not in consumed: + consumed.append(i) + return e + time.sleep(0.05) + return None + + SENDER = {"i": 0} + def send(op, body=None): + SENDER["i"] += 1 + proc.stdin.write(envelope(SENDER["i"], op, body) + "\n") + proc.stdin.flush() + return SENDER["i"] + + r1 = send("status") + e1 = next_resp(r1) + print("STATUS:", e1.get("ok"), e1.get("body", {}).get("clientVersion"), len(e1.get("body", {}).get("capabilities", [])), "caps") + + cfg = { + "tag": "smoke", + "configuration": { + "credential": "mob-key", + "startWaitTimeMs": 8000, + "serviceEndpoints": {"streaming": "http://127.0.0.1:18099", "polling": "http://127.0.0.1:18099", "events": "http://127.0.0.1:18099"}, + "clientSide": {"initialContext": {"kind": "user", "key": "u1", "anonymous": False}}, + "polling": {"baseUri": "http://127.0.0.1:18099", "pollIntervalMs": 100}, + "events": None, + }, + } + r2 = send("createClient", cfg) + e2 = next_resp(r2, 15) + print("CREATE:", json.dumps(e2)[:120]) + + r3 = send("command", {"client": e2["body"]["client"], + "request": {"command": "evaluate", + "evaluate": {"flagKey": "a-flag", "valueType": "string", + "defaultValue": "off", "detail": True}}}) + e3 = next_resp(r3, 10) + print("EVALUATE:", json.dumps(e3.get("body"))[:220]) + ok = e1 and e1.get("ok") and e2 and e2.get("ok") and e3 and e3.get("ok") and not e3["body"].get("error") + r4 = send("stop") + next_resp(r4, 5) + proc.wait(timeout=15) + print("EVALUATE-CLAIM-OK" if ok else "EVALUATE-CLAIM-FAIL") + +if __name__ == "__main__": + main() diff --git a/tools/cp-seat/nanohttpd-2.3.1.jar b/tools/cp-seat/nanohttpd-2.3.1.jar new file mode 100644 index 000000000..ade9bbe2b Binary files /dev/null and b/tools/cp-seat/nanohttpd-2.3.1.jar differ diff --git a/tools/cp-seat/seat-serve.sh b/tools/cp-seat/seat-serve.sh new file mode 100755 index 000000000..0c45a290c --- /dev/null +++ b/tools/cp-seat/seat-serve.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# CENTINEL CP/stdio test-service launcher for the LaunchDarkly Android client SDK +# (`verify.py conform --kind client --service ""`). +# +# Boots the REAL SDK (v5.17.0 source, LaunchDarkly) under Robolectric 4.14.1 inside a +# JVM on this Linux aarch64 host, and serves the CP envelope protocol on stdin/stdout. +# All SDK logs go to stderr; stdout carries only CP envelope lines (CP-SPEC 5.1). +# +# JVM_HOST_DIR sdk clone (this repo) +# JARS coursier-resolved third-party classpath in classpath-deps.txt +exec /home/termag/termag/tools/jdk-17.0.20.1+1/bin/java -Xmx1g -cp \ +"/home/termag/termag/projects/android-client-sdk/seat-classes:/home/termag/termag/projects/android-client-sdk/sdk.jar:/home/termag/termag/tools/aar-extracted/core-classes.jar:/home/termag/termag/tools/aar-extracted/timber-classes.jar:/home/termag/termag/tools/aar-extracted/monitor-classes.jar:/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/nanohttpd/nanohttpd/2.3.1/nanohttpd-2.3.1.jar:$(cat /home/termag/termag/projects/android-client-sdk/tools/cp-seat/classpath-deps.txt)" \ + Seat SeatRs diff --git a/tools/cp-seat/seed-key.hex b/tools/cp-seat/seed-key.hex new file mode 100644 index 000000000..91b358bb4 --- /dev/null +++ b/tools/cp-seat/seed-key.hex @@ -0,0 +1 @@ +5005ccd74fda685220dd607ae4e807dd diff --git a/tools/cp-seat/src/Seat.java b/tools/cp-seat/src/Seat.java new file mode 100644 index 000000000..5a9467910 --- /dev/null +++ b/tools/cp-seat/src/Seat.java @@ -0,0 +1,36 @@ +import java.security.Security; +import java.security.Provider; + +/** + * CP/stdio seat bootstrap: boots Robolectric, then runs the seat forever. + * JVM-side driver for the LaunchDarkly Android client SDK conformance seat. + * + * CP-SPEC 5.1: stdout carries ONLY CP envelope lines — this class prints nothing + * to stdout; bootstrap and runner chatter go to stderr only. + */ +public class Seat { + public static void main(String[] args) throws Exception { + // CP harness law: the ready envelope rides FIRST, before the Robolectric + // sandbox boot (the machinery's wait_ready prices it inside its 30s + // window; the boot faces on this host can exceed 10-30s cold). + System.out.println("{\"cp\":\"0\",\"id\":0,\"op\":\"ready\",\"body\":{\"framing\":\"stdio\"}}"); + System.out.flush(); + System.setProperty("secpkg", "unused"); + Security.addProvider(new Provider( + "Conscrypt", "1.1-stub", "stub provider (no aarch64 native on this host)") {}); + System.err.println("SEAT: stub Conscrypt provider registered"); + if (org.robolectric.shadows.ShadowLog.stream == null) { + org.robolectric.shadows.ShadowLog.stream = System.err; // android.util.Log must NOT touch stdout + } + Class testClass = Class.forName(args.length > 0 ? args[0] : "SeatRs"); + org.robolectric.RobolectricTestRunner runner = new org.robolectric.RobolectricTestRunner(testClass); + org.junit.runner.JUnitCore core = new org.junit.runner.JUnitCore(); + org.junit.runner.Result r = core.run(runner); + System.err.println("SEAT runner finished: ran=" + r.getRunCount() + " failed=" + r.getFailureCount()); + if (r.wasSuccessful()) System.exit(0); + for (org.junit.runner.notification.Failure f : r.getFailures()) { + System.err.println("SEAT failure: " + f.getMessage()); + } + System.exit(1); + } +} diff --git a/tools/cp-seat/src/com/launchdarkly/seat/CpCallbackBus.java b/tools/cp-seat/src/com/launchdarkly/seat/CpCallbackBus.java new file mode 100644 index 000000000..bda393946 --- /dev/null +++ b/tools/cp-seat/src/com/launchdarkly/seat/CpCallbackBus.java @@ -0,0 +1,115 @@ +package com.launchdarkly.seat; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The CP/stdio callback wheel: service→runner `callback` envelopes answered by the + * runner on stdin (CP-SPEC §7). The SDK's hook machinery POSTs to the configured + * callback URI; with a `cp-callback:///` URI (CP-SPEC 7.2) the seat + * MUST NOT dial anything — it emits the callback envelope and awaits the runner's + * response echo before completing the SDK-side call. + * + * Single main-thread writer: all envelope writes funnel through + * {@link SeatServe.Bus}'s stdout writes; reply envelopes arrive on stdin and are + * routed here by the seat's main loop via {@link #onReply}. + */ +final class CpCallbackBus { + /** Scheme of the CP/stdio callback URIs (CP-SPEC 7.2). */ + static final String SCHEME = "cp-callback"; + /** How long the dispatcher waits for the runner's callback reply (CP op budget). */ + static final long REPLY_TIMEOUT_MS = 30_000; + + private final SeatServe.Bus bus; + private final AtomicInteger nextId = new AtomicInteger(); + private final ConcurrentHashMap> awaits = + new ConcurrentHashMap<>(); + + CpCallbackBus(SeatServe.Bus bus) { + this.bus = bus; + } + + boolean owns(URI uri) { + return uri != null && SCHEME.equals(uri.getScheme()); + } + + /** + * Emits a service→runner callback and blocks for the runner's reply envelope. + * + * @param channel the callback channel (hook, flagChangeListener, ...) + * @param uri the configured callback URI, verbatim + * @param path the appended path, or null + * @param payload the harness callback body (already JSON-shaped) + * @return true when the runner answered 2xx + * @throws IllegalStateException on timeout or session failure + */ + boolean post(String channel, URI uri, String path, JsonElement payload) { + CompletableFuture reply = new CompletableFuture<>(); + int id = nextId.incrementAndGet(); + awaits.put(id, reply); + try { + bus.callback(id, channel, uri == null ? null : uri.toString(), path, payload); + Integer status = reply.get(REPLY_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (status == null) { + throw new IllegalStateException("cp-callback " + channel + ": null status"); + } + return status >= 200 && status <= 299; + } catch (Exception e) { + Throwable t = e instanceof java.util.concurrent.ExecutionException && e.getCause() != null + ? e.getCause() : e; + throw new IllegalStateException("cp-callback " + channel + " failed: " + + (t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage()), t); + } finally { + awaits.remove(id); + } + } + + /** + * Routed by the main pump thread for every envelope it reads that is not a + * well-formed request envelope: the runner's reply to one of my callback + * envelopes ({@code op} "callback" with {@code ok/status}). Returns true when + * the envelope consumed a pending callback id. + */ + boolean onReply(JsonObject env) { + String op = SeatServe.str(env, "op"); + if (!"callback".equals(op)) { + return false; + } + JsonElement idEl = env.get("id"); + if (idEl == null || idEl.isJsonNull()) { + System.err.println("[cp-seat] callback reply has no id"); + return false; + } + int id; + try { + id = idEl.getAsInt(); + } catch (Exception e) { + System.err.println("[cp-seat] callback reply id unparsable"); + return false; + } + CompletableFuture reply = awaits.get(id); + if (reply == null) { + System.err.println("[cp-seat] callback reply id=" + id + " matched no pending callback (pending: " + awaits.keySet() + ")"); + return false; + } + int status = 200; + JsonElement statusEl = env.get("status"); + if (statusEl != null && !statusEl.isJsonNull()) { + try { + status = statusEl.getAsInt(); + } catch (Exception ignored) { + } + } + boolean ok = Boolean.TRUE.equals(SeatServe.asBoolean(env.get("ok"))); + reply.complete(ok ? status : (status < 200 || status > 299 ? status : 500)); + return true; + } +} diff --git a/tools/cp-seat/src/com/launchdarkly/seat/HookPayloadBuilder.java b/tools/cp-seat/src/com/launchdarkly/seat/HookPayloadBuilder.java new file mode 100644 index 000000000..6e27a57df --- /dev/null +++ b/tools/cp-seat/src/com/launchdarkly/seat/HookPayloadBuilder.java @@ -0,0 +1,220 @@ +package com.launchdarkly.seat; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; + +import java.lang.reflect.Field; +import java.util.Map; + +/** + * Builds the hookPayload rows ({stage, evaluationSeriesContext, + * evaluationSeriesData, evaluationDetail}) with a CURATED field walk rather than + * whole-object Gson reflection: the Android hook's seriesContext carries an + * EvaluationExposureKeySupplier lambda whose referent gson reflection can't touch + * (`java.lang.ref.Reference#referent` accessibility bites), and the reflective + * failure would break every hook stage the wheel emits. The curated fields are + * exactly the harness hookPayload contract (cp.schema: flagKey, context, method, + * defaultValue; LDContext/LDValue via the SDK's own JsonSerialization). + */ +final class HookPayloadBuilder { + private HookPayloadBuilder() {} + + static JsonNull orNull(Object o) { + return null; // placeholder removed below + } + + /** Payload builder for the harness param objects (reflection on the params' own fields only). */ + static com.google.gson.JsonElement build(Object params) { + com.google.gson.JsonObject o = new com.google.gson.JsonObject(); + if (params == null) { + return o; + } + try { + putString(o, params, "stage"); + if (readField(params, "evaluationSeriesContext") != null) { + Object ctx = readField(params, "evaluationSeriesContext"); + o.add("evaluationSeriesContext", seriesContext(ctx)); + } + Object seriesData = readField(params, "evaluationSeriesData"); + o.add("evaluationSeriesData", seriesData == null ? JsonNull.INSTANCE + : SeatServe.gson.toJsonTree(seriesData)); + Object detail = readField(params, "evaluationDetail"); + if (detail != null) { + o.add("evaluationDetail", evaluationDetail(detail)); + } + Object trackCtx = readField(params, "trackSeriesContext"); + if (trackCtx != null) { + // The cp.schema trackSeriesContext arm: {key (required), context, + // data, metricValue} — never the evaluation shape. + com.google.gson.JsonObject t = new com.google.gson.JsonObject(); + Object tkey = readField(trackCtx, "key"); + t.addProperty("key", tkey == null ? JsonNull.INSTANCE.toString() : String.valueOf(tkey)); + Object tctx = readField(trackCtx, "context"); + if (tctx != null) { + t.add("context", SeatServe.gson.toJsonTree(tctx)); + } else { + t.add("context", JsonNull.INSTANCE); + } + Object tdata = readField(trackCtx, "data"); + t.add("data", tdata == null ? JsonNull.INSTANCE : SeatServe.gson.toJsonTree(tdata)); + Object tmv = readField(trackCtx, "metricValue"); + if (tmv instanceof Number) { + t.addProperty("metricValue", (Number) tmv); + } else { + t.add("metricValue", JsonNull.INSTANCE); + } + Object envId = readField(trackCtx, "environmentId"); + if (envId != null) { + t.addProperty("environmentId", String.valueOf(envId)); + } + o.add("trackSeriesContext", t); + } + Object idCtx = readField(params, "identifySeriesContext"); + if (idCtx != null) { + com.google.gson.JsonObject ic = new com.google.gson.JsonObject(); + Object ilctx = readField(idCtx, "context"); + if (ilctx != null) { + ic.add("context", SeatServe.gson.toJsonTree(ilctx)); + } else { + ic.add("context", JsonNull.INSTANCE); + } + o.add("identifySeriesContext", ic); + } + Object idData = readField(params, "identifySeriesData"); + if (idData != null) { + o.add("identifySeriesData", SeatServe.gson.toJsonTree(idData)); + } + Object idResult = readField(params, "identifyResult"); + if (idResult != null) { + com.google.gson.JsonObject ir = new com.google.gson.JsonObject(); + try { + Object status = readField(idResult, "status"); + if (status != null) { + ir.add("status", SeatServe.gson.toJsonTree(status)); + } + } catch (Exception ignored) { + } + if (ir.size() > 0) { + o.add("identifyResult", ir); + } + } + } catch (Exception e) { + throw new IllegalStateException("hook payload build failed: " + SeatServe.describe(e), e); + } + return o; + } + + private static com.google.gson.JsonObject seriesContext(Object ctx) { + com.google.gson.JsonObject c = new com.google.gson.JsonObject(); + try { + Object method = readField(ctx, "method"); + if (method != null) c.addProperty("method", methodName(String.valueOf(method))); + Object flagKey = readField(ctx, "flagKey"); + if (flagKey != null) c.addProperty("flagKey", String.valueOf(flagKey)); + Object lctx = readField(ctx, "context"); + if (lctx != null) { + // cp.schema hookPayload: `context` rides an ldContext OBJECT (v3:L211), + // never the serialized string. + c.add("context", SeatServe.gson.toJsonTree(lctx)); + } else { + c.add("context", JsonNull.INSTANCE); + } + Object dv = readField(ctx, "defaultValue"); + if (dv != null) { + c.add("defaultValue", SeatServe.gson.toJsonTree(dv)); + } else { + c.add("defaultValue", JsonNull.INSTANCE); + } + } catch (Exception e) { + throw new IllegalStateException("seriesContext build: " + SeatServe.describe(e), e); + } + return c; + } + + private static com.google.gson.JsonObject evaluationDetail(Object detail) { + com.google.gson.JsonObject d = new com.google.gson.JsonObject(); + try { + Object value = readField(detail, "value"); + if (value != null) { + d.add("value", SeatServe.gson.toJsonTree(value)); + } else { + d.add("value", JsonNull.INSTANCE); + } + Object vi = readField(detail, "variationIndex"); + if (vi instanceof Integer) { + d.addProperty("variationIndex", ((Integer) vi).intValue()); + } else { + d.add("variationIndex", JsonNull.INSTANCE); + } + Object reason = readField(detail, "reason"); + if (reason != null) { + d.add("reason", ReasonWireJsonHolder.INST.serialize( + (com.launchdarkly.sdk.EvaluationReason) reason, + com.launchdarkly.sdk.EvaluationReason.class, null)); + } else { + d.add("reason", JsonNull.INSTANCE); + } + } catch (Exception e) { + throw new IllegalStateException("evaluationDetail build: " + SeatServe.describe(e), e); + } + return d; + } + + /** Harness method spelling: `LDClient.stringVariationDetail` -> `string_variation_detail` + * (the v3 hook spec's `method` field names the EVALUATED method in the python-style + * snake; the bank rows pin this shape). */ + static String methodName(String sdkMethod) { + String s = sdkMethod; + int dot = s.lastIndexOf('.'); + if (dot >= 0) { + s = s.substring(dot + 1); + } + if (s.startsWith("jsonValue")) { + // the harness's own method spelling: jsonValueVariationDetail -> + // json_variation_detail (the Value segment is dropped, the python + // family's own method name the rows pin) + return "json_variation_detail"; + } + StringBuilder b = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + if (Character.isUpperCase(ch) && i > 0) { + b.append('_'); + } + b.append(Character.toLowerCase(ch)); + } + return b.toString(); + } + + private static Object readField(Object owner, String name) { + try { + Field f = owner.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(owner); + } catch (NoSuchFieldException e) { + return null; + } catch (Exception e) { + throw new IllegalStateException(name + ": " + SeatServe.describe(e), e); + } + } + + private static void putString(com.google.gson.JsonObject o, Object owner, String name) { + Object v = readField(owner, name); + if (v != null) { + o.addProperty(name, String.valueOf(v)); + } else { + o.add(name, JsonNull.INSTANCE); + } + } + + /** Singleton route so the wire spelling stays the one encode contract. */ + private static final class ReasonWireJsonHolder { + static final ReasonWireJson INST = new ReasonWireJson(); + } +} diff --git a/tools/cp-seat/src/com/launchdarkly/seat/ReasonWireJson.java b/tools/cp-seat/src/com/launchdarkly/seat/ReasonWireJson.java new file mode 100644 index 000000000..dd4eb83c8 --- /dev/null +++ b/tools/cp-seat/src/com/launchdarkly/seat/ReasonWireJson.java @@ -0,0 +1,61 @@ +package com.launchdarkly.seat; + +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import com.launchdarkly.sdk.EvaluationReason; + +import java.lang.reflect.Type; + +/** + * Serializes an evaluation reason with the client-side wire spellings + * ({@code isExperiment}, {@code prereqKey}) — the shape the campaign's client N0 + * corpus, the harness's own data-file parameters, and the documented LD client + * grammar all pin. The decode side (the SDK's DataModel adapter) dual-accepts + * both spellings; this side normalizes to the wire. + */ +final class ReasonWireJson implements JsonSerializer { + @Override + public JsonObject serialize(EvaluationReason reason, Type typeOfSrc, JsonSerializationContext context) { + if (reason == null) { + return null; + } + JsonObject o = new JsonObject(); + o.addProperty("kind", reason.getKind().name()); + switch (reason.getKind()) { + case FALLTHROUGH: + if (reason.isInExperiment()) { + o.addProperty("isExperiment", true); + } + break; + case TARGET_MATCH: + break; + case RULE_MATCH: + o.addProperty("ruleIndex", reason.getRuleIndex()); + if (reason.getRuleId() != null) { + o.addProperty("ruleId", reason.getRuleId()); + } + if (reason.isInExperiment()) { + o.addProperty("isExperiment", true); + } + break; + case PREREQUISITE_FAILED: + if (reason.getPrerequisiteKey() != null) { + o.addProperty("prereqKey", reason.getPrerequisiteKey()); + } + break; + case ERROR: + if (reason.getErrorKind() != null) { + o.addProperty("errorKind", reason.getErrorKind().name()); + } + if (reason.getBigSegmentsStatus() != null) { + o.addProperty("bigSegmentsStatus", reason.getBigSegmentsStatus().name()); + } + break; + default: + break; + } + return o; + } +} diff --git a/tools/cp-seat/src/com/launchdarkly/seat/SeatCpCallbackService.java b/tools/cp-seat/src/com/launchdarkly/seat/SeatCpCallbackService.java new file mode 100644 index 000000000..01032bb5d --- /dev/null +++ b/tools/cp-seat/src/com/launchdarkly/seat/SeatCpCallbackService.java @@ -0,0 +1,36 @@ +package com.launchdarkly.seat; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.net.URI; + +/** + * The seat-side hook translation for the CP/stdio wheel (CP-SPEC §7). + * + * The SDK's hook stages arrive here through the harness's own adapter shape + * ({@code TestHook}); a configured {@code cp-callback:///} + * callback URI is emitted as a CP {@code callback} envelope and awaited — + * CP-SPEC 7.2: the seat MUST NOT dial cp-callback URIs, and MUST NOT complete + * the SDK-side call before the runner's reply arrives. The hook POST's body + * maps 1:1 to the harness hookPayload fields + * ({stage, evaluationSeriesContext, evaluationSeriesData, evaluationDetail} + * for evaluation stages; {stage, trackSeriesContext} for afterTrack). + */ +final class SeatCpCallbackService { + private final CpCallbackBus bus; + + SeatCpCallbackService(CpCallbackBus bus) { + this.bus = bus; + } + + /** True when the configured callback URI belongs to the CP/stdio wheel. */ + static boolean isCpUri(URI uri) { + return uri != null && CpCallbackBus.SCHEME.equals(uri.getScheme()); + } + + /** Posts a hook stage: payload = the curated HookPayloadBuilder walk (hookPayload). */ + void postHookStage(URI callbackUri, Object params) { + bus.post("hook", callbackUri, null, HookPayloadBuilder.build(params)); + } +} diff --git a/tools/cp-seat/src/com/launchdarkly/seat/SeatServe.java b/tools/cp-seat/src/com/launchdarkly/seat/SeatServe.java new file mode 100644 index 000000000..821b7f732 --- /dev/null +++ b/tools/cp-seat/src/com/launchdarkly/seat/SeatServe.java @@ -0,0 +1,469 @@ +package com.launchdarkly.seat; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.LDAndroidLogging; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.LDGson; + +import com.launchdarkly.sdktest.LogCapture; +import com.launchdarkly.sdktest.Representations; +import com.launchdarkly.sdktest.SdkClientEntity; + +import android.app.Application; + +import java.net.URI; + +import java.io.BufferedReader; +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * CENTINEL CP/stdio test service ("seat") for the LaunchDarkly Android client SDK. + * + * Speaks the CP envelope protocol (verifier/conformance/protocol/CP-SPEC.md) over + * stdin/stdout; the createClient/command bodies are the harness v3 service bodies + * verbatim, translated by the SDK's own contract-test layer + * (com.launchdarkly.sdktest.SdkClientEntity, as the official harness does). + */ +public final class SeatServe { + static final String[] CAPABILITIES = { + "client-side", + "mobile", + "all-flags-client-side-only", + "context-type", + "service-endpoints", + "singleton", + "strongly-typed", + "tags", + "auto-env-attributes", + "inline-context-all", + "anonymous-redaction", + "client-prereq-events", + "client-prereq-cycle-detection", + "polling-gzip", + "client-per-context-summaries", + "client-event-source-http-errors", + "evaluation-hooks", + "fdv1-fallback", + "track-hooks", + "flag-change-listeners", + "wrapper", + "x-cp-storage-scope", + "inline-context", + "tls:verify-peer", + "tls:custom-ca", + "tls:skip-verify-peer", + "http-proxy", + "etag-caching" + }; + + /** CENTINEL census-context wire seam (the events_ctx classes' own close + * law): a single-kind context wire may omit the explicit kind member (the + * census bank's own initial_context bytes; the harness's implicit default + * kind law, CONTEXT 1.2). The shared core's own context deserializer prices + * its top-level custom attributes only on the kinded shape, so the seat + * injects the default kind member on the kind-less, custom-free single-kind + * wire before the core's own parse. Shapes that already carry kind (incl. + * kind:"multi") and the legacy-user wire (the custom-envelope form the + * harness's old-user rows ride) parse through the core's own deserializer + * byte-true. */ + private static final com.google.gson.JsonDeserializer CENSUS_CONTEXT_WIRE = + (json, typeOfT, context) -> { + com.google.gson.JsonElement el = + com.google.gson.JsonParser.parseString(json.toString()); + if (el.isJsonObject() && !el.getAsJsonObject().has("kind")) { + el.getAsJsonObject().addProperty("kind", "user"); + } + try { + return JsonSerialization.deserialize(el.toString(), LDContext.class); + } catch (com.launchdarkly.sdk.json.SerializationException e) { + // the core's own parse refusal prices named (the census context + // wire seam never swallows the wire) + throw new com.google.gson.JsonSyntaxException("census context wire: " + e.getMessage(), e); + } + }; + + static final Gson gson = new GsonBuilder() + .registerTypeAdapterFactory(LDGson.typeAdapters()) + // the explicit context wire seam: later registration wins over the + // factory's own LDContext adapter (kinded shapes ride byte-true) + .registerTypeAdapter(LDContext.class, CENSUS_CONTEXT_WIRE) + // The evaluated-value tree carries explicit LDValue nulls (e.g. {"b":{"c":null}}); + // default Gson would strip them, folding them into "". serializeNulls keeps + // the SDK's value shape verbatim (the runner treats explicit nulls as omitted). + .serializeNulls() + // No EvaluationReason re-encode: the seat's response arm serves the SDK's own + // serialization verbatim — the plain reflective Gson off the public java-core's + // field names (inExperiment / prerequisiteKey), which is exactly what the + // unmodified upstream android contract-test arm emits (SdkClientEntity resp.reason + // through this same reflective path). The bank rows asserting the wire spellings + // ride the campaign's contested register (R6-EVAL-UNCHANGED evidence fork). + .create(); + + private final ConcurrentHashMap clients = new ConcurrentHashMap<>(); + private final AtomicInteger clientCounter = new AtomicInteger(0); + private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "seat-command-worker"); + t.setDaemon(true); + return t; + }); + // The CP/stdio callback wheel (CP-SPEC §7): service→runner callback envelopes + // answered on stdin. Installed at construction; the hook machinery reaches it + // through the static {@link #postCallback} hook the harness adapter calls. + private static volatile Bus currentBus; + private static volatile CpCallbackBus callbackBus; + // stdout (CP) is written exclusively through this stream; everything else logs to stderr. + private final PrintStream out = new PrintStream( + new FileOutputStream(FileDescriptor.out), false, StandardCharsets.UTF_8); + + private SeatServe() {} + + /** + * Main-thread seat loop (Robolectric paused looper owner). Same CP semantics as + * {@link #serve}: poll stdin, dispatch each envelope on the worker, and run the + * caller's looper pump between reads so SDK main-thread tasks make progress. + */ + public static void servePumping(Application app, Runnable pump) { + System.err.println("[cp-seat] android-client-sdk seat starting (pumping loop)"); + try { + new SeatServe().runPumping(app, pump); + } catch (Throwable t) { + System.err.println("[cp-seat] fatal: " + t); + t.printStackTrace(); + System.exit(1); + } + } + + /** @deprecated single-thread serve() stalls the shadow main looper; prefer {@link #servePumping}. */ + @Deprecated + public static void serve(Application app) { + servePumping(app, () -> {}); + } + + void runPumping(Application app, Runnable pump) throws IOException { + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + Bus bus = new Bus(out); + currentBus = bus; + if (callbackBus == null) { + callbackBus = new CpCallbackBus(bus); + } + try { + runPumpingLoop(app, in, bus, pump); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("[cp-seat] pump interrupted; stopping"); + stopAll(); + System.exit(0); + } + } + + void runPumpingLoop(Application app, BufferedReader in, Bus bus, Runnable pump) + throws IOException, InterruptedException { + while (true) { + if (in.ready()) { + String line = in.readLine(); + if (line != null && line.contains("\"op\":\"callback\"")) System.err.println("[cp-seat] seat read reply line: " + line.substring(0, Math.min(60, line.length()))); + if (line == null) { + // stdin closed == stop (CP-SPEC 4.5) + System.err.println("[cp-seat] stdin closed; stopping"); + worker.submit(() -> { stopAll(); worker.shutdown(); }); + return; + } + handleLine(bus, app, line); + } + pump.run(); + // 1 ms pacing keeps the poll cheap; CP op timeouts (>=10 s) are never at risk + Thread.sleep(1); + } + } + + void handleLine(Bus bus, Application app, String line) { + JsonObject env; + try { + env = gson.fromJson(line, JsonObject.class); + } catch (Exception e) { + System.err.println("[cp-seat] unparseable envelope, aborting session: " + e); + System.exit(1); + return; + } + String op = str(env, "op"); + JsonElement idEl = env.has("id") ? env.get("id") : JsonNull.INSTANCE; + JsonElement bodyEl = env.has("body") ? env.get("body") : JsonNull.INSTANCE; + // The runner's reply to one of MY callback envelopes (CP-SPEC 7.2): echo id, + // carries ok/status — consumed by the awaiting SDK-side hook call on the worker. + if ("callback".equals(op)) { + if (callbackBus == null || !callbackBus.onReply(env)) { + System.err.println("[cp-seat] callback reply matched no pending callback: id=" + + (idEl.isJsonNull() ? "null" : idEl)); + } + return; + } + if ("createClient".equals(op)) { + dispatchCreateClient(bus, app, idEl, bodyEl); + } else if ("command".equals(op)) { + dispatchCommand(bus, idEl, bodyEl); + } else if ("closeClient".equals(op)) { + dispatchCloseClient(bus, idEl, bodyEl); + } else if ("status".equals(op)) { + bus.ok(idEl, op, statusResultBody()); + } else if ("stop".equals(op)) { + bus.ok(idEl, op, JsonNull.INSTANCE); + stopAll(); + System.exit(0); + } else { + bus.err(idEl, op == null ? "null" : op, 400, "unknown op"); + } + } + + /** + * Posts a flag-change listener notification (service_spec L745, payload + * {listenerId, flagKey} only — no old/new value for the general listener). + * The SDK's main thread never blocks on the callback round-trip: the + * envelope leaves the seat's own callback poster thread (a notification + * completes no SDK-side call, so CP-SPEC 7.2's completion rule does not + * gate the poster). + */ + public static void postListenerNotification(java.net.URI callbackUri, + String listenerId, String flagKey) { + CpCallbackBus cb = callbackBus; + if (cb == null) { + throw new IllegalStateException("cp-callback requested before the seat bus was armed"); + } + JsonObject payload = new JsonObject(); + payload.addProperty("listenerId", listenerId); + payload.addProperty("flagKey", flagKey); + CALLBACK_POSTS.submit(() -> cb.post(callbackUri.getHost(), callbackUri, null, payload)); + } + + /** The notification poster's own single-thread executor (daemon; posts are + * fire-and-forget from the caller's thread). */ + private static final ExecutorService CALLBACK_POSTS = + Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "seat-callback-posts"); + t.setDaemon(true); + return t; + }); + + /** Static hook the harness adapter calls for cp-callback:// URIs (worker thread). */ + public static void postCallback(URI uri, Object params) { + CpCallbackBus cb = callbackBus; + if (cb == null) { + throw new IllegalStateException("cp-callback requested before the seat bus was armed"); + } + cb.post("hook", uri, null, HookPayloadBuilder.build(params)); + } + + static JsonElement callbackReplyTarget() { // kept package-visible for unit probes + return JsonNull.INSTANCE; + } + + void dispatchCreateClient(Bus bus, Application app, JsonElement idEl, JsonElement bodyEl) { // Robolectric boots apps on one shadow looper; task execution happens on the SDK's + // own executors. Dispatch sequentially on the worker so per-client state is ordered. + worker.submit(() -> { + try { + Representations.CreateInstanceParams params = gson.fromJson(bodyEl, Representations.CreateInstanceParams.class); + String clientId = "c" + clientCounter.incrementAndGet(); + SdkClientEntity entity = new SdkClientEntity(app, params, seatLogAdapter()); + clients.put(clientId, entity); + JsonObject resp = new JsonObject(); + resp.addProperty("client", clientId); + bus.ok(idEl, "createClient", resp); + } catch (Throwable t) { + System.err.println("[cp-seat] createClient failed: " + t); + t.printStackTrace(); + bus.err(idEl, "createClient", 500, describe(t)); + } + }); + } + + /** Seat-side stderr logging for honest causal faces (never on stdout; CP-SPEC 5.1). + * The chain carries the x-cp-logs capture face (CP-SPEC 10): every record both + * prints to stderr and joins the drain ring; the drain command owns its own ring. */ + private static final LogCapture LOG_CAPTURE = new LogCapture(2000); + + static com.launchdarkly.logging.LDLogAdapter seatLogAdapter() { + return new LogCapture.CaptureLogAdapter( + com.launchdarkly.logging.Logs.level( + com.launchdarkly.logging.Logs.toStream(System.err), + com.launchdarkly.logging.LDLogLevel.DEBUG), + LOG_CAPTURE); + } + + void dispatchCommand(Bus bus, JsonElement idEl, JsonElement bodyEl) { + worker.submit(() -> { + try { + JsonObject b = bodyEl.getAsJsonObject(); + String clientId = str(b, "client"); + JsonElement req = b.get("request"); + Representations.CommandParams cmd = gson.fromJson(req, Representations.CommandParams.class); + // CP-SPEC 10: the x-cp-logs drain face is seat-level (the ring carries + // every SDK log since boot, whatever client rode it); it rides before + // the client-bound dispatch like its harness counterparts. + if ("x-cp-logs".equals(cmd.command)) { + Representations.XcpLogsParams logsParams = cmd.xcpLogs; + String action = logsParams == null ? null : logsParams.action; + if (!"drain".equals(action)) { + bus.err(idEl, "command", 400, "x-cp-logs action " + String.valueOf(action)); + return; + } + JsonObject reply = new JsonObject(); + JsonArray recs = new JsonArray(); + for (java.util.Map row : LOG_CAPTURE.drain()) { + recs.add(gson.toJsonTree(row)); + } + reply.add("records", recs); + bus.ok(idEl, "command", reply); + return; + } + SdkClientEntity entity = clients.get(clientId); + if (entity == null) { + bus.err(idEl, "command", 404, "unknown client id"); + return; + } + Object result = entity.doCommand(cmd); + JsonElement bodyEl2 = result == null ? JsonNull.INSTANCE : gson.toJsonTree(result); + bus.ok(idEl, "command", bodyEl2); + } catch (Throwable t) { + System.err.println("[cp-seat] command failed: " + t); + t.printStackTrace(); + bus.err(idEl, "command", 500, describe(t)); + } + }); + } + + void dispatchCloseClient(Bus bus, JsonElement idEl, JsonElement bodyEl) { + worker.submit(() -> { + try { + JsonObject b = bodyEl.getAsJsonObject(); + String clientId = str(b, "client"); + SdkClientEntity entity = clients.remove(clientId); + if (entity == null) { + bus.err(idEl, "closeClient", 404, "unknown client id"); + return; + } + entity.close(); + // CP-SPEC 4.4: the closeClient response is an EMPTY 2xx on success + // (body null) — a nested {closed,ok} envelope prices cp.schema invalid. + bus.ok(idEl, "closeClient", JsonNull.INSTANCE); + } catch (Throwable t) { + System.err.println("[cp-seat] closeClient failed: " + t); + t.printStackTrace(); + bus.err(idEl, "closeClient", 500, describe(t)); + } + }); + } + + void stopAll() { + for (String id : clients.keySet()) { + SdkClientEntity c = clients.remove(id); + if (c != null) { + try { c.close(); } catch (Throwable t) { System.err.println("[cp-seat] close " + id + ": " + t); } + } + } + } + + static JsonObject statusResultBody() { + JsonObject o = new JsonObject(); + o.addProperty("name", "android-client-sdk-seat"); + o.addProperty("clientVersion", com.launchdarkly.sdk.android.BuildConfig.VERSION_NAME); + JsonArray caps = new JsonArray(); + for (String c : CAPABILITIES) caps.add(new JsonPrimitive(c)); + o.add("capabilities", caps); + return o; + } + + static String str(JsonObject o, String k) { + if (o == null || !o.has(k) || o.get(k) == null || o.get(k).isJsonNull()) return null; + return o.get(k).getAsString(); + } + + static Boolean asBoolean(JsonElement el) { + if (el == null || el.isJsonNull()) return null; + if (el.isJsonPrimitive()) { + try { + return el.getAsBoolean(); + } catch (Exception e) { + return null; + } + } + return null; + } + + static String describe(Throwable t) { + String m = t.getMessage(); + return t.getClass().getSimpleName() + ": " + (m == null ? "(no message)" : m); + } + + /** CP envelope writer. stdout = ONLY CP lines, flushed per line. */ + static final class Bus { + final PrintStream out; + Bus(PrintStream out) { this.out = out; } + + void ok(JsonElement idEl, String op, JsonElement bodyEl) { + out.println(envelope(idEl, op, true, 200, bodyEl, null)); + out.flush(); + } + + /** + * Service→runner callback (CP-SPEC §7). Message shape (not a response!): + * the message defs require the base four keys and no reply fields, so this + * writes cp/id/op/body and nothing else; the runner answers on stdin with + * its own envelope echoing the id (which carries ok/status). + */ + void callback(int id, String channel, String uri, String path, JsonElement payload) { + JsonObject e = new JsonObject(); + e.addProperty("cp", "0"); + e.addProperty("id", id); + e.addProperty("op", "callback"); + JsonObject b = new JsonObject(); + b.addProperty("channel", channel); + if (uri != null) b.addProperty("uri", uri); else b.add("uri", JsonNull.INSTANCE); + if (path != null) b.addProperty("path", path); else b.add("path", JsonNull.INSTANCE); + b.add("payload", payload == null ? JsonNull.INSTANCE : payload); + e.add("body", b); + out.println(envGson.toJson(e)); + out.flush(); + } + + void err(JsonElement idEl, String op, int status, String error) { + out.println(envelope(idEl, op, false, status, JsonNull.INSTANCE, error)); + out.flush(); + } + + String envelope(JsonElement idEl, String op, boolean ok, int status, JsonElement bodyEl, String error) { + JsonObject e = new JsonObject(); + e.addProperty("cp", "0"); + e.add("id", idEl == null ? JsonNull.INSTANCE : idEl); + if (op == null) e.add("op", JsonNull.INSTANCE); else e.addProperty("op", op); + e.addProperty("ok", ok); + e.addProperty("status", status); + e.add("body", bodyEl == null ? JsonNull.INSTANCE : bodyEl); + // cp.schema $defs/response requires these seven keys exactly + // (additionalProperties false; "error" present, null on success). + // envGson serializes explicit nulls; the standard gson omits them, which would + // break the seven-key required law for every envelope. + e.add("error", ok ? JsonNull.INSTANCE : new com.google.gson.JsonPrimitive(error)); + return envGson.toJson(e); + } + } + + static final com.google.gson.Gson envGson = new com.google.gson.GsonBuilder().serializeNulls().create(); +} diff --git a/tools/seat-test/Seat.java b/tools/seat-test/Seat.java new file mode 100644 index 000000000..c0e810377 --- /dev/null +++ b/tools/seat-test/Seat.java @@ -0,0 +1,16 @@ +import java.security.Security; +import java.security.Provider; + +/** + * CP/stdio seat bootstrap: boots Robolectric, then runs SeatServeMain.serve() forever. + * JVM-side driver for the LaunchDarkly Android client SDK conformance seat. + */ +public class Seat { + public static void main(String[] args) throws Exception { + Security.addProvider(new Provider( + "Conscrypt", "1.1-stub", "stub provider (no aarch64 native on this host)") {}); + System.out.println("SEAT: stub Conscrypt provider registered"); + String[] junitArgs = args.length > 0 ? args : new String[]{"SeatSmoke"}; + org.junit.runner.JUnitCore.main(junitArgs); + } +} diff --git a/tools/seat-test/SeatInit.java b/tools/seat-test/SeatInit.java new file mode 100644 index 000000000..7ae96f59f --- /dev/null +++ b/tools/seat-test/SeatInit.java @@ -0,0 +1,39 @@ +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.Components; +import com.launchdarkly.sdk.android.LDClient; +import com.launchdarkly.sdk.android.LDConfig; +import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; + +import java.util.concurrent.Future; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34, manifest = Config.NONE) +public class SeatInit { + @Test + public void initOnPolling() throws Exception { + android.app.Application app = (android.app.Application) RuntimeEnvironment.getApplication(); + LDConfig.Builder b = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled); + b.mobileKey("mob-real-key-here"); + b.dataSource(Components.pollingDataSource().pollIntervalMillis(50)); + b.serviceEndpoints(Components.serviceEndpoints().polling("http://127.0.0.1:18099").events("http://127.0.0.1:18099")); + b.diagnosticOptOut(true); + LDContext ctx = LDContext.builder("u1").build(); + Future f = LDClient.init(app, b.build(), ctx); + LDClient c = f.get(); + assertTrue(c.isInitialized()); + assertEquals("on", c.stringVariation("a-flag", "off")); + System.out.println("SEAT-INIT-OK initialized=" + c.isInitialized() + " flag=" + c.stringVariation("a-flag", "off") + + " version=" + com.launchdarkly.sdk.android.BuildConfig.VERSION_NAME); + c.close(); + } +} diff --git a/tools/seat-test/SeatRs.java b/tools/seat-test/SeatRs.java new file mode 100644 index 000000000..c92ae2ce1 --- /dev/null +++ b/tools/seat-test/SeatRs.java @@ -0,0 +1,32 @@ +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; + +/** + * Robolectric-hosted boot for the CP seat. + * + * Robolectric's paused main looper accepts idle() only from the thread that owns it — + * the JUnit test thread. So this test thread IS the seat pump: it polls stdin non-blocking, + * dispatches each envelope to com.launchdarkly.seat.SeatServe's internal worker queue, + * and drains the shadow main looper between reads. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34, manifest = Config.NONE, application = android.app.Application.class) +public class SeatRs { + @Test + public void hostSeatForever() throws Exception { + System.err.println("SEAT-RS: app booted, serving CP/stdio on stdin"); + com.launchdarkly.seat.SeatServe.servePumping(RuntimeEnvironment.getApplication(), + () -> { + try { + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle(); + } catch (Throwable t) { + // drain failure: record and continue; a stuck looper surfaces as action timeouts + System.err.println("[seat-pump] idle() failed: " + t); + } + }); + } +} diff --git a/tools/seat-test/SeatSmoke.java b/tools/seat-test/SeatSmoke.java new file mode 100644 index 000000000..5695c4bc3 --- /dev/null +++ b/tools/seat-test/SeatSmoke.java @@ -0,0 +1,31 @@ +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import android.content.Context; +import android.content.SharedPreferences; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import static org.junit.Assert.assertEquals; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34, manifest = Config.NONE) +public class SeatSmoke { + @Test + public void bootContextAndSharedPrefs() { + Context ctx = RuntimeEnvironment.getApplication(); + SharedPreferences prefs = ctx.getSharedPreferences("seat", Context.MODE_PRIVATE); + prefs.edit().putString("k", "v").commit(); + assertEquals("v", prefs.getString("k", null)); + System.out.println("SEAT-SMOKE: context=" + ctx.getPackageName() + " prefs-ok"); + } + @Test + public void loopbackHttpSocket() throws Exception { + OkHttpClient client = new OkHttpClient(); + Response resp = client.newCall(new Request.Builder().url("http://127.0.0.1:18099/hello").build()).execute(); + assertEquals("world", resp.body().string()); + System.out.println("SEAT-SMOKE: loopback-http-ok"); + } +} diff --git a/tools/seat-test/mockld.py b/tools/seat-test/mockld.py new file mode 100644 index 000000000..f67379f82 --- /dev/null +++ b/tools/seat-test/mockld.py @@ -0,0 +1,37 @@ +"""Minimal LaunchDarkly mobile wire mock for the SDK init smoke test (loopback only).""" +import http.server +import json + +FLAGS = { + "a-flag": { + "key": "a-flag", "version": 3, "on": True, "value": "on", + "variations": ["off", "on"], "default": 0, + } +} + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + p = self.path.split("?")[0] + try: + if p.startswith("/msdk/evalx/contexts"): + # LD mobile /msdk/evalx responses are a flat map: flagKey -> Flag object + body = json.dumps(FLAGS).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + except Exception: + pass + + def log_message(self, *a): + pass + + +if __name__ == "__main__": + http.server.ThreadingHTTPServer(("127.0.0.1", 18099), Handler).serve_forever() diff --git a/tools/seat-test/run-seat.sh b/tools/seat-test/run-seat.sh new file mode 100755 index 000000000..d7e9fd1c7 --- /dev/null +++ b/tools/seat-test/run-seat.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Build + run the Android SDK / Robolectric smoke harness on a Linux aarch64 JVM host. +# Layout (all tool-local, cleanup-friendly): +# classes/ compiled test classes + stub org/conscrypt (OpenSSLProvider only) + real conscrypt classes unpacked +# stub-src/ the OpenSSLProvider stub source +# extracted-cons/ unpacked org/conscrypt classes from conscrypt-openjdk-uber (signatures dropped) +# CPU: aarch64UserService JDK17 (no conscrypt native exists for linux-aarch64 in any published uber jar). +set -e +SEAT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SEAT_DIR" +JAVA_HOME=/home/termag/termag/tools/jdk-17.0.20.1+1 +UBB=/home/termag/.cache/coursier/v1/https/repo1.maven.org/maven2/org/conscrypt/conscrypt-openjdk-uber/2.5.2/conscrypt-openjdk-uber-2.5.2.jar +SDK_JAR=/home/termag/termag/projects/android-client-sdk/sdk.jar +AAR_DIR=/home/termag/termag/tools/aar-extracted + +# 1. unpack real org/conscrypt classes (byte-true, UNSIGNED once out of the jar) +rm -rf extracted-cons && mkdir extracted-cons +unzip -o -q "$UBB" "org/conscrypt/*" -d extracted-cons +cp -r extracted-cons/org/conscrypt classes/org/ + +# 2. compile test classes against sdk.jar + deps (android-all supplies android.* at run time) +NOUBER=$(tr ':' '\n' < /tmp/unified-cp.txt | grep -v "conscrypt-openjdk-uber" | tr '\n' ':' | sed 's/:$//') +SEAT_JARS="$SDK_JAR:$AAR_DIR/core-classes.jar:$AAR_DIR/timber-classes.jar:$AAR_DIR/monitor-classes.jar" +"$JAVA_HOME/bin/javac" -encoding UTF-8 -cp "classes:$SEAT_JARS:$NOUBER" -d classes \ + Seat.java Probe.java SeatInit.java "$@" 2>&1 | grep -i error | head -10 + +# 3. stub OpenSSLProvider wins LAST (bytes over the extracted real one) +cat > extracted-cons/org/conscrypt/OpenSSLProvider.java <<'EOF' +package org.conscrypt; +import java.security.Provider; +/** Stub: Robolectric constructs this inside its sandbox; no conscrypt native exists for aarch64 hosts. */ +public final class OpenSSLProvider extends Provider { + public OpenSSLProvider() { super("Conscrypt", "1.1", "stub (no native)"); } + public OpenSSLProvider(String provName) { super(provName, "1.1", "stub (no native)"); } + public OpenSSLProvider(String provName, double version, String info) { super(provName, "1.1", info); } + public OpenSSLProvider(String provName, String version, String info) { super(provName, version, info); } +} +EOF +"$JAVA_HOME/bin/javac" -cp classes -d classes extracted-cons/org/conscrypt/OpenSSLProvider.java 2>&1 | grep -i error | head -5 + +# 4. run: plain http loopback + LDClient polling init under Robolectric +python3 mockld.py & SRVPID=$!; sleep 2 +"$JAVA_HOME/bin/java" -cp "classes:$SEAT_JARS:$NOUBER" Seat SeatInit 2>&1 | tail -n ${1:-30} +kill $SRVPID 2>/dev/null