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
57 changes: 57 additions & 0 deletions device/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
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,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<String, String> 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));
}
}
74 changes: 74 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,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.
* <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 {
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);
}
}
}
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
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);
}
}
45 changes: 45 additions & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<Jar> {
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<Test>("runUnitTests") {
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
outputs.upToDateWhen { false }
}
4 changes: 4 additions & 0 deletions server/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading