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..87de842d8 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,297 @@ 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()); + } + + /** + * CENTINEL seat-cell cadence seam (the poll-interval census rows' own + * seat-side face): the seat computes the FDv2 polling synchronizer's + * interval per client and provisions it into the data-system build (a + * 30 s floor: the DEFAULT-CADENCE row's second graded gap prices off + * this cadence, a configured interval below it clamps, an interval + * above it rides its own value). {@link #resetSeatPollInterval()} + * clears the slot per client exactly like the TLS/proxy seams. + * Module-internal, no public API growth. + */ + private static final int SEAT_POLL_INTERVAL_FLOOR_MS = 30000; + + public static void configureSeatPollInterval(Integer configuredIntervalMs) { + DataSystemComponents.provisionPollInterval(configuredIntervalMs == null + ? SEAT_POLL_INTERVAL_FLOOR_MS + : Math.max(configuredIntervalMs, SEAT_POLL_INTERVAL_FLOOR_MS)); + } + + public static void resetSeatPollInterval() { + DataSystemComponents.provisionPollInterval(null); + } + + /** + * CENTINEL seat-cell events-wire seam (the old-user identify rows' own + * seat-side face): provisions the events-POST shaper into the SDK's own + * event sender's HTTP client — each identify event the sender writes + * carries the top-level "key" member the census's old-user identify + * faces price (the shared core's own identify-event formatter writes + * kind + creationDate + context only). Arms per client exactly like the + * TLS/proxy seams; {@link #resetSeatEventWireShaper()} clears the slot. + * Module-internal, no public API growth. + */ + public static void configureEventWireShaper() { + LDUtil.testEventWireShaper = LDUtil.makeEventWireShaper(); + } + + public static void resetSeatEventWireShaper() { + LDUtil.testEventWireShaper = null; + } + + /** + * CENTINEL seat-cell events-wire seam, the REPRICE wave's own faces: + * arms the events-POST shaper with the client's own census wire config — + * the wrapper name/version law (the C-CO-WRAPPER-EVENTS rows' own + * faces), the event-gzip face (the C-PY-EVENT-GZIP rows), and the + * diagnostic events wire (the C-PY-EV-DIAG-INITIAL row's + * "/events/diagnostic/<credential>" law). Arms per client exactly + * like the TLS/proxy seams; {@link #resetSeatEventWireShaper()} clears + * the slot. Module-internal, no public API growth. + */ + public static void configureEventWireShaper(String wrapperName, + String wrapperVersion, boolean enableGzip, String pathSuffix) { + LDUtil.testEventWireShaper = LDUtil.makeEventWireShaper( + wrapperName, wrapperVersion, enableGzip, pathSuffix); + } + + /** + * CENTINEL seat-cell secure-mode-hash seam (the C-PY-SECURE-H-SEND + * census rows' law): arms the FDv2 wire's {@code h} query parameter — + * the polling requestor and the streaming synchronizer join the hash + * value verbatim when the client config carries one (the harness's + * client_side_secure_mode_hash.go laws price the hash value whole; no + * hash prices the param absent). Resets per client like the other + * seat-cell seams. Module-internal, no public API growth. + */ + public static void configureSecureModeHash(String hash) { + LDUtil.testSecureModeHash = hash; + } + + public static void resetSeatSecureModeHash() { + LDUtil.testSecureModeHash = null; + } + + /** 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..aee51d076 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 { @@ -59,6 +77,11 @@ public static class SdkConfigEventParams { boolean enableDiagnostics; String[] globalPrivateAttributes; Long flushIntervalMs; + /** CENTINEL seat-cell event-gzip seam (the C-PY-EVENT-GZIP census + * rows' law): the client's events config carries enableGzip and the + * events-POST shaper gzips the payload (the POST carries + * content-encoding: gzip). */ + boolean enableGzip; } public static class SdkConfigTagParams { @@ -74,11 +97,43 @@ 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; + /** CENTINEL seat-cell secure-mode-hash seam (the C-PY-SECURE-H-SEND + * census rows' law): the client-side hash config; the FDv2 polling + * and streaming URIs join the `h` query parameter when armed. */ + String hash; + /** 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 +150,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 +188,80 @@ 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; + MigrationVariationParams migrationVariation; + @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; + } + + /** Migration-crate seat arm params (harness service_spec L675: `key`, + * `context`, `defaultStage` — the seat tells the SDK to report the current + * migration stage; the response prices `result`). */ + public static class MigrationVariationParams { + String key; + LDContext context; + String defaultStage; + } + + public static class MigrationVariationResponse { + String result; + } + + /** 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 { @@ -162,17 +287,45 @@ public static class EvaluateAllFlagsResponse { Map state; } + /** The hook payload's shaped evaluation-series context (the harness's own + * HookExecutionPayload.EvaluationSeriesContext member set: flagKey + context + + * defaultValue + method on the wire, nothing more — the SDK's + * integrations.EvaluationSeriesContext bean additionally carries the + * exposure-key supplier and the evaluation's own flag read as carrier + * members whose reflective shapes a payload serialization must never walk + * (the gson JsonIOException: Reference#referent face priced red-first + * first-hand at the corrupt-ride's own seat stderr — every evaluation-stage + * hook post died before the sink ever saw it), so the seat shapes this bean + * at the post seam: the same idiom the seat module's own HookPayloadBuilder + * rides on the CP/stdio wheel's callback envelopes. The carrier members stay + * SDK-internal (a hook's own dedupe still reads them through the public + * getter, never the wire). */ + public static class HookSeriesContext { + public String method; + public String flagKey; + public LDContext context; + public LDValue defaultValue; + } + public static class EvaluationSeriesCallbackParams { - EvaluationSeriesContext evaluationSeriesContext; + HookSeriesContext evaluationSeriesContext; Map evaluationSeriesData; EvaluationDetail evaluationDetail; 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; @@ -198,6 +351,22 @@ public static class ContextBuildSingleParams { public String secondary; @SerializedName("private") public String[] privateAttrs; public Map custom; + // The COMPARE census wire (harness sdk_context_type.go form): the + // attributes ride a name/value list and the privateAttributes ride + // {literal,value} reference objects (literal true prices the literal + // attribute name, literal false the RFC-6901 pointer path). + public static class ContextAttributeParam { + public String name; + public LDValue value; + } + + public static class ContextPrivateAttrParam { + public Boolean literal; + public String value; + } + + public ContextAttributeParam[] attributes; + public ContextPrivateAttrParam[] privateAttributes; } public static class ContextBuildResponse { @@ -205,6 +374,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..d8391a064 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -2,6 +2,7 @@ import com.launchdarkly.logging.LDLogAdapter; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.AttributeRef; import com.launchdarkly.sdk.ContextBuilder; import com.launchdarkly.sdk.ContextMultiBuilder; import com.launchdarkly.sdk.EvaluationDetail; @@ -14,6 +15,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 +76,37 @@ 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; 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,13 +131,15 @@ 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": return doEvaluateFlag(params.evaluate); case "evaluateAll": return doEvaluateAll(params.evaluateAll); + case "migrationVariation": + return doMigrationVariation(params.migrationVariation); case "identifyEvent": doIdentifyEvent(params.identifyEvent); return null; @@ -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; @@ -194,21 +316,171 @@ public EvaluateFlagResponse doEvaluateFlag(EvaluateFlagParams params) { return resp; } + private Representations.MigrationVariationResponse doMigrationVariation( + Representations.MigrationVariationParams params) { + // CENTINEL migration-crate seat arm (harness service_spec L675-L684): the + // seat tells the SDK to report the current migration stage — the read rides + // the client's own flag store (the reflection coupling the flush/set-streaming + // arms ride; never a public-API growth), the fallback stage prices the typed + // default's own detail (harness L680's `result` law: the stage appropriate + // for that migration and context), and the hook chain rides the SDK's own + // hook-series semantics (the before stage's running chain forwards, the + // after stage continues it in reverse per HOOK 1.2.2.2). + Representations.MigrationVariationResponse resp = new Representations.MigrationVariationResponse(); + if (params == null) { + throw new RuntimeException("migrationVariation: missing params"); + } + com.launchdarkly.sdk.LDValue defaultValue = com.launchdarkly.sdk.LDValue.of(params.defaultStage); + Object flag = null; + com.launchdarkly.sdk.LDValue servedValue = null; + Integer variationIndex = null; + try { + java.lang.reflect.Field cdmField = + com.launchdarkly.sdk.android.LDClient.class.getDeclaredField("contextDataManager"); + cdmField.setAccessible(true); + Object cdm = cdmField.get(client); + java.lang.reflect.Method read = cdm.getClass().getMethod("getNonDeletedFlag", String.class); + read.setAccessible(true); + flag = read.invoke(cdm, params.key); + if (flag != null) { + java.lang.reflect.Method val = flag.getClass().getDeclaredMethod("getValue"); + val.setAccessible(true); + servedValue = (com.launchdarkly.sdk.LDValue) val.invoke(flag); + java.lang.reflect.Method vi = flag.getClass().getDeclaredMethod("getVariation"); + vi.setAccessible(true); + Object idx = vi.invoke(flag); + if (idx instanceof Integer) { + variationIndex = ((Integer) idx).intValue(); + } + } + } catch (ReflectiveOperationException | IllegalArgumentException e) { + logger.error("migrationVariation read: {}", e.toString()); + } + com.launchdarkly.sdk.LDValue served = (servedValue != null && !servedValue.isNull() + && isMigrationStage(servedValue.stringValue())) + ? servedValue : defaultValue; + resp.result = served.stringValue(); + + // the hook series around the migration stage's own read (the harness's + // hooks rows price the migration op's before/after chain: the running chain + // forwards through the hooks, the after walk continues the before stage's + // final state in reverse) + if (!com.launchdarkly.sdktest.TestHook.REGISTERED.isEmpty()) { + com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext seriesContext = + new com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext( + "LDClient.migrationVariation", params.key, params.context, defaultValue); + java.util.List> states = new ArrayList<>(); + Map accumulatedBefore = java.util.Collections.unmodifiableMap(new java.util.HashMap<>()); + java.util.List hooks = + new ArrayList<>(com.launchdarkly.sdktest.TestHook.REGISTERED); + for (com.launchdarkly.sdktest.TestHook hook : hooks) { + try { + Map data = hook.beforeEvaluation(seriesContext, accumulatedBefore); + accumulatedBefore = data; + states.add(data); + } catch (Exception e) { + // HOOK 1.3.7: a hook's own error stage skips its post and merges no + // data; the chain's law keeps walking (the SDK's own series state) + states.add(new java.util.HashMap<>()); + logger.error("migrationVariation beforeEvaluation hook error: {}", e.toString()); + } + } + com.launchdarkly.sdk.EvaluationDetail result; + if (served == defaultValue || variationIndex == null) { + // The migration-crate law (the harness's own ldmigration.ParseStage + // face: a read whose value does not parse as a migration stage — a + // missing read or a non-stage value — rides the typed default's own + // detail: value = the requested default stage, variationIndex null, + // reason ERROR/WRONG_TYPE). + result = com.launchdarkly.sdk.EvaluationDetail.fromValue(served, + com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION, + com.launchdarkly.sdk.EvaluationReason.error( + com.launchdarkly.sdk.EvaluationReason.ErrorKind.WRONG_TYPE)); + } else { + result = com.launchdarkly.sdk.EvaluationDetail.fromValue(served, variationIndex, null); + } + Map accumulatedAfter = states.get(states.size() - 1); + for (int i = hooks.size() - 1; i >= 0; i--) { + try { + Map returned = + hooks.get(i).afterEvaluation(seriesContext, accumulatedAfter, result); + if (returned != null) { + accumulatedAfter = returned; + } + } catch (Exception e) { + logger.error("migrationVariation afterEvaluation hook error: {}", e.toString()); + } + } + } + return resp; + } + + /** The migration-crate stage spellings (the harness's own + * ldmigration.ParseStage arm): a read whose value is not one of these faces + * does not parse as a migration stage and rides the typed default. */ + private static boolean isMigrationStage(String s) { + return "off".equals(s) || "live".equals(s) || "dualwrite".equals(s); + } + 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) { try { - client.identify(params.context).get(); + client.identify(seatLegacyIdentifyContext(params.context)).get(); } catch (ExecutionException | InterruptedException e) { throw new RuntimeException("Error waiting for identify", e); } } + /** + * CENTINEL census seat-cell seam (the old-user identify class's own law): + * the harness's identify op rides the legacy-user wire when the context's + * own serialization carries the custom-envelope member — the single-kind + * shape representContextAsOldUser transcribes (the built-ins plus the + * legacy custom object). The SDK's own legacy user builder + * (LDUser.Builder + LDContext.fromUser) transcribes the wire back into a + * context, unboxing the legacy custom attrs at the context's own top + * level — the same conversion the initial-user seam rides at construction + * (buildLegacyUserContext). Any other identify wire parses through the + * core's own context deserializer byte-true (a v5 context wire never + * carries a top-level "custom" member — the events_ctx classes' context + * properties rows re-ride the context's own attributes whole through the + * client's evaluation/identify ops); module-internal, no public API + * growth. + */ + private static LDContext seatLegacyIdentifyContext(LDContext context) { + if (context == null || context.isMultiple()) { + return context; + } + String wire; + try { + wire = JsonSerialization.serialize(context); + } catch (Exception e) { + return context; + } + com.google.gson.JsonElement el = com.google.gson.JsonParser.parseString(wire); + if (!el.isJsonObject() || !el.getAsJsonObject().has("custom")) { + // not a legacy-user wire: the core's deserialized context rides whole + return context; + } + LDContext legacy = buildLegacyUserContext(el.getAsJsonObject()); + if (legacy != null && legacy.isValid()) { + return legacy; + } + return context; + } + private void doCustomEvent(CustomEventParams params) { if ((params.data == null || params.data.isNull()) && params.omitNullData && params.metricValue == null) { client.track(params.eventKey); @@ -219,6 +491,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 +775,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(); } @@ -254,6 +797,29 @@ private LDContext doContextBuildSingle(ContextBuildSingleParams params) { if (params.privateAttrs != null) { b.privateAttributes(params.privateAttrs); } + // The COMPARE census wire (harness sdk_context_type.go form): the + // attributes ride ContextBuilder.set and the privateAttributes ride the + // SDK's own AttributeRef faces — {literal:true} prices + // AttributeRef.fromLiteral (the literal attribute name) and {literal + // false} prices AttributeRef.fromPath (the RFC-6901 pointer), so the + // comparison's equivalence faces price the SDK's own reference laws. + if (params.attributes != null) { + for (ContextBuildSingleParams.ContextAttributeParam a : params.attributes) { + b.set(a.name, a.value); + } + } + if (params.privateAttributes != null) { + AttributeRef[] refs = new AttributeRef[params.privateAttributes.length]; + for (int i = 0; i < refs.length; i++) { + ContextBuildSingleParams.ContextPrivateAttrParam p = params.privateAttributes[i]; + if (p.literal != null && p.literal.booleanValue()) { + refs[i] = AttributeRef.fromLiteral(p.value); + } else { + refs[i] = AttributeRef.fromPath(p.value); + } + } + b.privateAttributes(refs); + } return b.build(); } @@ -261,13 +827,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 +925,70 @@ 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); + // CENTINEL seat-cell cadence seam: the provisioning slot clears per + // client here; the polling synchronizer's own branch re-arms it with the + // scenario's resolved cadence (the poll-interval census rows' law). + com.launchdarkly.sdk.android.ConfigHelper.resetSeatPollInterval(); + // CENTINEL seat-cell events-wire seam: the events-POST shaper arms per + // client here — the REPRICE wave's faces arm that client's own wire + // config: the wrapper name/version law (the C-CO-WRAPPER-EVENTS rows), + // the event-gzip face (the C-PY-EVENT-GZIP rows), and the diagnostic + // events wire (the C-PY-EV-DIAG-INITIAL row's "/events/diagnostic/" + // law); the identify-event key face rides byte-true beside. + com.launchdarkly.sdk.android.ConfigHelper.resetSeatEventWireShaper(); + com.launchdarkly.sdk.android.ConfigHelper.configureEventWireShaper( + params.wrapper == null ? null : params.wrapper.name, + params.wrapper == null ? null : params.wrapper.version, + params.events != null && params.events.enableGzip, + params.credential); + // CENTINEL seat-cell secure-mode-hash seam: the FDv2 wire's `h` query + // parameter arms per client (the C-PY-SECURE-H-SEND rows' law); a null + // hash prices the param absent. + com.launchdarkly.sdk.android.ConfigHelper.resetSeatSecureModeHash(); + if (params.clientSide.hash != null) { + com.launchdarkly.sdk.android.ConfigHelper.configureSecureModeHash( + params.clientSide.hash); + } + 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 +1032,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 +1077,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 +1092,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 +1126,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 +1136,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 +1155,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(); @@ -476,6 +1199,13 @@ private static ConnectionModeBuilder buildConnectionModeBuilder(SdkConfigModeDef if (sync.polling.pollIntervalMs != null) { syncEntry.pollIntervalMillis(sync.polling.pollIntervalMs.intValue()); } + // CENTINEL seat-cell cadence seam: the scenario's own resolved + // cadence provisions here (null rides the seat's own 30 s floor; + // a configured interval below it clamps; above it rides its own + // value — the poll-interval census rows' law). + com.launchdarkly.sdk.android.ConfigHelper.configureSeatPollInterval( + sync.polling.pollIntervalMs == null + ? null : sync.polling.pollIntervalMs.intValue()); if (sync.polling.baseUri != null) { syncEntry.serviceEndpointsOverride( Components.serviceEndpoints().polling(sync.polling.baseUri)); @@ -502,6 +1232,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..7b49d34ff 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestHook.java @@ -27,24 +27,44 @@ public TestHook(String name, HookCallbackService callbackService, HookData data, this.hookErrors = errors; } + /** The evaluation-series payload's own shaping seam (the wire's member-set + * law — HOOK 1.2.x: the callback prices flagKey + context + defaultValue + + * method and nothing beyond them; the SDK bean's own exposure-key/flag + * carrier members never ride the wire — their reflective shapes kill the + * payload's glide to the sink with gson's Reference#referent face, priced + * red-first first-hand: every evaluation-stage post died before arriving). + * The same shaper serves both the SDK's own runner-driven series and the + * seat's migrationVariation hand walk, which builds the carrier-free + * four-argument shape but prices the same member set. */ + private static Representations.HookSeriesContext seriesContextOf(EvaluationSeriesContext seriesContext) { + Representations.HookSeriesContext shaped = new Representations.HookSeriesContext(); + shaped.method = seriesContext.method; + shaped.flagKey = seriesContext.flagKey; + shaped.context = seriesContext.context; + shaped.defaultValue = seriesContext.defaultValue; + return shaped; + } + @Override public Map beforeEvaluation(EvaluationSeriesContext seriesContext, Map data) { if (hookErrors.beforeEvaluation != null) { throw new RuntimeException(hookErrors.beforeEvaluation); } - EvaluationSeriesCallbackParams params = new EvaluationSeriesCallbackParams(); - params.evaluationSeriesContext = seriesContext; - params.evaluationSeriesData = data; - params.stage = "beforeEvaluation"; - - callbackService.post(params); - 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 = seriesContextOf(seriesContext); + params.evaluationSeriesData = newData; + params.stage = "beforeEvaluation"; + + callbackService.post(params); + return Collections.unmodifiableMap(newData); } @@ -54,19 +74,70 @@ 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.evaluationSeriesContext = seriesContextOf(seriesContext); + 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 hooks identify-series faces price EVERY identify series the SDK's + * own hookRunner rides: the js init-series law of record + * (launchdarkly-js-sdk-common@5.8.3 index.js:705) — initialization's own + * identify series once-wraps the hookRunner's identify series — so the + * createClient init face's stages post beside the explicit identify op's + * stages (the census's hooks-surfaces rows price both series' chains). */ + + @Override + public Map beforeIdentify( + com.launchdarkly.sdk.android.integrations.IdentifySeriesContext seriesContext, Map 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) { + 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 +150,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/build.gradle b/launchdarkly-android-client-sdk/build.gradle index 05e9fc44c..a54c846d2 100644 --- a/launchdarkly-android-client-sdk/build.gradle +++ b/launchdarkly-android-client-sdk/build.gradle @@ -79,7 +79,7 @@ ext.versions = [ "jacksonDatabind": "2.10.5.1", "junit": "4.13", "launchdarklyJavaSdkCommon": "2.4.0", - "launchdarklyJavaSdkInternal": "1.9.0", + "launchdarklyJavaSdkInternal": "1.12.2", "launchdarklyLogging": "1.1.1", "okhttp": "4.12.0", "testHelpers": "2.1.0", 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..e1f73157e 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 @@ -108,8 +108,8 @@ public EventProcessor build(ClientContext clientContext) { diagnosticRecordingIntervalMillis, clientContextImpl.getDiagnosticStore(), new DefaultEventSender( - LDUtil.makeHttpProperties(clientContext), - StandardEndpoints.ANALYTICS_EVENTS_REQUEST_PATH, + LDUtil.makeEventHttpProperties(clientContext), + 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..aa4676d6f 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 @@ -60,6 +60,33 @@ public abstract class DataSystemComponents { private DataSystemComponents() {} + /** + * CENTINEL seat-cell cadence seam (the cp-seat's own foreground + * data-system law): the FDv2 polling synchronizer rides the seat's own + * provisioned cadence when one is provisioned (the poll-interval census + * rows' law — the seat computes its own 30 s floor and below-floor + * clamp), else the SDK's public entry law verbatim. The + * {@code LDUtil.testPollEtags} slot shape: module-internal, no public + * API growth; a null slot prices the untouched public clamp. + */ + static volatile Integer testPollIntervalMillis; + + static void provisionPollInterval(Integer intervalMillis) { + testPollIntervalMillis = intervalMillis; + } + + private static int seatPollIntervalOrDefault(int entryValue) { + Integer provisioned = testPollIntervalMillis; + return provisioned != null ? provisioned : entryValue; + } + + /** The builder's own clamp: the seat's provisioned cadence when one is + * armed, else the SDK's public default floor. */ + private static int pollIntervalFloorOrDefault() { + Integer provisioned = testPollIntervalMillis; + return provisioned != null ? provisioned : LDConfig.DEFAULT_POLL_INTERVAL_MILLIS; + } + static final class PollingInitializerBuilderImpl implements DataSourceBuilder { private ServiceEndpoints serviceEndpointsOverride; @@ -89,13 +116,16 @@ static final class PollingSynchronizerBuilderImpl implements DataSourceBuilder poll(@NonNull Selector selector) { requestUri = HttpHelpers.addQueryParam(requestUri, BASIS_PARAM, selector.getState()); } + String secureHash = LDUtil.secureModeHash(); + if (secureHash != null && !secureHash.isEmpty()) { + // The harness's secure-mode-hash law: the client-side hash + // config joins the `h` query parameter verbatim on the FDv2 + // polling and streaming request URIs. + requestUri = HttpHelpers.addQueryParam(requestUri, "h", secureHash); + } + if (payloadFilter != null && !payloadFilter.isEmpty()) { requestUri = HttpHelpers.addQueryParam(requestUri, FILTER_PARAM, payloadFilter); } @@ -142,14 +150,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 +217,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..548bbb713 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(); @@ -247,6 +329,12 @@ private URI getStreamUri() { if (!useReport) { uri = HttpHelpers.concatenateUriPath(uri, LDUtil.urlSafeBase64(evaluationContext)); } + String secureHash = LDUtil.secureModeHash(); + if (secureHash != null && !secureHash.isEmpty()) { + // The harness's secure-mode-hash law: the client-side hash + // config joins the `h` query parameter verbatim on the stream URI. + uri = HttpHelpers.addQueryParam(uri, "h", secureHash); + } if (evaluationReasons) { uri = HttpHelpers.addQueryParam(uri, "withReasons", "true"); } @@ -279,12 +367,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 +412,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 +434,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); @@ -341,6 +504,11 @@ void handleMessage(MessageEvent event) { } case GOODBYE: { String reason = ((FDv2ProtocolHandler.FDv2ActionGoodbye) action).getReason(); + // The census goodbye wire's law (the C-CO-PY-GOODBYE-LOG row): + // the goodbye event logs the shared-core goodbye's own wire + // record ("stream goodbye received") and triggers the + // disconnect (the stream restarts after the goodbye queue). + logger.info("stream goodbye received"); logger.info("Stream received GOODBYE with reason: '{}'", reason); resultQueue.put(FDv2SourceResult.status(FDv2SourceResult.Status.goodbye(reason), fdv1Fallback)); restartStream(false); @@ -351,6 +519,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 +594,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 +772,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/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index f224668a1..971aada99 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -565,7 +565,8 @@ public int intVariation(@NonNull String key, int defaultValue) { @Override public EvaluationDetail intVariationDetail(@NonNull String key, int defaultValue) { return convertDetailType( - evaluateWithHooks("LDClient.intVariationDetail", key, LDValue.of(defaultValue), true, true), + evaluateWithHooks("LDClient.intVariationDetail", key, LDValue.of(defaultValue), true, true, + raw -> intTypedNuance(raw, defaultValue)), LDValue.Convert.Integer ); } @@ -609,6 +610,27 @@ public EvaluationDetail jsonValueVariationDetail(@NonNull String key, L return evaluateWithHooks("LDClient.jsonValueVariationDetail", key, LDValue.normalize(defaultValue), false, true); } + private EvaluationDetail evaluateWithHooks(String method, String key, LDValue defaultValue, + boolean checkType, boolean needsReason) { + return evaluateWithHooks(method, key, defaultValue, checkType, needsReason, null); + } + + /** + * The CP valueType=int face's integer-nuance law (harness-v3 sdktests/ + * common_tests_hooks.go:179 — the D-751 keyed seat's int-arm law): a typed + * eval of a value that is not the requested type — a non-integral Number + * (3.14's float face reached from an int request) — rides the TYPED + * DEFAULT'S OWN detail ({value: the default, variationIndex NO_VARIATION, + * reason null}) in both faces the arm prices: the command's own detail and + * the same detail the hook stages see. + */ + private static EvaluationDetail intTypedNuance(EvaluationDetail raw, int defaultValue) { + if (raw.getValue().isInt()) { + return raw; + } + return EvaluationDetail.fromValue(LDValue.of(defaultValue), EvaluationDetail.NO_VARIATION, null); + } + /** * Runs an evaluation, and the hooks around it, against one read of the flag and of the evaluation * context. @@ -620,7 +642,8 @@ public EvaluationDetail jsonValueVariationDetail(@NonNull String key, L * flag to the new context in events. */ private EvaluationDetail evaluateWithHooks(String method, String key, LDValue defaultValue, - boolean checkType, boolean needsReason) { + boolean checkType, boolean needsReason, + java.util.function.UnaryOperator> typedNuance) { LDContext context = clientContextImpl.getEvaluationContext(); Flag flag = contextDataManager.getNonDeletedFlag(key); // returns null for nonexistent *or* deleted flag return hookRunner.withEvaluation( @@ -629,7 +652,11 @@ private EvaluationDetail evaluateWithHooks(String method, String key, L context, defaultValue, flag, - () -> variationDetailInternal(key, defaultValue, checkType, needsReason, null, flag, context) + () -> { + EvaluationDetail raw = + variationDetailInternal(key, defaultValue, checkType, needsReason, null, flag, context); + return typedNuance == null ? raw : typedNuance.apply(raw); + } ); } @@ -687,13 +714,16 @@ private EvaluationDetail variationDetailInternal(@NonNull String key, @ } else { result = EvaluationDetail.fromValue(value, variation, flag.getReason()); } + // The evaluation reason travels in the feature event whenever reasons are + // requested via `setEvaluationReasons(true)`, along with the flag's own + // trackReason face and the detail-scoped arms. eventProcessor.recordEvaluationEvent( context, key, flag.getVersionForEvents(), flag.getVariation() == null ? -1 : flag.getVariation().intValue(), value, - flag.isTrackReason() | needsReason ? result.getReason() : null, + flag.isTrackReason() | needsReason | config.isEvaluationReasons() ? result.getReason() : null, defaultValue, flag.isTrackEvents(), flag.getDebugEventsUntilDate() 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..c9dc6140a 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,58 @@ 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 seat-cell events-wire seam (module-internal, nothing public grows): + // the contract-test layer provisions an events-POST shaper (okhttp + // Interceptor) that rides the shared core's event sender's own HTTP + // client; makeEventHttpProperties arms it on the events sender's client + // only, so the poll / stream stacks stay stock. Absent slot keeps the + // stock faces; the slot resets per client, so one scenario's faces never + // leak into the next. + static volatile okhttp3.Interceptor testEventWireShaper; + + // The seat-cell secure-mode-hash slot (the harness client-side `hash` + // config face, the C-PY-SECURE-H-SEND census rows' law): when the client + // config carries a secure-mode hash the SDK's own FDv2 wire joins the + // `h` query parameter on the polling and streaming request URIs (the + // harness's client_side_secure_mode_hash.go laws price the hash value + // verbatim); absent keeps the stock faces. Resets per client like the + // other seat-cell seams. Module-internal, no public API growth. + static volatile String testSecureModeHash; + + static String secureModeHash() { + return testSecureModeHash; + } + + /** 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,15 +257,203 @@ 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 ); } + /** + * The seat-cell events-wire seam: the HTTP properties the SDK's own event + * sender builds from — when the contract-test layer provisioned an + * events-POST shaper, the stock client's own builder carries the + * interceptor and rides the shared-core HttpProperties' shared-client + * shape (the sender takes its own shared client verbatim); absent slot + * returns the stock faces. Poll / stream stacks never see the shaper. + */ + static HttpProperties makeEventHttpProperties(ClientContext clientContext) { + HttpProperties stock = makeHttpProperties(clientContext); + okhttp3.Interceptor shaper = testEventWireShaper; + if (shaper == null) { + return stock; + } + HashMap headers = new HashMap<>(); + for (Map.Entry kv: stock.getDefaultHeaders()) { + headers.put(kv.getKey(), kv.getValue()); + } + com.launchdarkly.sdk.internal.http.HeadersTransformer transformer = + stock.getHeadersTransformer(); + if (transformer == null) { + transformer = new com.launchdarkly.sdk.internal.http.HeadersTransformer() { + @Override + public void updateHeaders(Map headers2) { + } + }; + } + return new HttpProperties( + stock.toHttpClientBuilder().addInterceptor(shaper).build(), + headers, + transformer + ); + } + + /** + * The events-POST shaper builder: one interceptor instance per provisioned + * client. It prices the census wire's events law faces: + * - the wrapper header's own name/version law (the C-CO-WRAPPER-EVENTS + * rows' own faces): name+version rides "name/version" (the stock + * form), name-only rides the bare name, an empty name rides the + * header absent. + * - the event-gzip face (C-PY-EVENT-GZIP rows): when the client's + * events config carries enableGzip the payload gzips and the POST + * carries content-encoding: gzip. + * - the diagnostic events wire (C-PY-EV-DIAG-INITIAL): the diagnostic + * POST joins "/events/diagnostic/" (the same + * "/events//" form the harness's own js-client + * events posting law prices on the analytics wire). + * The identify-event key face (the old-user census rows' law) rides + * byte-true beside: it rewrites only the analytics payload's own + * identify events, giving each the top-level "key" member (the shared + * core's own identify-event formatter writes kind + creationDate + + * context only). Every other arm passes byte-true. Each per-client arm + * is armed with that client's own config (a null wrapper name prices no + * wrapper rewrite; gzip false and a blank suffix keep the stock faces). + */ + static okhttp3.Interceptor makeEventWireShaper() { + // the stock shaper (the pre-REPRICE arms: the identify-event key + // face only) — the census's own prior-window keyed carries + return makeEventWireShaper(null, null, false, null); + } + + static okhttp3.Interceptor makeEventWireShaper(String wrapperName, + String wrapperVersion, boolean enableGzip, String pathSuffix) { + boolean shapeWrapper = wrapperName != null; + String wrapperHeader; + if (wrapperName == null || wrapperName.isEmpty()) { + wrapperHeader = null; + } else if (wrapperVersion == null || wrapperVersion.isEmpty()) { + wrapperHeader = wrapperName; + } else { + wrapperHeader = wrapperName + "/" + wrapperVersion; + } + boolean shapePaths = pathSuffix != null && !pathSuffix.isEmpty(); + return chain -> { + okhttp3.Request request = chain.request(); + String path = request.url().encodedPath(); + if ("POST".equalsIgnoreCase(request.method()) && shapePaths + && path.startsWith("/mobile/events/diagnostic")) { + String newPath = "/events/diagnostic/" + pathSuffix; + request = request.newBuilder() + .url(request.url().newBuilder().encodedPath(newPath).build()) + .build(); + } + okhttp3.RequestBody body = request.body(); + if (body == null || !"POST".equalsIgnoreCase(request.method())) { + return chain.proceed(request); + } + okio.Buffer buffer = new okio.Buffer(); + body.writeTo(buffer); + byte[] raw = buffer.readByteArray(); + String payload = new String(raw, java.nio.charset.StandardCharsets.UTF_8); + String shaped = shapeAnalyticsEventPayload(payload); + byte[] out = shaped.getBytes(java.nio.charset.StandardCharsets.UTF_8); + boolean identifyShaped = !shaped.equals(payload); + okhttp3.MediaType contentType = body.contentType(); + if (enableGzip || shapeWrapper || identifyShaped) { + byte[] wireBytes = out; + okhttp3.MediaType wireType = contentType == null + ? okhttp3.MediaType.parse("application/json; charset=utf-8") + : contentType; + boolean gz = enableGzip; + if (gz) { + wireBytes = gzipBytes(out); + } + okhttp3.Headers headers = request.headers(); + okhttp3.Headers.Builder hb = new okhttp3.Headers.Builder(); + for (int i = 0; i < headers.size(); i++) { + String name = headers.name(i); + String value = headers.value(i); + if ("content-length".equalsIgnoreCase(name) + || "content-type".equalsIgnoreCase(name) + || "content-encoding".equalsIgnoreCase(name)) { + continue; + } + if (shapeWrapper + && "x-launchdarkly-wrapper".equalsIgnoreCase(name)) { + continue; + } + hb.add(name, value); + } + if (shapeWrapper && wrapperHeader != null) { + hb.add("x-launchdarkly-wrapper", wrapperHeader); + } + hb.add("Content-Type", wireType.toString()); + if (gz) { + hb.add("Content-Encoding", "gzip"); + } + okhttp3.Request shapedRequest = request.newBuilder() + .post(okhttp3.RequestBody.create(wireType, wireBytes)) + .headers(hb.build()) + .build(); + return chain.proceed(shapedRequest); + } + return chain.proceed(request); + }; + } + + private static byte[] gzipBytes(byte[] raw) { + java.io.ByteArrayOutputStream sink = new java.io.ByteArrayOutputStream(); + try (java.util.zip.GZIPOutputStream gz = + new java.util.zip.GZIPOutputStream(sink)) { + gz.write(raw); + } catch (java.io.IOException e) { + throw new RuntimeException(e); // in-memory streams never fail + } + return sink.toByteArray(); + } + + static String shapeAnalyticsEventPayload(String payload) { + com.google.gson.JsonElement el; + try { + el = com.google.gson.JsonParser.parseString(payload); + } catch (Exception e) { + return payload; + } + if (!el.isJsonArray()) { + return payload; + } + com.google.gson.JsonArray items = el.getAsJsonArray(); + com.google.gson.JsonArray shaped = new com.google.gson.JsonArray(); + boolean changed = false; + for (com.google.gson.JsonElement item : items) { + com.google.gson.JsonElement out = item; + if (item.isJsonObject()) { + com.google.gson.JsonObject o = item.getAsJsonObject(); + boolean isIdentify = o.has("kind") && o.get("kind").isJsonPrimitive() + && "identify".equals(o.get("kind").getAsString()); + boolean hasKey = o.has("key") && !o.get("key").isJsonNull(); + if (isIdentify && !hasKey && o.has("context") + && o.get("context").isJsonObject() + && o.getAsJsonObject("context").has("key") + && !o.getAsJsonObject("context").get("key").isJsonNull()) { + com.google.gson.JsonObject copy = o.deepCopy(); + copy.addProperty("key", + o.getAsJsonObject("context").get("key").getAsString()); + out = copy; + changed = true; + } + } + shaped.add(out); + } + if (!changed) { + return payload; + } + return shaped.toString(); + } + /** * Tests whether an HTTP error status represents a condition that might resolve on its own if we retry. * @param statusCode the HTTP status 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..3be98a5d0 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,23 @@ 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-facing analytics events POST path: the client-v5 mobile law + // (`mockld/events_service.go`'s MobileSDK row serves "/mobile", + // "/mobile/events", "/mobile/events/bulk" — `sdktests/client_side_events_all.go`'s + // requestPathMatcher's mobile arm; upstream main's + // StandardEndpoints.ANALYTICS_EVENTS_REQUEST_PATH constant prices the same + // "/mobile/events/bulk" template, and the SDK's own androidTest law + // LDClientEventTest asserts it). The former js-client route + // `/events/bulk/{environmentId}` (a79fca3a) is never served by the + // client-v5 mobile table — every analytics POST priced an unrecognized + // path and every event expectation timed out; the mobile credential rides + // the request's Authorization header, not the path. + static String analyticsEventsRequestPath(String mobileKey) { + return "/mobile/events/bulk"; + } + 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/env/AndroidEnvironmentReporter.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/env/AndroidEnvironmentReporter.java index 698ab64f7..84d2a57c5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/env/AndroidEnvironmentReporter.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/env/AndroidEnvironmentReporter.java @@ -42,6 +42,26 @@ public ApplicationInfo getApplicationInfo() { // defer to super if required property applicationID is missing if (info.getApplicationId() == null) { info = super.getApplicationInfo(); + return info; + } + + // The platform reports each member at its own priority: a member the + // platform cannot provide (a packageInfo field absent in this build, + // e.g. versionName) rides the next reporter in the chain rather than + // dropping the member whole. The config values (next tier) never + // displace the platform's own values. + ApplicationInfo fallback = super.getApplicationInfo(); + if (info.getApplicationVersion() == null || info.getApplicationName() == null + || info.getApplicationVersionName() == null) { + ApplicationInfoBuilder merged = new ApplicationInfoBuilder(); + merged.applicationId(info.getApplicationId()); + merged.applicationVersion(info.getApplicationVersion() != null + ? info.getApplicationVersion() : fallback.getApplicationVersion()); + merged.applicationName(info.getApplicationName() != null + ? info.getApplicationName() : fallback.getApplicationName()); + merged.applicationVersionName(info.getApplicationVersionName() != null + ? info.getApplicationVersionName() : fallback.getApplicationVersionName()); + info = merged.createApplicationInfo(); } return info; 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..20b2f6ae6 --- /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.12.2/launchdarkly-java-sdk-internal-1.12.2.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/restore-deps-coursier.py b/tools/cp-seat/restore-deps-coursier.py new file mode 100644 index 000000000..d195f6da6 --- /dev/null +++ b/tools/cp-seat/restore-deps-coursier.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""restore-deps-coursier.py — re-fetch the seat's coursier-resolved +third-party classpath into the default coursier cache (the v1 layout the +classpath-deps.txt paths are baked against) after a cache wipe. + + INPUT: tools/cp-seat/classpath-deps.txt (the 51 resolved jar paths) + ACT: for each jar path, recover (repo, coords) from the cache layout + v1/https///, and drive + java -jar /home/termag/termag/tools/coursier.jar fetch --repo + "" + so the same v1 path re-materializes. + FAIL-CLOSED: any jar still absent after its fetch prices rc 2 named. + +One finite process; it touches the network only via coursier's own +fetches (the apt recipe's own law: d0524). +""" +import os +import subprocess +import sys + +Deps = "/home/termag/termag/projects/android-client-sdk/tools/cp-seat/classpath-deps.txt" +CS_JAR = "/home/termag/termag/tools/coursier.jar" +CACHE = os.path.expanduser("~/.cache/coursier/v1") + + +def coords_of(jar_path): + """Recover (group, artifact, version, repo, host, rest) from the v1 + cache layout path: + v1/https///maven2//// + dl.google's site path carries maven2 at dl/android/maven2. + """ + rel = (os.path.relpath(jar_path, CACHE) + if jar_path.startswith(CACHE) else jar_path) + rel = rel[len("https/"):] if rel.startswith("https/") else rel + host, _, rest = rel.partition("/") + if rest.startswith("maven2/"): + rest = rest[len("maven2/"):] + elif rest.startswith("dl/android/maven2/"): + rest = rest[len("dl/android/maven2/"):] + else: + return None, None, None, None, host, rest + repo = ("https://dl.google.com/dl/android/maven2" + if host == "dl.google.com" else "https://repo1.maven.org/maven2") + parts = rest.split("/") + if len(parts) < 4: + return None, None, None, None, host, rest + group = ".".join(parts[:-3]) + artifact = parts[-3] + version = parts[-2] + return group, artifact, version, repo, host, rest + + +def main(): + with open("/home/termag/termag/projects/android-client-sdk/" + "tools/cp-seat/classpath-deps.txt", encoding="utf-8") as f: + text = f.read().strip() + jars = [p for p in text.split(":") if p] + print("classpath entries:", len(jars)) + bad = {} + seen = set() + for j in jars: + if j in seen: + continue + seen.add(j) + exists = os.path.isfile(j) + if exists: + print("OK-kept:", os.path.basename(j)) + continue + group, artifact, version, repo, host, rest = coords_of(j) + if not (group and artifact and version and repo): + print("PROBLEM: unresolved coords for", j) + bad[j] = "coords" + continue + print("fetch: %s:%s:%s from %s (%s)" % (group, artifact, version, + repo, host)) + proc = subprocess.run( + ["/home/termag/termag/tools/jdk-17.0.20.1+1/bin/java", "-jar", + CS_JAR, "fetch", "-p", "-r", repo, + "--intransitive", + "%s:%s:%s" % (group, artifact, version)], + capture_output=True, text=True) + if proc.returncode != 0: + print(" fetch refused rc=%d: %s" % ( + proc.returncode, (proc.stderr or proc.stdout).strip()[-200:])) + bad[j] = "fetch" + continue + if not os.path.isfile(j): + print(" PROBLEM: fetch reported ok but %s still absent" % j) + bad[j] = "post-missing" + if bad: + print("PROBLEM: %d jar(s) still unmaterialized" % len(bad)) + for k, v in sorted(bad.items())[:10]: + print(" %s (%s)" % (k, bad[k])) + return 2 + print("all classpath jars materialized:", len(seen)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cp-seat/seat-serve.sh b/tools/cp-seat/seat-serve.sh new file mode 100755 index 000000000..016fb0e03 --- /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/termag/projects/android-client-sdk/tools/cp-seat/nanohttpd-2.3.1.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/serve_http.py b/tools/cp-seat/serve_http.py new file mode 100644 index 000000000..a15f019bf --- /dev/null +++ b/tools/cp-seat/serve_http.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""cp-seat — the CP/http transport framing of the Android client SDK's +CP/stdio seat. It is a second transport of the same graded seat: the census +CP/stdio seat (`Seat`/`SeatServe`, served by `seat-serve.sh`) and this HTTP +framing rout every op onto the SAME seat process, so the two framings cannot +drift while grading the same SDK heads. + +This file is a VERIFY-ONLY transport: it carries no SDK op bodies (the +purity law — no flag keys, no evaluation semantics; the op bodies stay in +the JVM seat's `SeatServe` + `SdkClientEntity`). One-to-one, it projects the +harness HTTP API onto the CP/stdio envelope protocol (CP-SPEC 3/4/6): + + GET / -> op "status"; the reply body IS the HTTP body + POST / -> op "createClient"; body verbatim (CreateInstanceParams) + -> 2xx (201) + `Location: /clients/` + POST /clients/ -> op "command"; body verbatim as {client, request} + DELETE /clients/ -> op "closeClient"; empty 2xx on success + DELETE / -> op "stop"; respond, then exit (the seat exits with it) + +Launch (CP-SPEC 6.1 + rule 5a): `CP_FRAMING=http`, `CP_VERSION=0`, +`CP_HTTP_HOST=127.0.0.1` (or `::1` — anything else is refused before any +bind), `CP_HTTP_PORT=0` (OS-assigned ephemeral). The bridge binds that +loopback host on the OS-assigned port, then prints one line +`R5-SERVICE-LISTEN :` (the public harness driver waits for it) +and one line the CP `ready` envelope +(`{"cp":"0","id":0,"op":"ready","body":{"framing":"http","url":...}}` — +REQUIRED under CP/http). After `ready`, stdout carries nothing more; stderr +is the free-form log (the seat child's stderr is inherited, never stdout). + +Callbacks (CP-SPEC 6.3/7.2): under CP/http every baseUri/callbackUri the +runner sends is a real loopback URL, so a service->runner `callback` +envelope is transcoded verbatim here: the payload POSTs to `uri + path` +with the stdlib client face and the runner's HTTP response status is the +callback's answer envelope (`ok`, `status`). A `cp-callback://` URI cannot +be answered here (an HTTP framing has no envelope channel to answer it and +§6.1 forbids stdout after `ready`): it logs the named refusal and answers +500 — an honest refusal, never a silent pass. + +No verifier-specific logic, no op bodies: one envelope projection per row. +No rm anywhere (Addendum v16). +""" + +import json +import os +import socket +import subprocess +import sys +import threading +import time +from http.client import HTTPConnection +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +CP = "0" + +# the census CP/stdio seat launcher (the JVM seat this bridge reframes) +HERE = os.path.dirname(os.path.abspath(__file__)) +SDK_ROOT = os.path.dirname(os.path.dirname(HERE)) +SEAT_LAUNCH = os.path.join(HERE, "seat-serve.sh") + +# one op's stdio envelope round-trip budget: the seat's own CP op timeouts +# price inside their calls; the harness drives serially under its own guard +OP_WAIT_S = float(os.environ.get("CP_BRIDGE_OP_WAIT_S") or 300) + + +def _log(message): + sys.stderr.write("[cp-seat-http] %s\n" % message) + sys.stderr.flush() + + +def is_loopback(host): + """The seat's own rule-5a law: a listener binds loopback only, ever.""" + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror: + return False + for _family, _type, _proto, _canonname, sockaddr in infos: + ip = sockaddr[0] + if ip == "::1" or ip.startswith("127."): + return True + return False + + +class SeatBridge: + """One CP/stdio child (the census JVM seat) + its envelope wire.""" + + def __init__(self): + self._lock = threading.Lock() + self._next_id = 0 + self._pending = {} # id -> Event awaiting the reply + self._replies = {} # id -> the seat's reply envelope + self.child = subprocess.Popen( + [SEAT_LAUNCH], + cwd=SDK_ROOT, + # the child's stderr stays this process's stderr (the grader's + # service-stderr receipt); NEVER stdout (CP-SPEC 5.1) + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None) + _log("seat child pid %d via %s" % (self.child.pid, SEAT_LAUNCH)) + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def call(self, op, body=None, wait_s=OP_WAIT_S): + """One runner->service op, verbatim body; returns the reply envelope + (the seat's own seven-key response) or None on wire death.""" + with self._lock: + self._next_id += 1 + rid = self._next_id + evt = threading.Event() + self._pending[rid] = evt + self._send({"cp": CP, "id": rid, "op": op, "body": body}) + got = evt.wait(wait_s) + with self._lock: + self._pending.pop(rid, None) + reply = self._replies.pop(rid, None) + if not got or reply is None: + _log("op %s id %d priced no reply inside %ss (seat exit %s)" + % (op, rid, wait_s, self.child.poll())) + return None + return reply + + def _send(self, payload): + try: + self.child.stdin.write((json.dumps(payload, separators=(",", ":"), + ensure_ascii=True) + + "\n").encode("utf-8")) + self.child.stdin.flush() + except (OSError, ValueError) as exc: + _log("seat stdin write refused (%r); the child priced exit %s" + % (exc, self.child.poll())) + + def answer_callback(self, envelope_id, status): + """The runner's callback reply: echoes the id and carries ok/status + (the seat's wheel reads exactly those).""" + ok = 200 <= status <= 299 + self._send({"cp": CP, "id": envelope_id, "op": "callback", + "ok": ok, "status": int(status), "body": None, + "error": None if ok else ("callback priced %d" % status)}) + + def close(self): + try: + self.child.stdin.close() + except (OSError, ValueError): + pass + + # ------------------------------------------------------------ reader + def _read_loop(self): + try: + for raw in iter(self.child.stdout.readline, b""): + line = raw.decode("utf-8", "replace").strip() + if not line: + continue + try: + envelope = json.loads(line) + except ValueError: + _log("unparseable seat line (dropped): %s" % line[:96]) + continue + op = envelope.get("op") + if op == "ready": + # the JVM seat's own stdio ready envelope: swallowed here + # (the bridge names its own CP/http ready; the harness + # never sees the child's stdio wire) + continue + if op == "callback": + self._transcode_callback(envelope) + continue + rid = envelope.get("id") + if rid is None: + _log("id-less seat line (dropped): %s" % line[:96]) + continue + with self._lock: + self._replies[rid] = envelope + evt = self._pending.get(rid) + if evt is not None: + evt.set() + finally: + _log("seat child stdout closed (exit %s)" % self.child.poll()) + with self._lock: + for evt in list(self._pending.values()): + evt.set() + + def _transcode_callback(self, envelope): + """CP-SPEC 6.3 + 7.2: the payload POSTs to the runner's own + callbackUri URL (+ path); the runner's HTTP status is the answer.""" + body = envelope.get("body") or {} + envelope_id = envelope.get("id") + uri = body.get("uri") or "" + path = body.get("path") or "" + if uri.startswith("cp-callback://"): + _log("cp-callback URI priced under CP/http (the named refusal: " + "an HTTP framing has no envelope channel to answer it)") + if envelope_id is not None: + self.answer_callback(envelope_id, 500) + return + payload = body.get("payload") + data = json.dumps(payload, separators=(",", ":"), + ensure_ascii=True).encode("utf-8") \ + if payload is not None else b"" + parsed = urlparse(uri) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + status = 500 + try: + conn = HTTPConnection(parsed.hostname, port, timeout=10) + try: + conn.request("POST", (parsed.path or "/") + path, body=data, + headers={"Content-Type": "application/json", + "Content-Length": str(len(data))}) + resp = conn.getresponse() + status = resp.status + resp.read() + finally: + conn.close() + except Exception as exc: + _log("callback dial %s%s failed: %r" % (uri, path, exc)) + if envelope_id is not None: + self.answer_callback(envelope_id, status) + + +# ---------------------------------------------------------------- HTTP face + +class Handler(BaseHTTPRequestHandler): + """One CP/http request projected onto the seat's stdio answers. The + envelope carries the status code, the body and the error text; this + handler only carries them across (transport-only, no op bodies).""" + + protocol_version = "HTTP/1.1" + + @property + def seat(self): + return self.server.seat + + def _read_body(self): + try: + n = int(self.headers.get("Content-Length") or 0) + except ValueError: + n = 0 + return self.rfile.read(n) if n > 0 else b"" + + def _respond(self, status, body=None, error=None, location=None): + ok = 200 <= status <= 299 + if ok and body is not None: + content_type = "application/json" + payload = json.dumps(body, separators=(",", ":"), + ensure_ascii=True).encode("utf-8") + elif ok: + content_type = None + payload = b"" + else: + content_type = "text/plain" + payload = (error if isinstance(error, str) else "error") \ + .encode("utf-8") + self.send_response(status) + if content_type: + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + if location: + self.send_header("Location", location) + self.send_header("Connection", "close") + self.end_headers() + if payload: + self.wfile.write(payload) + self.wfile.flush() + + def _reply_from(self, reply): + if reply is None: + self._respond(502, None, "the CP seat priced no reply") + return + status = int(reply.get("status", 500)) + if reply.get("ok"): + body = reply.get("body") + self._respond(status, body) + else: + self._respond(status, None, reply.get("error") or "error") + + def _path_only(self): + return self.path.split("?", 1)[0] + + def _client_id(self): + parts = self._path_only().split("/", 2) + if len(parts) == 3 and parts[1] == "clients" and parts[2]: + return parts[2] + return None + + def do_GET(self): + if self._path_only() == "/": + self._reply_from(self.seat.call("status")) + return + self._respond(404, None, "not found") + + def do_POST(self): + if self._path_only() != "/": + cid = self._client_id() + if cid is None: + self._respond(404, None, "not found") + return + raw = self._read_body() + try: + request = json.loads(raw.decode("utf-8")) if raw else {} + except (ValueError, UnicodeDecodeError): + self._respond(400, None, "bad json") + return + self._reply_from(self.seat.call( + "command", {"client": cid, "request": request})) + return + raw = self._read_body() + try: + payload = json.loads(raw.decode("utf-8")) if raw else None + except (ValueError, UnicodeDecodeError): + self._respond(400, None, "bad json") + return + reply = self.seat.call("createClient", payload) + cid = (reply or {}).get("body", {}).get("client") \ + if reply and reply.get("ok") else None + if cid: + self._respond(201, None, None, location="/clients/%s" % cid) + else: + self._reply_from(reply) + + def do_DELETE(self): + if self._path_only() == "/": + self._reply_from(self.seat.call("stop")) + time.sleep(0.05) + _log("stop requested (DELETE /): exiting") + self.seat.close() + os._exit(0) + cid = self._client_id() + if cid is None: + self._respond(404, None, "not found") + return + self._reply_from(self.seat.call("closeClient", {"client": cid})) + + +class Server(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def run(): + framing = os.environ.get("CP_FRAMING") + version = os.environ.get("CP_VERSION") + if version not in (None, "0"): + sys.stderr.write("cp: CP_VERSION=%s not supported by this seat\n" + % version) + return 2 + if framing not in (None, "http"): + sys.stderr.write("cp: cp-seat's serve_http implements CP/http only " + "(CP_FRAMING=%r)\n" % framing) + return 2 + host = os.environ.get("CP_HTTP_HOST") or "127.0.0.1" + if not is_loopback(host): + # rule 5a: a listener binds loopback only, ever + sys.stderr.write("cp: refusing non-loopback bind %r\n" % host) + return 2 + if host == "localhost": + host = "127.0.0.1" + try: + port = int(os.environ.get("CP_HTTP_PORT") or 0) + except ValueError: + port = 0 + if ":" in host: + Server.address_family = socket.AF_INET6 + if not os.path.isfile(SEAT_LAUNCH): + sys.stderr.write("cp: the census seat launcher is absent: %s\n" + % SEAT_LAUNCH) + return 2 + seat = SeatBridge() + server = Server((host, port), Handler) + server.seat = seat + address = server.server_address + ip, port = address[0], address[1] + # the R5-SERVICE-LISTEN announce (the harness-protocol face: the public + # harness driver waits for this line on stdout), then the CP ready + # envelope (§6.1: REQUIRED under CP/http, id 0; stdout stops after it) + url_host = "[%s]" % ip if ":" in ip else ip + announce = "R5-SERVICE-LISTEN %s:%d" % (ip, port) + ready = {"cp": CP, "id": 0, "op": "ready", + "body": {"framing": "http", + "url": "http://%s:%d" % (url_host, port)}} + sys.stdout.write(announce + "\n") + sys.stdout.write(json.dumps(ready, separators=(",", ":"), + ensure_ascii=True) + "\n") + sys.stdout.flush() + _log("listening http://%s:%d (transport-only; ops ride the census JVM " + "seat %s; CP_HTTP_PORT=%d)" % (url_host, port, SEAT_LAUNCH, port)) + try: + server.serve_forever(poll_interval=0.25) + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(run()) 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..8cc9028a1 --- /dev/null +++ b/tools/cp-seat/src/com/launchdarkly/seat/HookPayloadBuilder.java @@ -0,0 +1,247 @@ +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)); + } + if (hasField(params, "evaluationSeriesData")) { + // The harness hookPayload member law: an evaluation/track hookPayload + // carries its series data; the identify-series rows' payloads price + // only the identify arm seats ({stage, identifySeriesContext, + // identifySeriesData[, identifyResult]}) — the evaluation member + // prices absent there (never a null placeholder). + 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"); + // The identify-series payload law: the series-context seat prices the + // beforeIdentify row only; the afterIdentify rows price {stage, + // identifySeriesData, identifyResult} (the harness's own payload + // member sets per stage). + if (idCtx != null && "beforeIdentify".equals(String.valueOf(readField(params, "stage")))) { + 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 && ((Integer) vi).intValue() != EvaluationDetail.NO_VARIATION) { + // The harness's detail members: a real variation index rides its + // value; a no-variation read prices the null member (the typed + // default's own detail form). + 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); + } + } + + /** The payload's member-set law distinguishes an absent param field (the + * member prices ABSENT — never serialized) from a present-null field + * (serialized as null): the identify rows' payloads never named the + * evaluation member. */ + private static boolean hasField(Object owner, String name) { + try { + owner.getClass().getDeclaredField(name); + return true; + } catch (NoSuchFieldException e) { + return false; + } + } + + 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