From 60aabae8b2c6a87cfcecae77b804a96912b2a69f Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 1 Sep 2026 17:50:03 +0100 Subject: [PATCH 1/5] Add the device and server door packages with side-declaring agents Adds the two public artifacts of the PDR-091b split, following the ably-js reference implementation (ably-js#2293): - io.ably.pubsub:server (jar, on :core): PubSubServer.httpClientBuilder() and PubSubServer.realtimeClientBuilder(), each accepting everything the core constructors accept (ClientOptions, API key or token string). - io.ably.pubsub:device (aar, on :core-android): PubSubDevice.clientBuilder(), one door per PDR-091. - A shared side helper (shared/src/main/java, compiled into both door artifacts rather than published) owns the ably-pubsub-device and ably-pubsub-server agent identifiers and the stamping rules: caller entries preserved, side entry applied last and unoverridable, caller's options never mutated, null passing through to the core's own error. The -device/-server suffixes are load-bearing for MAU billing classification and documented as such. - Fixes ClientOptions.copy() to carry headers, fallbackHosts, transportParams and agents, which it previously dropped; the doors rely on copy() for non-mutating stamping. Covered by a new unit test. - Server tests include a wire-level assertion that the Ably-Agent HTTP header carries ably-pubsub-server/ alongside the ably-java base identifier; device instrumentation tests assert the same contract and run in the emulator matrix. Co-Authored-By: Claude Fable 5 --- .github/workflows/emulate.yml | 2 +- device/build.gradle.kts | 57 +++++++ device/gradle.properties | 4 + .../ably/pubsub/device/PubSubDeviceTest.java | 61 ++++++++ .../io/ably/pubsub/device/PubSubDevice.java | 74 +++++++++ .../java/io/ably/lib/types/ClientOptions.java | 4 + .../io/ably/lib/types/ClientOptionsTest.java | 24 +++ server/build.gradle.kts | 45 ++++++ server/gradle.properties | 4 + .../io/ably/pubsub/server/PubSubServer.java | 122 +++++++++++++++ .../ably/pubsub/server/PubSubServerTest.java | 144 ++++++++++++++++++ settings.gradle.kts | 2 + .../java/io/ably/pubsub/internal/Side.java | 98 ++++++++++++ 13 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 device/build.gradle.kts create mode 100644 device/gradle.properties create mode 100644 device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java create mode 100644 device/src/main/java/io/ably/pubsub/device/PubSubDevice.java create mode 100644 server/build.gradle.kts create mode 100644 server/gradle.properties create mode 100644 server/src/main/java/io/ably/pubsub/server/PubSubServer.java create mode 100644 server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java create mode 100644 shared/src/main/java/io/ably/pubsub/internal/Side.java diff --git a/.github/workflows/emulate.yml b/.github/workflows/emulate.yml index 304ec070d..836ab86f2 100644 --- a/.github/workflows/emulate.yml +++ b/.github/workflows/emulate.yml @@ -50,7 +50,7 @@ jobs: arch: ${{ steps.get-avd-arch.outputs.arch }} target: default # Print emulator logs if tests fail - script: ./gradlew :core-android:connectedAndroidTest ${{ matrix.android-api-level == 19 && '-PhttpURLConnection' || '' }} || (adb logcat -d System.out:I && exit 1) + script: ./gradlew :core-android:connectedAndroidTest :device:connectedAndroidTest ${{ matrix.android-api-level == 19 && '-PhttpURLConnection' || '' }} || (adb logcat -d System.out:I && exit 1) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() diff --git a/device/build.gradle.kts b/device/build.gradle.kts new file mode 100644 index 000000000..f9f33e285 --- /dev/null +++ b/device/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.pubsub.device" + defaultConfig { + minSdk = 19 + compileSdk = 34 + buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") + testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + buildFeatures { + buildConfig = true + } + + lint { + abortOnError = false + } + + testOptions.targetSdk = 34 + + sourceSets { + getByName("main") { + // `../shared` holds the side-agent helper shared with the `server` module; it is + // compiled into each door artifact rather than published as an artifact of its own. + java.srcDirs("src/main/java", "../shared/src/main/java") + } + } +} + +dependencies { + api(project(":core-android")) + androidTestImplementation(libs.bundles.instrumental.android) +} + +configurations { + all { + exclude(group = "org.hamcrest", module = "hamcrest-core") + resolutionStrategy { + force(libs.jetbrains) + } + } +} diff --git a/device/gradle.properties b/device/gradle.properties new file mode 100644 index 000000000..1be8fb312 --- /dev/null +++ b/device/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=device +POM_NAME=Ably Pub/Sub device SDK +POM_DESCRIPTION=Ably Pub/Sub client for devices: Android apps and other end-user runtimes. The recommended entry point is PubSubDevice.clientBuilder(...). +POM_PACKAGING=aar diff --git a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java new file mode 100644 index 000000000..b4ff3ea91 --- /dev/null +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -0,0 +1,61 @@ +package io.ably.pubsub.device; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +/** + * The agent entries asserted here are what the platform reads to classify traffic on + * MAU-priced accounts, so these tests are deliberately strict: if one fails, billing + * classification is broken, not just a header. + */ +public class PubSubDeviceTest { + + private static final String FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret"; + + private static ClientOptions offlineOptions(String key) throws Exception { + ClientOptions options = new ClientOptions(key); + options.autoConnect = false; + return options; + } + + @Test + public void client_stampsDeviceAgent() throws Exception { + AblyRealtime client = PubSubDevice.clientBuilder(offlineOptions(FAKE_KEY)).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws Exception { + ClientOptions builtOptions = PubSubDevice.clientBuilder(FAKE_KEY).build().options; + assertEquals(FAKE_KEY, builtOptions.key); + assertNull(builtOptions.token); + assertEquals(BuildConfig.VERSION, builtOptions.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + } + + @Test + public void callerAgentEntries_arePreserved_andCannotOverrideTheSideEntry() throws Exception { + ClientOptions options = offlineOptions(FAKE_KEY); + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + callerAgents.put(Side.DEVICE_AGENT_IDENTIFIER, "not-the-real-version"); + options.agents = callerAgents; + + AblyRealtime client = PubSubDevice.clientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + + // the caller's own map is untouched + assertTrue(options.agents == callerAgents); + assertEquals("not-the-real-version", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertFalse(callerAgents.containsValue(BuildConfig.VERSION)); + } +} diff --git a/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java new file mode 100644 index 000000000..9b44823ff --- /dev/null +++ b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java @@ -0,0 +1,74 @@ +package io.ably.pubsub.device; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; + +/** + * The door into Ably Pub/Sub for devices: Android apps and other end-user runtimes. + *

+ * Clients built here declare themselves device-side to Ably: every connection and request + * they make carries the {@code ably-pubsub-device} agent entry, which is how the platform + * classifies the traffic (on MAU-priced accounts, device traffic is what is counted). The + * side is the package's to declare — a caller-supplied agent entry cannot override it. + *

+ * There is one door: a device holds one live client. Connectionless operations (history, + * presence reads, token requests) are all available on it. + *

+ * This builder is the only recommended entry point of this artifact; the classes it + * constructs come from {@code io.ably.pubsub:core-android}, which is an internal + * implementation artifact not intended for direct use. + */ +public final class PubSubDevice { + private PubSubDevice() {} + + /** + * Returns a builder for the device's client. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static ClientBuilder clientBuilder(ClientOptions options) { + return new ClientBuilder(options, null); + } + + /** + * Returns a builder for the device's client. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static ClientBuilder clientBuilder(String keyOrToken) { + return new ClientBuilder(null, keyOrToken); + } + + /** + * Builds the device client. Accepts everything the core constructor accepts. + */ + public static final class ClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private ClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the device side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRealtime build() throws AblyException { + final ClientOptions stamped; + if (keyOrToken != null) { + stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER, BuildConfig.VERSION); + } else { + stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER, BuildConfig.VERSION); + } + return new AblyRealtime(stamped); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/types/ClientOptions.java b/lib/src/main/java/io/ably/lib/types/ClientOptions.java index 3d63be81a..f1f88ded1 100644 --- a/lib/src/main/java/io/ably/lib/types/ClientOptions.java +++ b/lib/src/main/java/io/ably/lib/types/ClientOptions.java @@ -372,6 +372,10 @@ public ClientOptions copy() { copied.authParams = authParams; copied.queryTime = queryTime; copied.useTokenAuth = useTokenAuth; + copied.headers = headers; + copied.fallbackHosts = fallbackHosts; + copied.transportParams = transportParams; + copied.agents = agents; return copied; } diff --git a/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java b/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java index 3d482d95d..34f873e48 100644 --- a/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java +++ b/lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java @@ -1,7 +1,11 @@ package io.ably.lib.types; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import java.util.HashMap; + import org.junit.Test; public class ClientOptionsTest { @@ -13,4 +17,24 @@ public void should_support_idempotent_rest_publishing() { // Then assertTrue(clientOptions.idempotentRestPublishing); } + + @Test + public void copy_carries_headers_fallbackHosts_transportParams_and_agents() { + // Given + clientOptions.headers = new HashMap<>(); + clientOptions.headers.put("X-Custom", "value"); + clientOptions.fallbackHosts = new String[]{"a.example.com", "b.example.com"}; + clientOptions.transportParams = new Param[]{new Param("remainPresentFor", "1000")}; + clientOptions.agents = new HashMap<>(); + clientOptions.agents.put("some-sdk", "1.2.3"); + + // When + ClientOptions copied = clientOptions.copy(); + + // Then + assertSame(clientOptions.headers, copied.headers); + assertArrayEquals(clientOptions.fallbackHosts, copied.fallbackHosts); + assertSame(clientOptions.transportParams, copied.transportParams); + assertSame(clientOptions.agents, copied.agents); + } } diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 000000000..18aa5ec16 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + alias(libs.plugins.build.config) + alias(libs.plugins.maven.publish) + checkstyle + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +dependencies { + api(project(":core")) + testImplementation(libs.bundles.tests) +} + +buildConfig { + useJavaOutput() + packageName = "io.ably.pubsub.server" + buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") +} + +sourceSets { + named("main") { + java { + // `../shared` holds the side-agent helper shared with the `device` module; it is + // compiled into each door artifact rather than published as an artifact of its own. + srcDirs("src/main/java", "../shared/src/main/java") + } + } +} + +tasks.checkstyleMain.configure { + exclude("io/ably/pubsub/server/BuildConfig.java") +} + +tasks.register("runUnitTests") { + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} diff --git a/server/gradle.properties b/server/gradle.properties new file mode 100644 index 000000000..8aa9715ce --- /dev/null +++ b/server/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=server +POM_NAME=Ably Pub/Sub server SDK +POM_DESCRIPTION=Ably Pub/Sub client for servers and other trusted backend environments. The recommended entry points are PubSubServer.httpClientBuilder(...) and PubSubServer.realtimeClientBuilder(...). +POM_PACKAGING=jar diff --git a/server/src/main/java/io/ably/pubsub/server/PubSubServer.java b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java new file mode 100644 index 000000000..ef2ad67da --- /dev/null +++ b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java @@ -0,0 +1,122 @@ +package io.ably.pubsub.server; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; + +/** + * The door into Ably Pub/Sub for servers and other trusted backend environments. + *

+ * Clients built here declare themselves server-side to Ably: every connection and request + * they make carries the {@code ably-pubsub-server} agent entry, which is how the platform + * classifies the traffic (and, on MAU-priced accounts using API-key auth, how it earns the + * server exemption). The side is the package's to declare — a caller-supplied agent entry + * cannot override it. + *

+ * These builders are the only recommended entry points of this artifact; the classes they + * construct come from {@code io.ably.pubsub:core}, which is an internal implementation + * artifact not intended for direct use. + */ +public final class PubSubServer { + private PubSubServer() {} + + /** + * Returns a builder for a stateless client that interacts with Ably over HTTP. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static HttpClientBuilder httpClientBuilder(ClientOptions options) { + return new HttpClientBuilder(options, null); + } + + /** + * Returns a builder for a stateless client that interacts with Ably over HTTP. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static HttpClientBuilder httpClientBuilder(String keyOrToken) { + return new HttpClientBuilder(null, keyOrToken); + } + + /** + * Returns a builder for a stateful client that maintains a live connection to Ably. + * + * @param options a {@link ClientOptions} object to configure the client. + * @return the builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder(ClientOptions options) { + return new RealtimeClientBuilder(options, null); + } + + /** + * Returns a builder for a stateful client that maintains a live connection to Ably. + * + * @param keyOrToken an Ably API key or token string. + * @return the builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder(String keyOrToken) { + return new RealtimeClientBuilder(null, keyOrToken); + } + + /** + * Resolves the caller's input exactly as the core constructors would, then stamps the + * server-side agent entry. Resolution happens at {@code build()} time so the caller's + * input is read once, when the client is constructed. + */ + private static ClientOptions stampedOptions(ClientOptions options, String keyOrToken) throws AblyException { + if (keyOrToken != null) { + return Side.optionsWithSideAgent(keyOrToken, Side.SERVER_AGENT_IDENTIFIER, BuildConfig.VERSION); + } + return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER, BuildConfig.VERSION); + } + + /** + * Builds the HTTP (REST) client. Accepts everything the core constructor accepts. + */ + public static final class HttpClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private HttpClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the server side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRest build() throws AblyException { + return new AblyRest(stampedOptions(options, keyOrToken)); + } + } + + /** + * Builds the realtime client. Accepts everything the core constructor accepts. + */ + public static final class RealtimeClientBuilder { + private final ClientOptions options; + private final String keyOrToken; + + private RealtimeClientBuilder(ClientOptions options, String keyOrToken) { + this.options = options; + this.keyOrToken = keyOrToken; + } + + /** + * Constructs the client, declaring the server side on it. + * + * @return the client. + * @throws AblyException if the options, key or token are rejected. + */ + public AblyRealtime build() throws AblyException { + return new AblyRealtime(stampedOptions(options, keyOrToken)); + } + } +} diff --git a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java new file mode 100644 index 000000000..af02ffa31 --- /dev/null +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -0,0 +1,144 @@ +package io.ably.pubsub.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.sun.net.httpserver.HttpServer; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.pubsub.internal.Side; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +/** + * The agent entries asserted here are what the platform reads to classify traffic (and, on + * MAU-priced accounts, what earns the server exemption), so these tests are deliberately + * strict: if one fails, billing classification is broken, not just a header. + */ +public class PubSubServerTest { + + private static final String FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret"; + private static final String FAKE_TOKEN = "fakeTokenString"; + + private static ClientOptions offlineOptions(String key) throws AblyException { + ClientOptions options = new ClientOptions(key); + options.autoConnect = false; + return options; + } + + @Test + public void httpClient_stampsServerAgent() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void realtimeClient_stampsServerAgent() throws AblyException { + AblyRealtime client = PubSubServer.realtimeClientBuilder(offlineOptions(FAKE_KEY)).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void keyString_isAcceptedAndDisambiguatedAsKey() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_KEY).build(); + assertEquals(FAKE_KEY, client.options.key); + assertNull(client.options.token); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void tokenString_isAcceptedAndDisambiguatedAsToken() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder(FAKE_TOKEN).build(); + assertEquals(FAKE_TOKEN, client.options.token); + assertNull(client.options.key); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void callerAgentEntries_arePreserved() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + options.agents = new HashMap<>(); + options.agents.put("some-sdk", "1.2.3"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void callerCannotOverrideTheSideEntry() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + options.agents = new HashMap<>(); + options.agents.put(Side.SERVER_AGENT_IDENTIFIER, "not-the-real-version"); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void callersOptionsObject_isNotMutated() throws AblyException { + ClientOptions options = offlineOptions(FAKE_KEY); + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + options.agents = callerAgents; + PubSubServer.httpClientBuilder(options).build(); + assertTrue(options.agents == callerAgents); + assertEquals(1, callerAgents.size()); + assertFalse(callerAgents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void nullOptions_getTheCoreConstructorsOwnError() { + try { + PubSubServer.httpClientBuilder((ClientOptions) null).build(); + fail("expected the core's initialization error"); + } catch (AblyException e) { + assertEquals(40000, e.errorInfo.code); + } + } + + /** + * Wire-level assertion: the Ably-Agent header actually sent over HTTP carries the + * side-declaring entry alongside the core's base identifier. This is the value billing + * classification reads. + */ + @Test + public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { + AtomicReference observedAgentHeader = new AtomicReference<>(); + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext("/time", exchange -> { + observedAgentHeader.set(exchange.getRequestHeaders().getFirst("Ably-Agent")); + byte[] body = "[1234567890000]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + httpServer.start(); + try { + ClientOptions options = offlineOptions(FAKE_KEY); + options.tls = false; + options.restHost = "127.0.0.1"; + options.port = httpServer.getAddress().getPort(); + AblyRest client = PubSubServer.httpClientBuilder(options).build(); + client.time(); + + String agentHeader = observedAgentHeader.get(); + assertNotNull("no Ably-Agent header observed", agentHeader); + assertTrue("missing side-declaring entry in: " + agentHeader, + agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/" + BuildConfig.VERSION)); + assertTrue("missing core base identifier in: " + agentHeader, + agentHeader.contains("ably-java/")); + } finally { + httpServer.stop(0); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ed2fc200d..c11795fed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,8 @@ rootProject.name = "ably-java" include("core") include("core-android") +include("device") +include("server") include("gradle-lint") include("network-client-core") include("network-client-default") diff --git a/shared/src/main/java/io/ably/pubsub/internal/Side.java b/shared/src/main/java/io/ably/pubsub/internal/Side.java new file mode 100644 index 000000000..fb5205ea0 --- /dev/null +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -0,0 +1,98 @@ +package io.ably.pubsub.internal; + +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Internal helper shared by the {@code io.ably.pubsub:device} and {@code io.ably.pubsub:server} + * door artifacts. It is compiled into each artifact's output from a shared source directory + * rather than published, so that the two artifacts can share this code without a third + * artifact existing for it to live in. + *

+ * PDR-091 keeps {@code io.ably.pubsub:core} itself as the shared core, so nothing here may + * grow into a general abstraction over the core: it exists only to stamp the side a package + * declares. + */ +public final class Side { + private Side() {} + + /* + * The `-device` / `-server` suffix on both identifiers below is load-bearing, not + * cosmetic. On API-key auth the realtime system grants the server exemption by matching + * an agent entry ending in `-server`, and an identifier that is not yet in the + * ably-common registry is classified by that suffix alone. Renaming either without + * preserving its suffix silently reclassifies every client the package constructs. + * + * Both live here rather than in the package that uses each, so the naming scheme can be + * changed in one place. + */ + + /** The agent identifier declaring the device side, sent by {@code io.ably.pubsub:device}. */ + public static final String DEVICE_AGENT_IDENTIFIER = "ably-pubsub-device"; + + /** + * The agent identifier declaring the server side, sent by {@code io.ably.pubsub:server}. + *

+ * This is the entry that earns the MAU exemption on API-key auth, so its {@code -server} + * suffix is the one with billing consequences. + */ + public static final String SERVER_AGENT_IDENTIFIER = "ably-pubsub-server"; + + /** + * Returns a copy of the caller's options carrying the agent entry that declares this + * package's side. + *

+ * The copy is made with {@link ClientOptions#copy()} and a fresh agents map, so the + * caller's options and their own {@code agents} map are both left untouched. The + * caller's {@code agents} entries are preserved alongside the side stamp, so an SDK + * layered on top of this package keeps its attribution. The side stamp is applied last + * and so wins a collision on its own identifier: which side the package declares is the + * package's to state, not the caller's to redefine. + *

+ * {@code null} passes through unchanged rather than being defaulted, so a caller who + * passes nothing gets the core constructor's own initialization error ("no options + * provided") instead of constructing with only an {@code agents} entry and failing + * later with a vaguer authentication error. + * + * @param options the options the caller passed to the door's builder, or {@code null}. + * @param identifier the side-declaring agent identifier to stamp. + * @param version the version of the package doing the stamping. + * @return a stamped copy of the options, or {@code null} if {@code options} was {@code null}. + */ + public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier, String version) { + if (options == null) { + return null; + } + ClientOptions stamped = options.copy(); + Map agents = new LinkedHashMap<>(); + if (options.agents != null) { + agents.putAll(options.agents); + } + agents.put(identifier, version); + stamped.agents = agents; + return stamped; + } + + /** + * As {@link #optionsWithSideAgent(ClientOptions, String, String)}, for the API key or + * token string form the core constructors also accept. Reuses the core's own + * key-versus-token disambiguation ({@link ClientOptions#ClientOptions(String)}: an Ably + * API key always contains a colon, an Ably token never does). + * + * @param keyOrToken the Ably API key or token string the caller passed to the door's builder. + * @param identifier the side-declaring agent identifier to stamp. + * @param version the version of the package doing the stamping. + * @return stamped options constructed from the key or token. + * @throws AblyException if the key or token string is rejected by the core. + */ + public static ClientOptions optionsWithSideAgent(String keyOrToken, String identifier, String version) + throws AblyException { + ClientOptions options = new ClientOptions(keyOrToken); + options.agents = new LinkedHashMap<>(); + options.agents.put(identifier, version); + return options; + } +} From 4605fd44abd31a6bbd7e01dd17f3464678c02aaa Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 11:24:18 +0100 Subject: [PATCH 2/5] Make the side-declaring agent entries versionless Mirrors ably-js#2297, matching the registry entries in ably-common#361: the side flags are registered versionless (like `browser`), because under lockstep versioning a side-flag version always duplicates the SDK entry beside it, and across SDKs a bare version cannot say what it versions. Identity, version and support status keep travelling on the SDK's own ably-java/ entry. Wire shape: ably-java/2.0.0 jre/17 ably-pubsub-server AgentHeaderCreator already emits a null-valued map entry as a bare token, so no core change is needed; the doors stamp null and drop their BuildConfig version plumbing. Tests now assert the flag is present as a bare token and fail if any /version form regresses. Co-Authored-By: Claude Fable 5 --- device/build.gradle.kts | 5 --- .../ably/pubsub/device/PubSubDeviceTest.java | 23 ++++++++--- .../io/ably/pubsub/device/PubSubDevice.java | 5 ++- server/build.gradle.kts | 11 ----- .../io/ably/pubsub/server/PubSubServer.java | 9 ++-- .../ably/pubsub/server/PubSubServerTest.java | 41 ++++++++++++++----- .../java/io/ably/pubsub/internal/Side.java | 19 +++++---- 7 files changed, 67 insertions(+), 46 deletions(-) diff --git a/device/build.gradle.kts b/device/build.gradle.kts index f9f33e285..5418c02d1 100644 --- a/device/build.gradle.kts +++ b/device/build.gradle.kts @@ -8,7 +8,6 @@ android { defaultConfig { minSdk = 19 compileSdk = 34 - buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" } @@ -23,10 +22,6 @@ android { } } - buildFeatures { - buildConfig = true - } - lint { abortOnError = false } diff --git a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java index b4ff3ea91..9b31fbe5f 100644 --- a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -16,6 +16,9 @@ * The agent entries asserted here are what the platform reads to classify traffic on * MAU-priced accounts, so these tests are deliberately strict: if one fails, billing * classification is broken, not just a header. + *

+ * The side entry is a versionless flag — a bare token on the wire, per ably/ably-common#361 + * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. */ public class PubSubDeviceTest { @@ -27,10 +30,18 @@ private static ClientOptions offlineOptions(String key) throws Exception { return options; } + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private static void assertDeviceFlag(Map agents) { + assertTrue("expected the device side flag", agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)); + assertNull("the side flag is versionless", agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertFalse("a device client must not carry the server entry", + agents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + @Test public void client_stampsDeviceAgent() throws Exception { AblyRealtime client = PubSubDevice.clientBuilder(offlineOptions(FAKE_KEY)).build(); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertDeviceFlag(client.options.agents); } @Test @@ -38,7 +49,7 @@ public void keyString_isAcceptedAndDisambiguatedAsKey() throws Exception { ClientOptions builtOptions = PubSubDevice.clientBuilder(FAKE_KEY).build().options; assertEquals(FAKE_KEY, builtOptions.key); assertNull(builtOptions.token); - assertEquals(BuildConfig.VERSION, builtOptions.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + assertDeviceFlag(builtOptions.agents); } @Test @@ -46,16 +57,16 @@ public void callerAgentEntries_arePreserved_andCannotOverrideTheSideEntry() thro ClientOptions options = offlineOptions(FAKE_KEY); Map callerAgents = new HashMap<>(); callerAgents.put("some-sdk", "1.2.3"); - callerAgents.put(Side.DEVICE_AGENT_IDENTIFIER, "not-the-real-version"); + callerAgents.put(Side.DEVICE_AGENT_IDENTIFIER, "not-the-real-form"); options.agents = callerAgents; AblyRealtime client = PubSubDevice.clientBuilder(options).build(); assertEquals("1.2.3", client.options.agents.get("some-sdk")); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.DEVICE_AGENT_IDENTIFIER)); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertDeviceFlag(client.options.agents); // the caller's own map is untouched assertTrue(options.agents == callerAgents); - assertEquals("not-the-real-version", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)); - assertFalse(callerAgents.containsValue(BuildConfig.VERSION)); + assertEquals("not-the-real-form", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)); } } diff --git a/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java index 9b44823ff..c838ee19e 100644 --- a/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java +++ b/device/src/main/java/io/ably/pubsub/device/PubSubDevice.java @@ -62,11 +62,12 @@ private ClientBuilder(ClientOptions options, String keyOrToken) { * @throws AblyException if the options, key or token are rejected. */ public AblyRealtime build() throws AblyException { + // The side entry is a versionless flag — see Side. final ClientOptions stamped; if (keyOrToken != null) { - stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER, BuildConfig.VERSION); + stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER); } else { - stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER, BuildConfig.VERSION); + stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER); } return new AblyRealtime(stamped); } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 18aa5ec16..00e35300b 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -1,5 +1,4 @@ plugins { - alias(libs.plugins.build.config) alias(libs.plugins.maven.publish) checkstyle `java-library` @@ -19,12 +18,6 @@ dependencies { testImplementation(libs.bundles.tests) } -buildConfig { - useJavaOutput() - packageName = "io.ably.pubsub.server" - buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") -} - sourceSets { named("main") { java { @@ -35,10 +28,6 @@ sourceSets { } } -tasks.checkstyleMain.configure { - exclude("io/ably/pubsub/server/BuildConfig.java") -} - tasks.register("runUnitTests") { beforeTest(closureOf { logger.lifecycle("-> $this") }) outputs.upToDateWhen { false } diff --git a/server/src/main/java/io/ably/pubsub/server/PubSubServer.java b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java index ef2ad67da..ec310161b 100644 --- a/server/src/main/java/io/ably/pubsub/server/PubSubServer.java +++ b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java @@ -64,14 +64,15 @@ public static RealtimeClientBuilder realtimeClientBuilder(String keyOrToken) { /** * Resolves the caller's input exactly as the core constructors would, then stamps the - * server-side agent entry. Resolution happens at {@code build()} time so the caller's - * input is read once, when the client is constructed. + * server-side agent entry (a versionless flag — see {@link Side}). Resolution happens + * at {@code build()} time so the caller's input is read once, when the client is + * constructed. */ private static ClientOptions stampedOptions(ClientOptions options, String keyOrToken) throws AblyException { if (keyOrToken != null) { - return Side.optionsWithSideAgent(keyOrToken, Side.SERVER_AGENT_IDENTIFIER, BuildConfig.VERSION); + return Side.optionsWithSideAgent(keyOrToken, Side.SERVER_AGENT_IDENTIFIER); } - return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER, BuildConfig.VERSION); + return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER); } /** diff --git a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java index af02ffa31..d23d64a35 100644 --- a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -15,7 +15,9 @@ import io.ably.pubsub.internal.Side; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; @@ -24,6 +26,9 @@ * The agent entries asserted here are what the platform reads to classify traffic (and, on * MAU-priced accounts, what earns the server exemption), so these tests are deliberately * strict: if one fails, billing classification is broken, not just a header. + *

+ * The side entry is a versionless flag — a bare token on the wire, per ably/ably-common#361 + * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. */ public class PubSubServerTest { @@ -36,16 +41,24 @@ private static ClientOptions offlineOptions(String key) throws AblyException { return options; } + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private static void assertServerFlag(Map agents) { + assertTrue("expected the server side flag", agents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + assertNull("the side flag is versionless", agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertFalse("a server client must not carry the device entry", + agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)); + } + @Test public void httpClient_stampsServerAgent() throws AblyException { AblyRest client = PubSubServer.httpClientBuilder(offlineOptions(FAKE_KEY)).build(); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertServerFlag(client.options.agents); } @Test public void realtimeClient_stampsServerAgent() throws AblyException { AblyRealtime client = PubSubServer.realtimeClientBuilder(offlineOptions(FAKE_KEY)).build(); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertServerFlag(client.options.agents); } @Test @@ -53,7 +66,7 @@ public void keyString_isAcceptedAndDisambiguatedAsKey() throws AblyException { AblyRest client = PubSubServer.httpClientBuilder(FAKE_KEY).build(); assertEquals(FAKE_KEY, client.options.key); assertNull(client.options.token); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertServerFlag(client.options.agents); } @Test @@ -61,7 +74,7 @@ public void tokenString_isAcceptedAndDisambiguatedAsToken() throws AblyException AblyRest client = PubSubServer.httpClientBuilder(FAKE_TOKEN).build(); assertEquals(FAKE_TOKEN, client.options.token); assertNull(client.options.key); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertServerFlag(client.options.agents); } @Test @@ -71,16 +84,17 @@ public void callerAgentEntries_arePreserved() throws AblyException { options.agents.put("some-sdk", "1.2.3"); AblyRest client = PubSubServer.httpClientBuilder(options).build(); assertEquals("1.2.3", client.options.agents.get("some-sdk")); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + assertServerFlag(client.options.agents); } @Test public void callerCannotOverrideTheSideEntry() throws AblyException { ClientOptions options = offlineOptions(FAKE_KEY); options.agents = new HashMap<>(); - options.agents.put(Side.SERVER_AGENT_IDENTIFIER, "not-the-real-version"); + options.agents.put(Side.SERVER_AGENT_IDENTIFIER, "not-the-real-form"); AblyRest client = PubSubServer.httpClientBuilder(options).build(); - assertEquals(BuildConfig.VERSION, client.options.agents.get(Side.SERVER_AGENT_IDENTIFIER)); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertServerFlag(client.options.agents); } @Test @@ -107,8 +121,8 @@ public void nullOptions_getTheCoreConstructorsOwnError() { /** * Wire-level assertion: the Ably-Agent header actually sent over HTTP carries the - * side-declaring entry alongside the core's base identifier. This is the value billing - * classification reads. + * side-declaring flag as a bare token alongside the core's base identifier. This is the + * value billing classification reads. */ @Test public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { @@ -133,8 +147,13 @@ public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { String agentHeader = observedAgentHeader.get(); assertNotNull("no Ably-Agent header observed", agentHeader); - assertTrue("missing side-declaring entry in: " + agentHeader, - agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/" + BuildConfig.VERSION)); + List tokens = Arrays.asList(agentHeader.split(" ")); + // The flag must be present as a bare token: `name/anything` means the + // versionless stamp regressed (see ably/ably-common#361). + assertTrue("missing bare side flag in: " + agentHeader, + tokens.contains(Side.SERVER_AGENT_IDENTIFIER)); + assertFalse("side flag must be versionless in: " + agentHeader, + agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/")); assertTrue("missing core base identifier in: " + agentHeader, agentHeader.contains("ably-java/")); } finally { diff --git a/shared/src/main/java/io/ably/pubsub/internal/Side.java b/shared/src/main/java/io/ably/pubsub/internal/Side.java index fb5205ea0..1d173f71f 100644 --- a/shared/src/main/java/io/ably/pubsub/internal/Side.java +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -45,6 +45,13 @@ private Side() {} * Returns a copy of the caller's options carrying the agent entry that declares this * package's side. *

+ * The side entry is a versionless flag — a bare token on the wire, like the + * platform's own {@code browser} entry — registered as such in the ably-common agents + * registry (see ably/ably-common#361). Identity, version and support status keep + * travelling on the SDK's own {@code ably-java/} entry alongside it; + * {@link io.ably.lib.util.AgentHeaderCreator} emits a map entry with a {@code null} + * value as a bare token. + *

* The copy is made with {@link ClientOptions#copy()} and a fresh agents map, so the * caller's options and their own {@code agents} map are both left untouched. The * caller's {@code agents} entries are preserved alongside the side stamp, so an SDK @@ -59,10 +66,9 @@ private Side() {} * * @param options the options the caller passed to the door's builder, or {@code null}. * @param identifier the side-declaring agent identifier to stamp. - * @param version the version of the package doing the stamping. * @return a stamped copy of the options, or {@code null} if {@code options} was {@code null}. */ - public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier, String version) { + public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier) { if (options == null) { return null; } @@ -71,28 +77,27 @@ public static ClientOptions optionsWithSideAgent(ClientOptions options, String i if (options.agents != null) { agents.putAll(options.agents); } - agents.put(identifier, version); + agents.put(identifier, null); stamped.agents = agents; return stamped; } /** - * As {@link #optionsWithSideAgent(ClientOptions, String, String)}, for the API key or + * As {@link #optionsWithSideAgent(ClientOptions, String)}, for the API key or * token string form the core constructors also accept. Reuses the core's own * key-versus-token disambiguation ({@link ClientOptions#ClientOptions(String)}: an Ably * API key always contains a colon, an Ably token never does). * * @param keyOrToken the Ably API key or token string the caller passed to the door's builder. * @param identifier the side-declaring agent identifier to stamp. - * @param version the version of the package doing the stamping. * @return stamped options constructed from the key or token. * @throws AblyException if the key or token string is rejected by the core. */ - public static ClientOptions optionsWithSideAgent(String keyOrToken, String identifier, String version) + public static ClientOptions optionsWithSideAgent(String keyOrToken, String identifier) throws AblyException { ClientOptions options = new ClientOptions(keyOrToken); options.agents = new LinkedHashMap<>(); - options.agents.put(identifier, version); + options.agents.put(identifier, null); return options; } } From 42d3bd3864770649e652400da603dc055760629f Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 12:49:11 +0100 Subject: [PATCH 3/5] Rename the SDK agent identifier to ably-pubsub-java The family identifier follows the package split, per the agent identifier convention proposed on PDR-091b2 and registered in ably/ably-common#361: because it flips exactly at the split and the maintenance branch is never touched, the identifier alone partitions the fleet - ably-java/* is legacy-package traffic, ably-pubsub-java/* is new-package traffic. It names the family rather than any one published artifact; the side a client declares travels as the separate versionless entry stamped by the door packages: ably-pubsub-java/2.0.0 jre/17.0.12 ably-pubsub-server Requires the ably-pubsub-java registry entry (ably/ably-common#361) before any release from this branch. Mirrors ably/ably-js#2297. Co-Authored-By: Claude Fable 5 --- lib/src/main/java/io/ably/lib/transport/Defaults.java | 9 ++++++++- .../ably/lib/test/realtime/RealtimeHttpHeaderTest.java | 2 +- .../java/io/ably/pubsub/server/PubSubServerTest.java | 2 +- shared/src/main/java/io/ably/pubsub/internal/Side.java | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/transport/Defaults.java b/lib/src/main/java/io/ably/lib/transport/Defaults.java index 66e3e897c..8844bff11 100644 --- a/lib/src/main/java/io/ably/lib/transport/Defaults.java +++ b/lib/src/main/java/io/ably/lib/transport/Defaults.java @@ -14,7 +14,14 @@ public class Defaults { */ public static final String ABLY_PROTOCOL_VERSION = "6"; - public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-java", BuildConfig.VERSION); + /** + * The SDK family identifier. It renamed from {@code ably-java} with the per-side package + * split, so the identifier alone partitions the fleet: {@code ably-java/*} is legacy-package + * traffic, {@code ably-pubsub-java/*} is new-package traffic. It names the family rather than + * any one published artifact; the side a client declares travels as a separate versionless + * agent entry (see io.ably.pubsub.internal.Side and the agents registry in ably-common). + */ + public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-pubsub-java", BuildConfig.VERSION); /* realtime params */ public static final String ABLY_PROTOCOL_VERSION_PARAM = "v"; diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java index 258c21368..88d280dc9 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeHttpHeaderTest.java @@ -88,7 +88,7 @@ public void realtime_websocket_param_test() { * Defaults.ABLY_AGENT_PARAM, as ultimately the request param has been derived from those values. */ assertEquals("Verify correct lib version", requestParameters.get("agent"), - Collections.singletonList("ably-java/2.0.0 jre/" + System.getProperty("java.version"))); + Collections.singletonList("ably-pubsub-java/2.0.0 jre/" + System.getProperty("java.version"))); /* Spec RTN2a */ assertEquals("Verify correct format", requestParameters.get("format"), diff --git a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java index d23d64a35..56a5a12e3 100644 --- a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -155,7 +155,7 @@ public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { assertFalse("side flag must be versionless in: " + agentHeader, agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/")); assertTrue("missing core base identifier in: " + agentHeader, - agentHeader.contains("ably-java/")); + agentHeader.contains("ably-pubsub-java/")); } finally { httpServer.stop(0); } diff --git a/shared/src/main/java/io/ably/pubsub/internal/Side.java b/shared/src/main/java/io/ably/pubsub/internal/Side.java index 1d173f71f..e9da7b6fe 100644 --- a/shared/src/main/java/io/ably/pubsub/internal/Side.java +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -48,7 +48,7 @@ private Side() {} * The side entry is a versionless flag — a bare token on the wire, like the * platform's own {@code browser} entry — registered as such in the ably-common agents * registry (see ably/ably-common#361). Identity, version and support status keep - * travelling on the SDK's own {@code ably-java/} entry alongside it; + * travelling on the SDK's own {@code ably-pubsub-java/} entry alongside it; * {@link io.ably.lib.util.AgentHeaderCreator} emits a map entry with a {@code null} * value as a bare token. *

From 1913aa3d460f255d64c952864931c7292cee61c7 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 15:10:52 +0100 Subject: [PATCH 4/5] Drop DR/ticket numbers from code comments Comments describe the behavior in place (versionless registry entries, the load-bearing suffix) rather than citing decision-record or PR numbers; those references live in the PR descriptions and READMEs. Co-Authored-By: Claude Fable 5 --- .../java/io/ably/pubsub/device/PubSubDeviceTest.java | 2 +- .../src/test/java/io/ably/pubsub/server/PubSubServerTest.java | 4 ++-- shared/src/main/java/io/ably/pubsub/internal/Side.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java index 9b31fbe5f..9b280f19b 100644 --- a/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -17,7 +17,7 @@ * MAU-priced accounts, so these tests are deliberately strict: if one fails, billing * classification is broken, not just a header. *

- * The side entry is a versionless flag — a bare token on the wire, per ably/ably-common#361 + * The side entry is a versionless flag — a bare token on the wire, registered as such in the ably-common agents registry * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. */ public class PubSubDeviceTest { diff --git a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java index 56a5a12e3..ee4a8d4eb 100644 --- a/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -27,7 +27,7 @@ * MAU-priced accounts, what earns the server exemption), so these tests are deliberately * strict: if one fails, billing classification is broken, not just a header. *

- * The side entry is a versionless flag — a bare token on the wire, per ably/ably-common#361 + * The side entry is a versionless flag — a bare token on the wire, registered as such in the ably-common agents registry * — so the assertions also fail if a version (or any {@code /suffix}) reappears on it. */ public class PubSubServerTest { @@ -149,7 +149,7 @@ public void httpRequests_carryTheServerAgentHeaderOnTheWire() throws Exception { assertNotNull("no Ably-Agent header observed", agentHeader); List tokens = Arrays.asList(agentHeader.split(" ")); // The flag must be present as a bare token: `name/anything` means the - // versionless stamp regressed (see ably/ably-common#361). + // versionless stamp regressed (the registry entry is versionless). assertTrue("missing bare side flag in: " + agentHeader, tokens.contains(Side.SERVER_AGENT_IDENTIFIER)); assertFalse("side flag must be versionless in: " + agentHeader, diff --git a/shared/src/main/java/io/ably/pubsub/internal/Side.java b/shared/src/main/java/io/ably/pubsub/internal/Side.java index e9da7b6fe..147f74c27 100644 --- a/shared/src/main/java/io/ably/pubsub/internal/Side.java +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -12,7 +12,7 @@ * rather than published, so that the two artifacts can share this code without a third * artifact existing for it to live in. *

- * PDR-091 keeps {@code io.ably.pubsub:core} itself as the shared core, so nothing here may + * The package split keeps {@code io.ably.pubsub:core} itself as the shared core, so nothing here may * grow into a general abstraction over the core: it exists only to stamp the side a package * declares. */ @@ -47,7 +47,7 @@ private Side() {} *

* The side entry is a versionless flag — a bare token on the wire, like the * platform's own {@code browser} entry — registered as such in the ably-common agents - * registry (see ably/ably-common#361). Identity, version and support status keep + * registry. Identity, version and support status keep * travelling on the SDK's own {@code ably-pubsub-java/} entry alongside it; * {@link io.ably.lib.util.AgentHeaderCreator} emits a map entry with a {@code null} * value as a bare token. From d1884bf7c941d049bb5e0f57600a527e204cc43b Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 2 Sep 2026 15:57:30 +0100 Subject: [PATCH 5/5] Update pubsub-adapter agent assertions for the ably-pubsub-java identifier SdkWrapperAgentHeaderTest asserts the full agent header and still expected the pre-rename ably-java family identifier. Co-Authored-By: Claude Fable 5 --- .../ably/pubsub/SdkWrapperAgentHeaderTest.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt index d91359b8c..a9f44104b 100644 --- a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt +++ b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt @@ -27,7 +27,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -35,7 +35,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { realtimeClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -43,7 +43,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.request("/time") assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -59,7 +59,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -67,7 +67,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { restClient.time() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -75,7 +75,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.request("/time") assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -91,7 +91,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -99,7 +99,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { restClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -107,7 +107,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").presence.history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -123,7 +123,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -131,7 +131,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { realtimeClient.channels.get("test").history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) } @@ -139,7 +139,7 @@ class SdkWrapperAgentHeaderTest { server.servedRequests.test { wrapperSdkClient.channels.get("test").presence.history() assertEquals( - setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + setOf("ably-pubsub-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), ) }