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..5418c02d1 --- /dev/null +++ b/device/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.pubsub.device" + defaultConfig { + minSdk = 19 + compileSdk = 34 + testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + 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..9b280f19b --- /dev/null +++ b/device/src/androidTest/java/io/ably/pubsub/device/PubSubDeviceTest.java @@ -0,0 +1,72 @@ +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. + *
+ * 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 {
+
+ 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;
+ }
+
+ /** The stamped entry is present as a versionless flag, and the other side's is absent. */
+ private static void assertDeviceFlag(Map
+ * 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 {
+ // The side entry is a versionless flag — see Side.
+ final ClientOptions stamped;
+ if (keyOrToken != null) {
+ stamped = Side.optionsWithSideAgent(keyOrToken, Side.DEVICE_AGENT_IDENTIFIER);
+ } else {
+ stamped = Side.optionsWithSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER);
+ }
+ return new AblyRealtime(stamped);
+ }
+ }
+}
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/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/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/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/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(),
)
}
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
new file mode 100644
index 000000000..00e35300b
--- /dev/null
+++ b/server/build.gradle.kts
@@ -0,0 +1,34 @@
+plugins {
+ alias(libs.plugins.maven.publish)
+ checkstyle
+ `java-library`
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+tasks.withType
+ * 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 (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);
+ }
+ return Side.optionsWithSideAgent(options, Side.SERVER_AGENT_IDENTIFIER);
+ }
+
+ /**
+ * 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..ee4a8d4eb
--- /dev/null
+++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java
@@ -0,0 +1,163 @@
+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.Arrays;
+import java.util.HashMap;
+import java.util.List;
+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.
+ *
+ * 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 {
+
+ 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;
+ }
+
+ /** The stamped entry is present as a versionless flag, and the other side's is absent. */
+ private static void assertServerFlag(Map
+ * 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.
+ */
+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 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. Identity, version and support status keep
+ * travelling on the SDK's own {@code ably-pubsub-java/
+ * 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.
+ * @return a stamped copy of the options, or {@code null} if {@code options} was {@code null}.
+ */
+ public static ClientOptions optionsWithSideAgent(ClientOptions options, String identifier) {
+ if (options == null) {
+ return null;
+ }
+ ClientOptions stamped = options.copy();
+ Map