Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/emulate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions device/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
4 changes: 4 additions & 0 deletions device/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<String, String> 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();
assertDeviceFlag(client.options.agents);
}

@Test
public void keyString_isAcceptedAndDisambiguatedAsKey() throws Exception {
ClientOptions builtOptions = PubSubDevice.clientBuilder(FAKE_KEY).build().options;
assertEquals(FAKE_KEY, builtOptions.key);
assertNull(builtOptions.token);
assertDeviceFlag(builtOptions.agents);
}

@Test
public void callerAgentEntries_arePreserved_andCannotOverrideTheSideEntry() throws Exception {
ClientOptions options = offlineOptions(FAKE_KEY);
Map<String, String> callerAgents = new HashMap<>();
callerAgents.put("some-sdk", "1.2.3");
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"));
// 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-form", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER));
}
}
75 changes: 75 additions & 0 deletions device/src/main/java/io/ably/pubsub/device/PubSubDevice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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.
* <p>
* 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.
* <p>
* There is one door: a device holds one live client. Connectionless operations (history,
* presence reads, token requests) are all available on it.
* <p>
* 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);
}
}
}
9 changes: 8 additions & 1 deletion lib/src/main/java/io/ably/lib/transport/Defaults.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions lib/src/main/java/io/ably/lib/types/ClientOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
24 changes: 24 additions & 0 deletions lib/src/test/java/io/ably/lib/types/ClientOptionsTest.java
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,23 @@ 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(),
)
}

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(),
)
}

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(),
)
}
Expand All @@ -59,23 +59,23 @@ 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(),
)
}

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(),
)
}

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(),
)
}
Expand All @@ -91,23 +91,23 @@ 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(),
)
}

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(),
)
}

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(),
)
}
Expand All @@ -123,23 +123,23 @@ 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(),
)
}

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(),
)
}

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(),
)
}
Expand Down
Loading
Loading