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
27 changes: 27 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,30 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3
- run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests

# A second UTS leg through the server door's builders (see the uts.side handling in
# uts/.../ClientFactories.kt): the builders stamp a side-declaring agent entry and pass
# everything else through, so conformance must be identical on both legs; SideModesTest
# fails a leg whose stamp does not match. There is no device leg on the JVM — the device
# door is an Android artifact, covered by the instrumentation tests in emulate.yml.
- run: ./gradlew :uts:runUtsUnitTests -Duts.side=server

# Continuously proves the release pre-flight and that every published module
# builds a publishable artifact set, so version/coordinate regressions surface
# on PRs rather than on release day.
release-dry-run:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with:
persist-credentials: false
- name: Set up the JDK
uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3
- run: ./gradlew verifyReleaseArtifacts publishToMavenLocal
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
4 changes: 4 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,7 @@ jobs:
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3

- run: ./gradlew :uts:runUtsIntegrationTests

# A second leg through the server door's builders — see the uts.side handling in
# uts/.../ClientFactories.kt and the matching leg in check.yml.
- run: ./gradlew :uts:runUtsIntegrationTests -Duts.side=server
6 changes: 6 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3

# Fails before anything is uploaded if the artifact set, group or lockstep
# version drifts (core, core-android, device and server release
# together on the same version; partial release must be impossible).
- name: Release pre-flight
run: ./gradlew verifyReleaseArtifacts

- name: Publish and release to Maven Central
run: ./gradlew publishAndReleaseToMavenCentral
env:
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ Find out more:

---

> [!NOTE]
> **This branch carries the in-development 2.0 device/server package split.** The SDK is being
> restructured into new artifacts that declare which side of the network they run on, so that
> traffic classifies correctly on MAU-priced accounts:
>
> | Artifact | For | Entry point |
> |----------|-----|-------------|
> | `io.ably.pubsub:device` (aar) | Devices: Android apps and other end-user runtimes | `PubSubDevice.clientBuilder(...)` |
> | `io.ably.pubsub:server` (jar) | Servers and other trusted backend environments | `PubSubServer.httpClientBuilder(...)` / `PubSubServer.realtimeClientBuilder(...)` |
> | `io.ably.pubsub:core`, `io.ably.pubsub:core-android` | Internal implementation artifacts — do not depend on these directly | — |
>
> Nothing from this branch is published yet. The `io.ably:ably-java` and `io.ably:ably-android`
> 1.x artifacts continue to work and will receive security and critical fixes from a maintenance
> branch for one year after the 2.0 release. The installation instructions below still describe 1.x.

---

## Getting started

Everything you need to get started with Ably:
Expand Down
50 changes: 50 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,56 @@ subprojects {
}
}

/*
* Release pre-flight: the split ships core, core-android, device and server in lockstep
* (one version, one run), so the set of published artifacts and their
* coordinates are asserted here and the release workflow fails before anything is
* uploaded if they drift. If you add or remove a published module, update this list
* deliberately.
*/
val expectedReleaseArtifacts = sortedSetOf(
"io.ably.pubsub:core:jar",
"io.ably.pubsub:core-android:aar",
"io.ably.pubsub:device:aar",
"io.ably.pubsub:server:jar",
"io.ably.pubsub:liveobjects:jar",
"io.ably.pubsub:pubsub-adapter:jar",
"io.ably.pubsub:network-client-core:jar",
"io.ably.pubsub:network-client-default:jar",
"io.ably.pubsub:network-client-okhttp:jar",
)

tasks.register("verifyReleaseArtifacts") {
description = "Asserts the published artifact set, group and lockstep version before a release."
doLast {
val rootVersion = project.property("VERSION_NAME") as String
val actual = sortedSetOf<String>()
subprojects.filter { it.pluginManager.hasPlugin("com.vanniktech.maven.publish") }.forEach { p ->
val artifactId = p.findProperty("POM_ARTIFACT_ID")
?: error("${p.path} applies maven-publish but has no POM_ARTIFACT_ID")
val packaging = p.findProperty("POM_PACKAGING") ?: "jar"
// The version each module publishes at comes from its effective VERSION_NAME
// (a module-local gradle.properties can override the root's — exactly the
// lockstep drift this guards against).
val moduleVersion = p.findProperty("VERSION_NAME")
if (moduleVersion != rootVersion) {
error("Lockstep violation: ${p.path} has VERSION_NAME $moduleVersion, expected $rootVersion")
}
val group = p.findProperty("GROUP")
actual.add("$group:$artifactId:$packaging")
}
if (actual != expectedReleaseArtifacts) {
error(
"Published artifact set does not match the expected release set.\n" +
" expected: $expectedReleaseArtifacts\n" +
" actual: $actual\n" +
"If this change is deliberate, update expectedReleaseArtifacts in build.gradle.kts."
)
}
logger.lifecycle("Release pre-flight OK: ${actual.size} artifacts at $rootVersion: $actual")
}
}

configure(subprojects) {
pluginManager.withPlugin("com.vanniktech.maven.publish") {
extensions.configure<MavenPublishBaseExtension> {
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);
}
}
}
4 changes: 4 additions & 0 deletions lib/src/main/java/io/ably/lib/debug/DebugOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public DebugOptions copy() {
copied.authParams = authParams;
copied.queryTime = queryTime;
copied.useTokenAuth = useTokenAuth;
copied.headers = headers;
copied.fallbackHosts = fallbackHosts;
copied.transportParams = transportParams;
copied.agents = agents;
return copied;
}
}
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
Loading
Loading