diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a2fe3a2bb..4f8396ea9 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -24,3 +24,23 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests + + # 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 diff --git a/.github/workflows/emulate.yml b/.github/workflows/emulate.yml index a95d1386c..d34173b8e 100644 --- a/.github/workflows/emulate.yml +++ b/.github/workflows/emulate.yml @@ -50,10 +50,10 @@ jobs: arch: ${{ steps.get-avd-arch.outputs.arch }} target: default # Print emulator logs if tests fail - script: ./gradlew :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() with: - name: android-build-reports-${{ matrix.android-api-level }} - path: android/build/reports/ + name: core-android-build-reports-${{ matrix.android-api-level }} + path: core-android/build/reports/ diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index df5e399a8..90df0d535 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -27,13 +27,13 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew :java:testRestSuite -PhttpURLConnection + - run: ./gradlew :core:testRestSuite -PhttpURLConnection - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: - name: java-build-reports-rest - path: java/build/reports/ + name: core-build-reports-rest + path: core/build/reports/ check-realtime-httpurlconnection: runs-on: ubuntu-latest @@ -54,13 +54,13 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew :java:testRealtimeSuite -PhttpURLConnection + - run: ./gradlew :core:testRealtimeSuite -PhttpURLConnection - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: - name: java-build-reports-realtime - path: java/build/reports/ + name: core-build-reports-realtime + path: core/build/reports/ check-rest-okhttp: runs-on: ubuntu-latest permissions: @@ -80,7 +80,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew :java:testRestSuite + - run: ./gradlew :core:testRestSuite check-realtime-okhttp: runs-on: ubuntu-latest @@ -101,7 +101,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3 - - run: ./gradlew :java:testRealtimeSuite + - run: ./gradlew :core:testRealtimeSuite check-liveobjects: runs-on: ubuntu-latest diff --git a/.github/workflows/javadoc.yml b/.github/workflows/javadoc.yml index 8876dbb11..f99b52fa8 100644 --- a/.github/workflows/javadoc.yml +++ b/.github/workflows/javadoc.yml @@ -39,6 +39,6 @@ jobs: - name: Upload Documentation uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 with: - sourcePath: java/build/docs/javadoc + sourcePath: core/build/docs/javadoc githubToken: ${{ secrets.GITHUB_TOKEN }} artifactName: javadoc diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c55cc27a9..1d81892c7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c8f7abb7..dc5cf9653 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,13 @@ 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) -4. Ensure you have added suitable tests and the test suite is passing(`./gradlew java:testRestSuite java:testRealtimeSuite android:connectedAndroidTest`) +4. Ensure you have added suitable tests and the test suite is passing(`./gradlew core:testRestSuite core:testRealtimeSuite core-android:connectedAndroidTest`) 5. Push to the branch (`git push origin my-new-feature`) 6. Create a new Pull Request ### Building -The library consists of JRE-specific library (in `java/`) and an Android-specific library (in `android/`). The libraries are largely common-sourced; the `lib/` directory contains the common parts. +The library consists of JRE-specific library (in `core/`) and an Android-specific library (in `core-android/`). The libraries are largely common-sourced; the `lib/` directory contains the common parts. A gradle wrapper is included so these tasks can run without any prior installation of gradle. The Linux/OSX form of the commands, given below, is: @@ -23,11 +23,11 @@ but on Windows there is a batch file: The JRE-specific library JAR is built with: - ./gradlew java:jar + ./gradlew core:jar The Android-specific library AAR is built with: - ./gradlew android:assemble + ./gradlew core-android:assemble (The `ANDROID_HOME` environment variable must be set appropriately.) @@ -85,7 +85,7 @@ Once done, your custom network engine will be available for use within `ably-jav We use [Checkstyle](https://checkstyle.org/) to enforce code style and spot for transgressions and illogical constructs in our Java source files. -The Gradle build has been configured to run these on `java:assembleRelease`. +The Gradle build has been configured to run these on `core:assembleRelease`. It does not run for the Android build yet. You can run just the Checkstyle rules on their own using: @@ -123,18 +123,18 @@ The gradle project files can be imported to create projects in IntelliJ IDEA, Ec #### Importing into IntelliJ -The top-level ably-java project can be imported into IntelliJ IDEA, enabling development of both the java and android projects. This has been tested with IntelliJ IDEA Ultimate 2017.2. To import into IDEA: +The top-level ably-java project can be imported into IntelliJ IDEA, enabling development of both the core and core-android projects. This has been tested with IntelliJ IDEA Ultimate 2017.2. To import into IDEA: - do File->New->Project from Existing Sources... - select ably-java/settings.gradle - in the import dialog, check "Use auto-import" and uncheck "Create separate module per source set" - select "ok" -This will create a project with separate java and android modules. +This will create a project with separate core and core-android modules. Interactive run/debug configurations to execute the unit tests can be created as follows: - select Run->Edit configurations ... -- for the java project, create a new "JUnit" run configuration; or for the android project create a new "Android Instrumented Tests" configuration; +- for the core project, create a new "JUnit" run configuration; or for the core-android project create a new "Android Instrumented Tests" configuration; - select the Class as RealtimeSuite or RestSuite; - select the relevant module for the classpath. @@ -142,34 +142,34 @@ In order to run the Android configuration it is necessary to set up the Android #### Importing into Eclipse -The top-level ably-java project can be imported into Eclipse, enabling development of the java project only. The Eclipse Android development plugin (ADT) is no longer supported. This has been tested with Eclipse Oxygen.2 +The top-level ably-java project can be imported into Eclipse, enabling development of the core project only. The Eclipse Android development plugin (ADT) is no longer supported. This has been tested with Eclipse Oxygen.2 To import into Eclipse: - do File->Import->Gradle->Existing Gradle project; - follow the wizard steps, selecting the ably-java root directory. -This will create two projects in the workspace; one for the top-level ably-java project, and one for the java project. +This will create two projects in the workspace; one for the top-level ably-java project, and one for the core project. -Interactive run/debug configurations for the java project can be created as follows: +Interactive run/debug configurations for the core project can be created as follows: - select Run->Run configurations ... - create a new JUnit configuration -- select the java project; +- select the core project; - select the Class as RealtimeSuite or RestSuite; - select JUnit 4 as the test runner. #### Importing into Android studio -Android studio does not include the components required to support development of the java project, it is not capable of importing the multi-level ably-java gradle project. It is possible to import the android project as a standalone project into Android Studio by deleting the top-level settings.gradle file, which effectively decouples the android and java projects. +Android studio does not include the components required to support development of the core project, it is not capable of importing the multi-level ably-java gradle project. It is possible to import the core-android project as a standalone project into Android Studio by deleting the top-level settings.gradle file, which effectively decouples the core-android and core projects. This has been tested with Android Studio 3.0.1. To import into Android Studio: - do Import project (Gradle, Eclipse ADT, etc); -- select ably-java/android/build.gradle; +- select ably-java/core-android/build.gradle; - select OK to Gradle Sync. -This creates a single android project and module. +This creates a single core-android project and module. Configuration of Run/Debug configurations for running the unit tests on Android is the same as for IntelliJ IDEA (above). @@ -186,9 +186,9 @@ but on Windows there is a batch file: Tests are based on JUnit, and there are separate suites for the REST and Realtime libraries, with gradle tasks for the JRE-specific library: - ./gradlew java:testRestSuite + ./gradlew core:testRestSuite - ./gradlew java:testRealtimeSuite + ./gradlew core:testRealtimeSuite To run tests against a specific host, specify in the environment: @@ -199,12 +199,12 @@ Tests will run against the sandbox environment by default. Tests can be run on the Android-specific library. An Android device must be connected, either a real device or the Android emulator. - ./gradlew android:connectedAndroidTest + ./gradlew core-android:connectedAndroidTest We also have a small, fledgling set of unit tests which do not communicate with Ably's servers. The plan is to expand this collection of tests in due course: - ./gradlew java:runUnitTests + ./gradlew core:runUnitTests ### Interactive push tests @@ -225,7 +225,7 @@ signing.keyId=XXXXXXXX signing.password=ably-debug-key signing.secretKeyRingFile=/Users/username/.ably/ably-java-secring.gpg ``` -- Run `./gradlew android:assembleRelease` or `./gradlew android:assembleDebug`. +- Run `./gradlew core-android:assembleRelease` or `./gradlew core-android:assembleDebug`. ## Using `ably-java` / `ably-android` locally in other projects @@ -262,7 +262,7 @@ This library uses [semantic versioning](http://semver.org/). For each release, t If you've not configured the signing key in your [Gradle properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties) then release builds will complain: - Cannot perform signing task ':java:signArchives' because it has no configured signatory + Cannot perform signing task ':core:signArchives' because it has no configured signatory You need to [configure Signatory credentials](https://docs.gradle.org/current/userguide/signing_plugin.html#sec:signatory_credentials), for example via the `gradle.properties` file in your `GRADLE_USER_HOME` folder (usually `~/.gradle`). diff --git a/README.md b/README.md index a16465305..2a0449358 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index c08c36bea..000000000 --- a/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -POM_ARTIFACT_ID=ably-android -POM_NAME=Ably Android client library SDK -POM_DESCRIPTION=An Android Realtime and REST client library SDK for the Ably platform. -POM_PACKAGING=aar diff --git a/build.gradle.kts b/build.gradle.kts index a98b165b6..398076611 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.test.retry) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.compose) apply false } @@ -30,6 +31,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:jar", + "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() + 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 { diff --git a/android/.gitignore b/core-android/.gitignore similarity index 100% rename from android/.gitignore rename to core-android/.gitignore diff --git a/android/build.gradle.kts b/core-android/build.gradle.kts similarity index 100% rename from android/build.gradle.kts rename to core-android/build.gradle.kts diff --git a/core-android/gradle.properties b/core-android/gradle.properties new file mode 100644 index 000000000..09e1922d6 --- /dev/null +++ b/core-android/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=core-android +POM_NAME=Ably Pub/Sub core (Android) +POM_DESCRIPTION=Internal implementation artifact for Ably's own Pub/Sub device/server packages. Not intended for direct use: depend on io.ably.pubsub:device instead. +POM_PACKAGING=aar diff --git a/android/proguard.txt b/core-android/proguard.txt similarity index 100% rename from android/proguard.txt rename to core-android/proguard.txt diff --git a/android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java b/core-android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java rename to core-android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java diff --git a/android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java b/core-android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java rename to core-android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java diff --git a/android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java b/core-android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java rename to core-android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java diff --git a/android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java b/core-android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java rename to core-android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java diff --git a/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java b/core-android/src/androidTest/java/io/ably/lib/test/android/EventTest.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/test/android/EventTest.java rename to core-android/src/androidTest/java/io/ably/lib/test/android/EventTest.java diff --git a/android/src/androidTest/java/io/ably/lib/test/loader/ArgumentLoader.java b/core-android/src/androidTest/java/io/ably/lib/test/loader/ArgumentLoader.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/test/loader/ArgumentLoader.java rename to core-android/src/androidTest/java/io/ably/lib/test/loader/ArgumentLoader.java diff --git a/android/src/androidTest/java/io/ably/lib/test/loader/ResourceLoader.java b/core-android/src/androidTest/java/io/ably/lib/test/loader/ResourceLoader.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/test/loader/ResourceLoader.java rename to core-android/src/androidTest/java/io/ably/lib/test/loader/ResourceLoader.java diff --git a/android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java b/core-android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java similarity index 100% rename from android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java rename to core-android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java diff --git a/android/src/main/.gitignore b/core-android/src/main/.gitignore similarity index 100% rename from android/src/main/.gitignore rename to core-android/src/main/.gitignore diff --git a/android/src/main/AndroidManifest.xml b/core-android/src/main/AndroidManifest.xml similarity index 100% rename from android/src/main/AndroidManifest.xml rename to core-android/src/main/AndroidManifest.xml diff --git a/android/src/main/java/io/ably/lib/platform/AndroidNetworkConnectivity.java b/core-android/src/main/java/io/ably/lib/platform/AndroidNetworkConnectivity.java similarity index 100% rename from android/src/main/java/io/ably/lib/platform/AndroidNetworkConnectivity.java rename to core-android/src/main/java/io/ably/lib/platform/AndroidNetworkConnectivity.java diff --git a/android/src/main/java/io/ably/lib/platform/Platform.java b/core-android/src/main/java/io/ably/lib/platform/Platform.java similarity index 100% rename from android/src/main/java/io/ably/lib/platform/Platform.java rename to core-android/src/main/java/io/ably/lib/platform/Platform.java diff --git a/android/src/main/java/io/ably/lib/push/ActivationContext.java b/core-android/src/main/java/io/ably/lib/push/ActivationContext.java similarity index 97% rename from android/src/main/java/io/ably/lib/push/ActivationContext.java rename to core-android/src/main/java/io/ably/lib/push/ActivationContext.java index addb7d4eb..849631248 100644 --- a/android/src/main/java/io/ably/lib/push/ActivationContext.java +++ b/core-android/src/main/java/io/ably/lib/push/ActivationContext.java @@ -60,6 +60,7 @@ public void setAbly(AblyRest ably) { this.clientId = ably.auth.clientId; } + @SuppressWarnings("deprecation") // internal push-registration client, not an application entry point AblyRest getAbly() throws AblyException { if(ably != null) { Log.v(TAG, "getAbly(): returning existing Ably instance"); @@ -84,6 +85,7 @@ AblyRest getAbly() throws AblyException { * @return AblyRest instance with device identity token auth. We use this instance to perform * deregistration calls in push activation flow. */ + @SuppressWarnings("deprecation") // internal push-registration client, not an application entry point AblyRest getDeviceIdentityTokenBasedAblyClient(String deviceIdentityToken) throws AblyException { ClientOptions clientOptions = ably.options.copy(); clientOptions.clearAuthOptions(); diff --git a/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java b/core-android/src/main/java/io/ably/lib/push/ActivationStateMachine.java similarity index 100% rename from android/src/main/java/io/ably/lib/push/ActivationStateMachine.java rename to core-android/src/main/java/io/ably/lib/push/ActivationStateMachine.java diff --git a/android/src/main/java/io/ably/lib/push/LocalDevice.java b/core-android/src/main/java/io/ably/lib/push/LocalDevice.java similarity index 100% rename from android/src/main/java/io/ably/lib/push/LocalDevice.java rename to core-android/src/main/java/io/ably/lib/push/LocalDevice.java diff --git a/android/src/main/java/io/ably/lib/push/Push.java b/core-android/src/main/java/io/ably/lib/push/Push.java similarity index 100% rename from android/src/main/java/io/ably/lib/push/Push.java rename to core-android/src/main/java/io/ably/lib/push/Push.java diff --git a/android/src/main/java/io/ably/lib/push/PushChannel.java b/core-android/src/main/java/io/ably/lib/push/PushChannel.java similarity index 100% rename from android/src/main/java/io/ably/lib/push/PushChannel.java rename to core-android/src/main/java/io/ably/lib/push/PushChannel.java diff --git a/android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java b/core-android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java similarity index 100% rename from android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java rename to core-android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java diff --git a/android/src/main/java/io/ably/lib/realtime/Channel.java b/core-android/src/main/java/io/ably/lib/realtime/Channel.java similarity index 100% rename from android/src/main/java/io/ably/lib/realtime/Channel.java rename to core-android/src/main/java/io/ably/lib/realtime/Channel.java diff --git a/android/src/main/java/io/ably/lib/rest/AblyRest.java b/core-android/src/main/java/io/ably/lib/rest/AblyRest.java similarity index 83% rename from android/src/main/java/io/ably/lib/rest/AblyRest.java rename to core-android/src/main/java/io/ably/lib/rest/AblyRest.java index 7f04feb2a..fd853ced0 100644 --- a/android/src/main/java/io/ably/lib/rest/AblyRest.java +++ b/core-android/src/main/java/io/ably/lib/rest/AblyRest.java @@ -20,7 +20,11 @@ public class AblyRest extends AblyBase { * Spec: RSC1 * @param key The Ably API key or token string used to validate the client. * @throws AblyException + * @deprecated use {@code io.ably.pubsub.device.PubSubDevice#clientBuilder()} from the + * {@code io.ably.pubsub:device} artifact instead, which names the side of the + * connection your code runs on. */ + @Deprecated public AblyRest(String key) throws AblyException { super(key, new AndroidPlatformAgentProvider()); } @@ -31,7 +35,11 @@ public AblyRest(String key) throws AblyException { * Spec: RSC1 * @param options A {@link ClientOptions} object to configure the client connection to Ably. * @throws AblyException + * @deprecated use {@code io.ably.pubsub.device.PubSubDevice#clientBuilder()} from the + * {@code io.ably.pubsub:device} artifact instead, which names the side of the + * connection your code runs on. */ + @Deprecated public AblyRest(ClientOptions options) throws AblyException { super(options, new AndroidPlatformAgentProvider()); } diff --git a/android/src/main/java/io/ably/lib/rest/Channel.java b/core-android/src/main/java/io/ably/lib/rest/Channel.java similarity index 100% rename from android/src/main/java/io/ably/lib/rest/Channel.java rename to core-android/src/main/java/io/ably/lib/rest/Channel.java diff --git a/android/src/main/java/io/ably/lib/types/RegistrationToken.java b/core-android/src/main/java/io/ably/lib/types/RegistrationToken.java similarity index 100% rename from android/src/main/java/io/ably/lib/types/RegistrationToken.java rename to core-android/src/main/java/io/ably/lib/types/RegistrationToken.java diff --git a/android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java b/core-android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java similarity index 100% rename from android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java rename to core-android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java diff --git a/android/src/main/java/io/ably/lib/util/IntentUtils.java b/core-android/src/main/java/io/ably/lib/util/IntentUtils.java similarity index 100% rename from android/src/main/java/io/ably/lib/util/IntentUtils.java rename to core-android/src/main/java/io/ably/lib/util/IntentUtils.java diff --git a/android/src/main/resources/META-INF/io/ably/ably-android/verification.properties b/core-android/src/main/resources/META-INF/io/ably/ably-android/verification.properties similarity index 100% rename from android/src/main/resources/META-INF/io/ably/ably-android/verification.properties rename to core-android/src/main/resources/META-INF/io/ably/ably-android/verification.properties diff --git a/java/build.gradle.kts b/core/build.gradle.kts similarity index 100% rename from java/build.gradle.kts rename to core/build.gradle.kts diff --git a/core/gradle.properties b/core/gradle.properties new file mode 100644 index 000000000..81fedc9be --- /dev/null +++ b/core/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=core +POM_NAME=Ably Pub/Sub core (JVM) +POM_DESCRIPTION=Internal implementation artifact for Ably's own Pub/Sub device/server packages. Not intended for direct use: depend on io.ably.pubsub:server (JVM) or io.ably.pubsub:device (Android) instead. +POM_PACKAGING=jar diff --git a/java/src/main/java/io/ably/lib/platform/Platform.java b/core/src/main/java/io/ably/lib/platform/Platform.java similarity index 100% rename from java/src/main/java/io/ably/lib/platform/Platform.java rename to core/src/main/java/io/ably/lib/platform/Platform.java diff --git a/java/src/main/java/io/ably/lib/push/Push.java b/core/src/main/java/io/ably/lib/push/Push.java similarity index 100% rename from java/src/main/java/io/ably/lib/push/Push.java rename to core/src/main/java/io/ably/lib/push/Push.java diff --git a/java/src/main/java/io/ably/lib/realtime/Channel.java b/core/src/main/java/io/ably/lib/realtime/Channel.java similarity index 100% rename from java/src/main/java/io/ably/lib/realtime/Channel.java rename to core/src/main/java/io/ably/lib/realtime/Channel.java diff --git a/java/src/main/java/io/ably/lib/rest/AblyRest.java b/core/src/main/java/io/ably/lib/rest/AblyRest.java similarity index 69% rename from java/src/main/java/io/ably/lib/rest/AblyRest.java rename to core/src/main/java/io/ably/lib/rest/AblyRest.java index 7ab6a3390..682c019ff 100644 --- a/java/src/main/java/io/ably/lib/rest/AblyRest.java +++ b/core/src/main/java/io/ably/lib/rest/AblyRest.java @@ -17,7 +17,11 @@ public class AblyRest extends AblyBase { * Spec: RSC1 * @param key The Ably API key or token string used to validate the client. * @throws AblyException + * @deprecated use {@code io.ably.pubsub.server.PubSubServer#httpClientBuilder()} from the + * {@code io.ably.pubsub:server} artifact instead, which names the side of the + * connection your code runs on. */ + @Deprecated public AblyRest(String key) throws AblyException { super(key, new JavaPlatformAgentProvider()); } @@ -28,7 +32,11 @@ public AblyRest(String key) throws AblyException { * Spec: RSC1 * @param options A {@link ClientOptions} object to configure the client connection to Ably. * @throws AblyException + * @deprecated use {@code io.ably.pubsub.server.PubSubServer#httpClientBuilder()} from the + * {@code io.ably.pubsub:server} artifact instead, which names the side of the + * connection your code runs on. */ + @Deprecated public AblyRest(ClientOptions options) throws AblyException { super(options, new JavaPlatformAgentProvider()); } diff --git a/java/src/main/java/io/ably/lib/rest/Channel.java b/core/src/main/java/io/ably/lib/rest/Channel.java similarity index 100% rename from java/src/main/java/io/ably/lib/rest/Channel.java rename to core/src/main/java/io/ably/lib/rest/Channel.java diff --git a/java/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java b/core/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java similarity index 100% rename from java/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java rename to core/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java diff --git a/java/src/test/java/io/ably/lib/test/loader/ArgumentLoader.java b/core/src/test/java/io/ably/lib/test/loader/ArgumentLoader.java similarity index 100% rename from java/src/test/java/io/ably/lib/test/loader/ArgumentLoader.java rename to core/src/test/java/io/ably/lib/test/loader/ArgumentLoader.java diff --git a/java/src/test/java/io/ably/lib/test/loader/ResourceLoader.java b/core/src/test/java/io/ably/lib/test/loader/ResourceLoader.java similarity index 100% rename from java/src/test/java/io/ably/lib/test/loader/ResourceLoader.java rename to core/src/test/java/io/ably/lib/test/loader/ResourceLoader.java diff --git a/deploy/check-archives.sh b/deploy/check-archives.sh index 7914caedd..2a3d14c19 100755 --- a/deploy/check-archives.sh +++ b/deploy/check-archives.sh @@ -36,8 +36,8 @@ case "${ABLY_VERSION}" in ;; esac -ABLY_JAVA_RELEASE_DIR="${ABLY_HOME}/java/build/release/${ABLY_VERSION}/io/ably/ably-java/${ABLY_VERSION}" -ABLY_ANDROID_RELEASE_DIR="${ABLY_HOME}/android/build/release/${ABLY_VERSION}/io/ably/ably-android/${ABLY_VERSION}" +ABLY_JAVA_RELEASE_DIR="${ABLY_HOME}/core/build/release/${ABLY_VERSION}/io/ably/ably-java/${ABLY_VERSION}" +ABLY_ANDROID_RELEASE_DIR="${ABLY_HOME}/core-android/build/release/${ABLY_VERSION}/io/ably/ably-android/${ABLY_VERSION}" if [ ! -d "${ABLY_JAVA_RELEASE_DIR}" ]; then echo "Directory for Java release ${ABLY_JAVA_RELEASE_DIR} doesn't exist" diff --git a/device/build.gradle.kts b/device/build.gradle.kts new file mode 100644 index 000000000..baa0072de --- /dev/null +++ b/device/build.gradle.kts @@ -0,0 +1,89 @@ +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +kotlin { + explicitApi() + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + publishLibraryVariants("release") + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + + sourceSets { + commonMain.dependencies { + /* + * The two platform artifacts publish the same io.ably.lib.* types, so common code + * compiles against either one. ably-java is the arbitrary pick; each target below + * brings the real one. + */ + compileOnly(project(":core")) + } + androidMain.dependencies { + api(project(":core-android")) + } + jvmMain.dependencies { + api(project(":core")) + } + commonTest.dependencies { + compileOnly(project(":core")) + implementation(kotlin("test")) + } + } +} + +android { + namespace = "io.ably.pubsub.device" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + lint { + abortOnError = false + } + + testOptions { + targetSdk = 34 + /* + * AndroidPlatformAgentProvider reads android.os.Build.VERSION.SDK_INT, which is an unmocked + * stub in local unit tests. Without this it throws instead of returning a default. + */ + unitTests.isReturnDefaultValues = true + } +} + +mavenPublishing { + configure(KotlinMultiplatform(javadocJar = JavadocJar.Empty(), androidVariantsToPublish = listOf("release"))) +} + +/* check.yml invokes `runUnitTests` unqualified across all projects. */ +tasks.register("runUnitTests") { + dependsOn("jvmTest", "testDebugUnitTest") +} diff --git a/device/gradle.properties b/device/gradle.properties new file mode 100644 index 000000000..f4c3d8242 --- /dev/null +++ b/device/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=device +POM_NAME=Ably Pub/Sub device SDK +POM_DESCRIPTION=Ably Pub/Sub SDK for applications running on end-user devices, for Android and the JVM. diff --git a/device/src/androidMain/AndroidManifest.xml b/device/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..a2f47b605 --- /dev/null +++ b/device/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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 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 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)); + } +} diff --git a/device/src/commonMain/kotlin/io/ably/pubsub/device/PubSubDevice.kt b/device/src/commonMain/kotlin/io/ably/pubsub/device/PubSubDevice.kt new file mode 100644 index 000000000..0c0bac2c0 --- /dev/null +++ b/device/src/commonMain/kotlin/io/ably/pubsub/device/PubSubDevice.kt @@ -0,0 +1,424 @@ +package io.ably.pubsub.device + +import io.ably.lib.push.Storage +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.rest.Auth +import io.ably.lib.types.ClientOptions +import io.ably.lib.types.Param +import io.ably.lib.types.ProxyOptions +import io.ably.lib.util.Log +import io.ably.lib.util.Side + +/** + * Entry point for the Ably Pub/Sub SDK for devices: applications running on end-user devices, whose + * connections are counted on accounts with monthly-active-user billing. + * + * Clients built here are the same [AblyRealtime] objects the core SDK has always returned, and + * behave identically. What the artifact adds is the choice itself: the dependency you declare and + * the factory you call state which side of the connection your code runs on, rather than leaving it + * to be inferred. + * + * If your code runs on infrastructure you control, use the `io.ably.pubsub:server` artifact instead. + * + * There is a single door because there is a single choice to make. Connectionless operations — + * history, presence queries, token requests, `request()` and `batchPublish()` — are all available on + * the client this returns. + * + * ``` + * val client = PubSubDevice.clientBuilder() + * .key("xVLyHw.MHOCLg:...") + * .clientId("bob") + * .build() + * ``` + */ +public object PubSubDevice { + + /** + * Creates a builder for a device client. It exposes one method per [ClientOptions] property. + * + * @return a new builder. + */ + @JvmStatic + public fun clientBuilder(): ClientBuilder = ClientBuilder() + + /** + * Builds a device client. Obtain one from [PubSubDevice.clientBuilder]. + */ + public class ClientBuilder internal constructor() { + + /** Accumulates the calls made on this builder; handed to the client as-is by [build]. */ + private val options = ClientOptions() + + /** + * Sets [Auth.AuthOptions.authCallback]. + * + * @param authCallback the value to set. + * @return this builder. + */ + public fun authCallback(authCallback: Auth.TokenCallback): ClientBuilder = apply { options.authCallback = authCallback } + + /** + * Sets [Auth.AuthOptions.authUrl]. + * + * @param authUrl the value to set. + * @return this builder. + */ + public fun authUrl(authUrl: String): ClientBuilder = apply { options.authUrl = authUrl } + + /** + * Sets [Auth.AuthOptions.authMethod]. + * + * @param authMethod the value to set. + * @return this builder. + */ + public fun authMethod(authMethod: String): ClientBuilder = apply { options.authMethod = authMethod } + + /** + * Sets [Auth.AuthOptions.key]. + * + * @param key the value to set. + * @return this builder. + */ + public fun key(key: String): ClientBuilder = apply { options.key = key } + + /** + * Sets [Auth.AuthOptions.token]. + * + * @param token the value to set. + * @return this builder. + */ + public fun token(token: String): ClientBuilder = apply { options.token = token } + + /** + * Sets [Auth.AuthOptions.tokenDetails]. + * + * @param tokenDetails the value to set. + * @return this builder. + */ + public fun tokenDetails(tokenDetails: Auth.TokenDetails): ClientBuilder = apply { options.tokenDetails = tokenDetails } + + /** + * Sets [Auth.AuthOptions.authHeaders]. + * + * @param authHeaders the value to set. + * @return this builder. + */ + public fun authHeaders(authHeaders: Array): ClientBuilder = apply { options.authHeaders = authHeaders } + + /** + * Sets [Auth.AuthOptions.authParams]. + * + * @param authParams the value to set. + * @return this builder. + */ + public fun authParams(authParams: Array): ClientBuilder = apply { options.authParams = authParams } + + /** + * Sets [Auth.AuthOptions.queryTime]. + * + * @param queryTime the value to set. + * @return this builder. + */ + public fun queryTime(queryTime: Boolean): ClientBuilder = apply { options.queryTime = queryTime } + + /** + * Sets [Auth.AuthOptions.useTokenAuth]. + * + * @param useTokenAuth the value to set. + * @return this builder. + */ + public fun useTokenAuth(useTokenAuth: Boolean): ClientBuilder = apply { options.useTokenAuth = useTokenAuth } + + /** + * Sets [ClientOptions.clientId]. + * + * @param clientId the value to set. + * @return this builder. + */ + public fun clientId(clientId: String): ClientBuilder = apply { options.clientId = clientId } + + /** + * Sets [ClientOptions.logLevel]. + * + * @param logLevel the value to set. + * @return this builder. + */ + public fun logLevel(logLevel: Int): ClientBuilder = apply { options.logLevel = logLevel } + + /** + * Sets [ClientOptions.logHandler]. + * + * @param logHandler the value to set. + * @return this builder. + */ + public fun logHandler(logHandler: Log.LogHandler): ClientBuilder = apply { options.logHandler = logHandler } + + /** + * Sets [ClientOptions.tls]. + * + * @param tls the value to set. + * @return this builder. + */ + public fun tls(tls: Boolean): ClientBuilder = apply { options.tls = tls } + + /** + * Sets [ClientOptions.headers]. + * + * @param headers the value to set. + * @return this builder. + */ + public fun headers(headers: Map): ClientBuilder = apply { options.headers = headers } + + /** + * Sets [ClientOptions.restHost]. + * + * @param restHost the value to set. + * @return this builder. + */ + public fun restHost(restHost: String): ClientBuilder = apply { options.restHost = restHost } + + /** + * Sets [ClientOptions.port]. + * + * @param port the value to set. + * @return this builder. + */ + public fun port(port: Int): ClientBuilder = apply { options.port = port } + + /** + * Sets [ClientOptions.tlsPort]. + * + * @param tlsPort the value to set. + * @return this builder. + */ + public fun tlsPort(tlsPort: Int): ClientBuilder = apply { options.tlsPort = tlsPort } + + /** + * Sets [ClientOptions.useBinaryProtocol]. + * + * @param useBinaryProtocol the value to set. + * @return this builder. + */ + public fun useBinaryProtocol(useBinaryProtocol: Boolean): ClientBuilder = apply { options.useBinaryProtocol = useBinaryProtocol } + + /** + * Sets [ClientOptions.proxy]. + * + * @param proxy the value to set. + * @return this builder. + */ + public fun proxy(proxy: ProxyOptions): ClientBuilder = apply { options.proxy = proxy } + + /** + * Sets [ClientOptions.environment]. + * + * @param environment the value to set. + * @return this builder. + */ + public fun environment(environment: String): ClientBuilder = apply { options.environment = environment } + + /** + * Sets [ClientOptions.idempotentRestPublishing]. + * + * @param idempotentRestPublishing the value to set. + * @return this builder. + */ + public fun idempotentRestPublishing(idempotentRestPublishing: Boolean): ClientBuilder = apply { options.idempotentRestPublishing = idempotentRestPublishing } + + /** + * Sets [ClientOptions.httpOpenTimeout]. + * + * @param httpOpenTimeout the value to set. + * @return this builder. + */ + public fun httpOpenTimeout(httpOpenTimeout: Int): ClientBuilder = apply { options.httpOpenTimeout = httpOpenTimeout } + + /** + * Sets [ClientOptions.httpRequestTimeout]. + * + * @param httpRequestTimeout the value to set. + * @return this builder. + */ + public fun httpRequestTimeout(httpRequestTimeout: Int): ClientBuilder = apply { options.httpRequestTimeout = httpRequestTimeout } + + /** + * Sets [ClientOptions.httpMaxRetryDuration]. + * + * @param httpMaxRetryDuration the value to set. + * @return this builder. + */ + public fun httpMaxRetryDuration(httpMaxRetryDuration: Int): ClientBuilder = apply { options.httpMaxRetryDuration = httpMaxRetryDuration } + + /** + * Sets [ClientOptions.httpMaxRetryCount]. + * + * @param httpMaxRetryCount the value to set. + * @return this builder. + */ + public fun httpMaxRetryCount(httpMaxRetryCount: Int): ClientBuilder = apply { options.httpMaxRetryCount = httpMaxRetryCount } + + /** + * Sets [ClientOptions.fallbackHosts]. + * + * @param fallbackHosts the value to set. + * @return this builder. + */ + public fun fallbackHosts(fallbackHosts: Array): ClientBuilder = apply { options.fallbackHosts = fallbackHosts } + + /** + * Sets [ClientOptions.fallbackHostsUseDefault]. + * + * @param fallbackHostsUseDefault the value to set. + * @return this builder. + */ + @Deprecated("Deprecated on ClientOptions itself; use fallbackHosts to supply custom hosts.") + @Suppress("DEPRECATION") + public fun fallbackHostsUseDefault(fallbackHostsUseDefault: Boolean): ClientBuilder = apply { options.fallbackHostsUseDefault = fallbackHostsUseDefault } + + /** + * Sets [ClientOptions.fallbackRetryTimeout]. + * + * @param fallbackRetryTimeout the value to set. + * @return this builder. + */ + public fun fallbackRetryTimeout(fallbackRetryTimeout: Long): ClientBuilder = apply { options.fallbackRetryTimeout = fallbackRetryTimeout } + + /** + * Sets [ClientOptions.defaultTokenParams]. + * + * @param defaultTokenParams the value to set. + * @return this builder. + */ + public fun defaultTokenParams(defaultTokenParams: Auth.TokenParams): ClientBuilder = apply { options.defaultTokenParams = defaultTokenParams } + + /** + * Sets [ClientOptions.asyncHttpThreadpoolSize]. + * + * @param asyncHttpThreadpoolSize the value to set. + * @return this builder. + */ + public fun asyncHttpThreadpoolSize(asyncHttpThreadpoolSize: Int): ClientBuilder = apply { options.asyncHttpThreadpoolSize = asyncHttpThreadpoolSize } + + /** + * Sets [ClientOptions.pushFullWait]. + * + * @param pushFullWait the value to set. + * @return this builder. + */ + public fun pushFullWait(pushFullWait: Boolean): ClientBuilder = apply { options.pushFullWait = pushFullWait } + + /** + * Sets [ClientOptions.localStorage]. + * + * @param localStorage the value to set. + * @return this builder. + */ + public fun localStorage(localStorage: Storage): ClientBuilder = apply { options.localStorage = localStorage } + + /** + * Sets [ClientOptions.addRequestIds]. + * + * @param addRequestIds the value to set. + * @return this builder. + */ + public fun addRequestIds(addRequestIds: Boolean): ClientBuilder = apply { options.addRequestIds = addRequestIds } + + /** + * Sets [ClientOptions.agents]. + * + * @param agents the value to set. + * @return this builder. + */ + public fun agents(agents: Map): ClientBuilder = apply { options.agents = agents } + + /** + * Sets [ClientOptions.realtimeHost]. + * + * @param realtimeHost the value to set. + * @return this builder. + */ + public fun realtimeHost(realtimeHost: String): ClientBuilder = apply { options.realtimeHost = realtimeHost } + + /** + * Sets [ClientOptions.autoConnect]. + * + * @param autoConnect the value to set. + * @return this builder. + */ + public fun autoConnect(autoConnect: Boolean): ClientBuilder = apply { options.autoConnect = autoConnect } + + /** + * Sets [ClientOptions.queueMessages]. + * + * @param queueMessages the value to set. + * @return this builder. + */ + public fun queueMessages(queueMessages: Boolean): ClientBuilder = apply { options.queueMessages = queueMessages } + + /** + * Sets [ClientOptions.echoMessages]. + * + * @param echoMessages the value to set. + * @return this builder. + */ + public fun echoMessages(echoMessages: Boolean): ClientBuilder = apply { options.echoMessages = echoMessages } + + /** + * Sets [ClientOptions.recover]. + * + * @param recover the value to set. + * @return this builder. + */ + public fun recover(recover: String): ClientBuilder = apply { options.recover = recover } + + /** + * Sets [ClientOptions.realtimeRequestTimeout]. + * + * @param realtimeRequestTimeout the value to set. + * @return this builder. + */ + public fun realtimeRequestTimeout(realtimeRequestTimeout: Long): ClientBuilder = apply { options.realtimeRequestTimeout = realtimeRequestTimeout } + + /** + * Sets [ClientOptions.disconnectedRetryTimeout]. + * + * @param disconnectedRetryTimeout the value to set. + * @return this builder. + */ + public fun disconnectedRetryTimeout(disconnectedRetryTimeout: Long): ClientBuilder = apply { options.disconnectedRetryTimeout = disconnectedRetryTimeout } + + /** + * Sets [ClientOptions.suspendedRetryTimeout]. + * + * @param suspendedRetryTimeout the value to set. + * @return this builder. + */ + public fun suspendedRetryTimeout(suspendedRetryTimeout: Long): ClientBuilder = apply { options.suspendedRetryTimeout = suspendedRetryTimeout } + + /** + * Sets [ClientOptions.channelRetryTimeout]. + * + * @param channelRetryTimeout the value to set. + * @return this builder. + */ + public fun channelRetryTimeout(channelRetryTimeout: Int): ClientBuilder = apply { options.channelRetryTimeout = channelRetryTimeout } + + /** + * Sets [ClientOptions.transportParams]. + * + * @param transportParams the value to set. + * @return this builder. + */ + public fun transportParams(transportParams: Array): ClientBuilder = apply { options.transportParams = transportParams } + + /** + * Builds the client, which connects immediately unless [autoConnect] was set to false. + * + * @return an [AblyRealtime]. + * @throws io.ably.lib.types.AblyException if the options are invalid, for example if no + * authentication parameters were supplied. + */ + @Suppress("DEPRECATION") // this factory is the replacement for that constructor + public fun build(): AblyRealtime = AblyRealtime(Side.injectSideAgent(options, Side.DEVICE_AGENT_IDENTIFIER)) + } +} diff --git a/device/src/commonTest/kotlin/io/ably/pubsub/device/PubSubDeviceTest.kt b/device/src/commonTest/kotlin/io/ably/pubsub/device/PubSubDeviceTest.kt new file mode 100644 index 000000000..c2c088e11 --- /dev/null +++ b/device/src/commonTest/kotlin/io/ably/pubsub/device/PubSubDeviceTest.kt @@ -0,0 +1,62 @@ +package io.ably.pubsub.device + +import io.ably.lib.util.Side +import org.junit.Assert +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 `/suffix`) reappears on it. + */ +class PubSubDeviceTest { + @Test + @Throws(Exception::class) + fun client_stampsDeviceAgent() { + val client = PubSubDevice.clientBuilder().key(FAKE_KEY).autoConnect(false).build() + assertDeviceFlag(client.options.agents) + } + + @Test + @Throws(Exception::class) + fun keyString_isAcceptedAndDisambiguatedAsKey() { + val builtOptions = PubSubDevice.clientBuilder().key(FAKE_KEY).autoConnect(false).build().options + Assert.assertEquals(FAKE_KEY, builtOptions.key) + Assert.assertNull(builtOptions.token) + assertDeviceFlag(builtOptions.agents) + } + + @Test + @Throws(Exception::class) + fun callerAgentEntries_arePreserved_andCannotOverrideTheSideEntry() { + val callerAgents: Map = mapOf( + "some-sdk" to "1.2.3", + Side.DEVICE_AGENT_IDENTIFIER to "not-the-real-form", + ) + + val client = PubSubDevice.clientBuilder().key(FAKE_KEY).autoConnect(false).agents(callerAgents).build() + Assert.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) + + Assert.assertEquals("not-the-real-form", callerAgents.get(Side.DEVICE_AGENT_IDENTIFIER)) + } + + companion object { + private const val FAKE_KEY = "fakeAppId.fakeKeyId:fakeKeySecret" + + /** The stamped entry is present as a versionless flag, and the other side's is absent. */ + private fun assertDeviceFlag(agents: MutableMap) { + Assert.assertTrue("expected the device side flag", agents.containsKey(Side.DEVICE_AGENT_IDENTIFIER)) + Assert.assertNull("the side flag is versionless", agents.get(Side.DEVICE_AGENT_IDENTIFIER)) + Assert.assertFalse( + "a device client must not carry the server entry", + agents.containsKey(Side.SERVER_AGENT_IDENTIFIER) + ) + } + } +} diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index f9c566cf6..472bc8316 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -68,7 +68,7 @@ dependencies { implementation(libs.ktor.client.cio) implementation(project(":liveobjects")) - implementation(project(":android")) + implementation(project(":core-android")) implementation(libs.navigation.compose) diff --git a/gradle.properties b/gradle.properties index 687397416..946d53943 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ -GROUP=io.ably -VERSION_NAME=1.8.0 +GROUP=io.ably.pubsub +VERSION_NAME=2.0.0 POM_INCEPTION_YEAR=2015 POM_URL=https://github.com/ably/ably-java POM_SCM_URL=https://github.com/ably/ably-java/ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e935db93..8664438d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,4 +91,5 @@ lombok = { id = "io.freefair.lombok", version.ref = "lombok" } test-retry = { id = "org.gradle.test-retry", version.ref = "test-retry" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/java/gradle.properties b/java/gradle.properties deleted file mode 100644 index bff480295..000000000 --- a/java/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -POM_ARTIFACT_ID=ably-java -POM_NAME=Ably Java client library SDK -POM_DESCRIPTION=A Java Realtime and REST client library SDK for the Ably platform. -POM_PACKAGING=jar diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index b991ed63b..efd8bcad6 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -56,7 +56,12 @@ public class AblyRealtime extends AblyRest { * Spec: RSC1 * @param key The Ably API key or token string used to validate the client. * @throws AblyException + * @deprecated use {@code io.ably.pubsub.device.PubSubDevice#clientBuilder()} from the + * {@code io.ably.pubsub:device} artifact if this code runs on an end-user device, or + * {@code io.ably.pubsub.server.PubSubServer#realtimeClientBuilder()} from the + * {@code io.ably.pubsub:server} artifact if it runs on infrastructure you control. */ + @Deprecated public AblyRealtime(String key) throws AblyException { this(new ClientOptions(key)); } @@ -67,7 +72,12 @@ public AblyRealtime(String key) throws AblyException { * Spec: RSC1 * @param options A {@link ClientOptions} object. * @throws AblyException + * @deprecated use {@code io.ably.pubsub.device.PubSubDevice#clientBuilder()} from the + * {@code io.ably.pubsub:device} artifact if this code runs on an end-user device, or + * {@code io.ably.pubsub.server.PubSubServer#realtimeClientBuilder()} from the + * {@code io.ably.pubsub:server} artifact if it runs on infrastructure you control. */ + @Deprecated public AblyRealtime(ClientOptions options) throws AblyException { super(options); final InternalChannels channels = new InternalChannels(); 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/main/java/io/ably/lib/util/Side.java b/lib/src/main/java/io/ably/lib/util/Side.java new file mode 100644 index 000000000..50379e22c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/Side.java @@ -0,0 +1,67 @@ +package io.ably.lib.util; + +import io.ably.lib.types.ClientOptions; + +import java.util.HashMap; +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. + *

+ * 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"; + + /** + * Injects a side agent identifier into the provided {@code ClientOptions} instance. + * The method adds the specified agent identifier to the {@code agents} map in the given + * {@code ClientOptions} object. If the {@code agents} map does not exist, a new one is created. + * + * @param options the {@code ClientOptions} instance to which the agent identifier will be added. + * If {@code null}, the method returns {@code null}. + * @param identifier the agent identifier to be injected into the {@code ClientOptions} instance. + * This identifier is used for specifying and classifying the type of client. + * @return the updated {@code ClientOptions} instance with the injected agent identifier. + * Returns {@code null} if the input {@code options} is {@code null}. + */ + public static ClientOptions injectSideAgent(ClientOptions options, String identifier) { + if (options == null) { + return null; + } + Map agents = new HashMap<>(); + if (options.agents != null) { + agents.putAll(options.agents); + } + agents.put(identifier, null); + options.agents = agents; + return options; + } +} 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 1226309c5..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/1.8.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/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index 9d6ad9420..eb3d06203 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -11,11 +11,11 @@ repositories { } dependencies { - compileOnly(project(":java")) + compileOnly(project(":core")) implementation(libs.bundles.common) implementation(libs.coroutine.core) - testImplementation(project(":java")) + testImplementation(project(":core")) testImplementation(kotlin("test")) testImplementation(libs.bundles.kotlin.tests) } diff --git a/pubsub-adapter/build.gradle.kts b/pubsub-adapter/build.gradle.kts index 66ab1f09f..31a4e1a8c 100644 --- a/pubsub-adapter/build.gradle.kts +++ b/pubsub-adapter/build.gradle.kts @@ -9,9 +9,9 @@ kotlin { } dependencies { - compileOnly(project(":java")) + compileOnly(project(":core")) testImplementation(kotlin("test")) - testImplementation(project(":java")) + testImplementation(project(":core")) testImplementation(libs.nanohttpd) testImplementation(libs.coroutine.core) testImplementation(libs.coroutine.test) 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..ecbcb1117 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.maven.publish) + checkstyle + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + /* + * api, not implementation: the factories return io.ably.lib.rest.AblyRest and + * io.ably.lib.realtime.AblyRealtime, and consumers configure them with io.ably.lib.types.*, + * so the core is part of this module's compile-time ABI. + */ + api(project(":core")) + testImplementation(libs.bundles.tests) +} + +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..976318088 --- /dev/null +++ b/server/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=server +POM_NAME=Ably Pub/Sub SDK for servers +POM_DESCRIPTION=Ably Pub/Sub SDK for server-side JVM applications. +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..2c97da44d --- /dev/null +++ b/server/src/main/java/io/ably/pubsub/server/PubSubServer.java @@ -0,0 +1,633 @@ +package io.ably.pubsub.server; + +import io.ably.lib.push.Storage; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.rest.Auth; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Param; +import io.ably.lib.types.ProxyOptions; +import io.ably.lib.util.Log.LogHandler; +import io.ably.lib.util.Side; + +import java.util.Map; + +/** + * Entry point for the Ably Pub/Sub SDK for servers: applications running on infrastructure you + * control, whose traffic is exempt from monthly-active-user billing. + *

+ * Clients built here are the same {@link AblyRest} and {@link AblyRealtime} objects the core SDK + * has always returned, and behave identically. What the artifact adds is the choice itself: the + * dependency you declare and the factory you call state which side of the connection your code runs + * on, rather than leaving it to be inferred. + *

+ * If your code runs on an end-user device, use the {@code io.ably.pubsub:device} artifact instead. + * + *

{@code
+ * AblyRest http = PubSubServer.httpClientBuilder()
+ *     .key("xVLyHw.MHOCLg:...")
+ *     .build();
+ *
+ * AblyRealtime realtime = PubSubServer.realtimeClientBuilder()
+ *     .key("xVLyHw.MHOCLg:...")
+ *     .echoMessages(false)
+ *     .build();
+ * }
+ */ +public final class PubSubServer { + + private PubSubServer() { + } + + /** + * Creates a builder for a stateless HTTP client, which talks to Ably over plain HTTP requests + * without holding a connection open. + *

+ * This is the right choice for most server-side work: publishing, reading history, querying + * presence, issuing tokens and push administration. + * + * @return a new builder. + */ + public static HttpClientBuilder httpClientBuilder() { + return new HttpClientBuilder(); + } + + /** + * Creates a builder for a realtime client, which holds a persistent connection to Ably and can + * subscribe to messages and presence as they happen. + *

+ * Choose this over {@link #httpClientBuilder()} only when the server needs to receive messages + * live, rather than only send them. + * + * @return a new builder. + */ + public static RealtimeClientBuilder realtimeClientBuilder() { + return new RealtimeClientBuilder(); + } + + /** + * The options common to both server-side clients. One method per {@link ClientOptions} + * property that can affect a client of either kind; {@link RealtimeClientBuilder} adds the + * properties that only mean something for a persistent connection. + * + * @param the concrete builder type, so that chaining preserves it. + */ + public abstract static class ClientBuilder> { + + /** Accumulates the calls made on this builder; handed to the client as-is by build(). */ + final ClientOptions options = new ClientOptions(); + + ClientBuilder() { + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + /** + * Sets {@link Auth.AuthOptions#authCallback}. + * + * @param authCallback the value to set. + * @return this builder. + */ + public T authCallback(Auth.TokenCallback authCallback) { + options.authCallback = authCallback; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#authUrl}. + * + * @param authUrl the value to set. + * @return this builder. + */ + public T authUrl(String authUrl) { + options.authUrl = authUrl; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#authMethod}. + * + * @param authMethod the value to set. + * @return this builder. + */ + public T authMethod(String authMethod) { + options.authMethod = authMethod; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#key}. + * + * @param key the value to set. + * @return this builder. + */ + public T key(String key) { + options.key = key; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#token}. + * + * @param token the value to set. + * @return this builder. + */ + public T token(String token) { + options.token = token; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#tokenDetails}. + * + * @param tokenDetails the value to set. + * @return this builder. + */ + public T tokenDetails(Auth.TokenDetails tokenDetails) { + options.tokenDetails = tokenDetails; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#authHeaders}. + * + * @param authHeaders the value to set. + * @return this builder. + */ + public T authHeaders(Param[] authHeaders) { + options.authHeaders = authHeaders; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#authParams}. + * + * @param authParams the value to set. + * @return this builder. + */ + public T authParams(Param[] authParams) { + options.authParams = authParams; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#queryTime}. + * + * @param queryTime the value to set. + * @return this builder. + */ + public T queryTime(boolean queryTime) { + options.queryTime = queryTime; + return self(); + } + + /** + * Sets {@link Auth.AuthOptions#useTokenAuth}. + * + * @param useTokenAuth the value to set. + * @return this builder. + */ + public T useTokenAuth(boolean useTokenAuth) { + options.useTokenAuth = useTokenAuth; + return self(); + } + + /** + * Sets {@link ClientOptions#clientId}. + * + * @param clientId the value to set. + * @return this builder. + */ + public T clientId(String clientId) { + options.clientId = clientId; + return self(); + } + + /** + * Sets {@link ClientOptions#logLevel}. + * + * @param logLevel the value to set. + * @return this builder. + */ + public T logLevel(int logLevel) { + options.logLevel = logLevel; + return self(); + } + + /** + * Sets {@link ClientOptions#logHandler}. + * + * @param logHandler the value to set. + * @return this builder. + */ + public T logHandler(LogHandler logHandler) { + options.logHandler = logHandler; + return self(); + } + + /** + * Sets {@link ClientOptions#tls}. + * + * @param tls the value to set. + * @return this builder. + */ + public T tls(boolean tls) { + options.tls = tls; + return self(); + } + + /** + * Sets {@link ClientOptions#headers}. + * + * @param headers the value to set. + * @return this builder. + */ + public T headers(Map headers) { + options.headers = headers; + return self(); + } + + /** + * Sets {@link ClientOptions#restHost}. + * + * @param restHost the value to set. + * @return this builder. + */ + public T restHost(String restHost) { + options.restHost = restHost; + return self(); + } + + /** + * Sets {@link ClientOptions#port}. + * + * @param port the value to set. + * @return this builder. + */ + public T port(int port) { + options.port = port; + return self(); + } + + /** + * Sets {@link ClientOptions#tlsPort}. + * + * @param tlsPort the value to set. + * @return this builder. + */ + public T tlsPort(int tlsPort) { + options.tlsPort = tlsPort; + return self(); + } + + /** + * Sets {@link ClientOptions#useBinaryProtocol}. + * + * @param useBinaryProtocol the value to set. + * @return this builder. + */ + public T useBinaryProtocol(boolean useBinaryProtocol) { + options.useBinaryProtocol = useBinaryProtocol; + return self(); + } + + /** + * Sets {@link ClientOptions#proxy}. + * + * @param proxy the value to set. + * @return this builder. + */ + public T proxy(ProxyOptions proxy) { + options.proxy = proxy; + return self(); + } + + /** + * Sets {@link ClientOptions#environment}. + * + * @param environment the value to set. + * @return this builder. + */ + public T environment(String environment) { + options.environment = environment; + return self(); + } + + /** + * Sets {@link ClientOptions#idempotentRestPublishing}. + * + * @param idempotentRestPublishing the value to set. + * @return this builder. + */ + public T idempotentRestPublishing(boolean idempotentRestPublishing) { + options.idempotentRestPublishing = idempotentRestPublishing; + return self(); + } + + /** + * Sets {@link ClientOptions#httpOpenTimeout}. + * + * @param httpOpenTimeout the value to set. + * @return this builder. + */ + public T httpOpenTimeout(int httpOpenTimeout) { + options.httpOpenTimeout = httpOpenTimeout; + return self(); + } + + /** + * Sets {@link ClientOptions#httpRequestTimeout}. + * + * @param httpRequestTimeout the value to set. + * @return this builder. + */ + public T httpRequestTimeout(int httpRequestTimeout) { + options.httpRequestTimeout = httpRequestTimeout; + return self(); + } + + /** + * Sets {@link ClientOptions#httpMaxRetryDuration}. + * + * @param httpMaxRetryDuration the value to set. + * @return this builder. + */ + public T httpMaxRetryDuration(int httpMaxRetryDuration) { + options.httpMaxRetryDuration = httpMaxRetryDuration; + return self(); + } + + /** + * Sets {@link ClientOptions#httpMaxRetryCount}. + * + * @param httpMaxRetryCount the value to set. + * @return this builder. + */ + public T httpMaxRetryCount(int httpMaxRetryCount) { + options.httpMaxRetryCount = httpMaxRetryCount; + return self(); + } + + /** + * Sets {@link ClientOptions#fallbackHosts}. + * + * @param fallbackHosts the value to set. + * @return this builder. + */ + public T fallbackHosts(String[] fallbackHosts) { + options.fallbackHosts = fallbackHosts; + return self(); + } + + /** + * Sets {@link ClientOptions#fallbackHostsUseDefault}. + * + * @param fallbackHostsUseDefault the value to set. + * @return this builder. + * @deprecated deprecated on {@link ClientOptions} itself; use + * {@link #fallbackHosts(String[])} to supply custom hosts. + */ + @Deprecated + public T fallbackHostsUseDefault(boolean fallbackHostsUseDefault) { + options.fallbackHostsUseDefault = fallbackHostsUseDefault; + return self(); + } + + /** + * Sets {@link ClientOptions#fallbackRetryTimeout}. + * + * @param fallbackRetryTimeout the value to set. + * @return this builder. + */ + public T fallbackRetryTimeout(long fallbackRetryTimeout) { + options.fallbackRetryTimeout = fallbackRetryTimeout; + return self(); + } + + /** + * Sets {@link ClientOptions#defaultTokenParams}. + * + * @param defaultTokenParams the value to set. + * @return this builder. + */ + public T defaultTokenParams(Auth.TokenParams defaultTokenParams) { + options.defaultTokenParams = defaultTokenParams; + return self(); + } + + /** + * Sets {@link ClientOptions#asyncHttpThreadpoolSize}. + * + * @param asyncHttpThreadpoolSize the value to set. + * @return this builder. + */ + public T asyncHttpThreadpoolSize(int asyncHttpThreadpoolSize) { + options.asyncHttpThreadpoolSize = asyncHttpThreadpoolSize; + return self(); + } + + /** + * Sets {@link ClientOptions#pushFullWait}. + * + * @param pushFullWait the value to set. + * @return this builder. + */ + public T pushFullWait(boolean pushFullWait) { + options.pushFullWait = pushFullWait; + return self(); + } + + /** + * Sets {@link ClientOptions#localStorage}. + * + * @param localStorage the value to set. + * @return this builder. + */ + public T localStorage(Storage localStorage) { + options.localStorage = localStorage; + return self(); + } + + /** + * Sets {@link ClientOptions#addRequestIds}. + * + * @param addRequestIds the value to set. + * @return this builder. + */ + public T addRequestIds(boolean addRequestIds) { + options.addRequestIds = addRequestIds; + return self(); + } + + /** + * Sets {@link ClientOptions#agents}. + * + * @param agents the value to set. + * @return this builder. + */ + public T agents(Map agents) { + options.agents = agents; + return self(); + } + } + + /** + * Builds a stateless HTTP client. Obtain one from {@link PubSubServer#httpClientBuilder()}. + *

+ * Deliberately does not expose the realtime-only options, since an {@link AblyRest} never + * opens a connection for them to apply to. + */ + public static final class HttpClientBuilder extends ClientBuilder { + + HttpClientBuilder() { + } + + /** + * Builds the client. + * + * @return an {@link AblyRest}. + * @throws AblyException if the options are invalid, for example if no authentication + * parameters were supplied. + */ + @SuppressWarnings("deprecation") // this factory is the replacement for that constructor + public AblyRest build() throws AblyException { + return new AblyRest(Side.injectSideAgent(options, Side.SERVER_AGENT_IDENTIFIER)); + } + } + + /** + * Builds a realtime client. Obtain one from {@link PubSubServer#realtimeClientBuilder()}. + */ + public static final class RealtimeClientBuilder extends ClientBuilder { + + RealtimeClientBuilder() { + } + + /** + * Sets {@link ClientOptions#realtimeHost}. + * + * @param realtimeHost the value to set. + * @return this builder. + */ + public RealtimeClientBuilder realtimeHost(String realtimeHost) { + options.realtimeHost = realtimeHost; + return this; + } + + /** + * Sets {@link ClientOptions#autoConnect}. + * + * @param autoConnect the value to set. + * @return this builder. + */ + public RealtimeClientBuilder autoConnect(boolean autoConnect) { + options.autoConnect = autoConnect; + return this; + } + + /** + * Sets {@link ClientOptions#queueMessages}. + * + * @param queueMessages the value to set. + * @return this builder. + */ + public RealtimeClientBuilder queueMessages(boolean queueMessages) { + options.queueMessages = queueMessages; + return this; + } + + /** + * Sets {@link ClientOptions#echoMessages}. + * + * @param echoMessages the value to set. + * @return this builder. + */ + public RealtimeClientBuilder echoMessages(boolean echoMessages) { + options.echoMessages = echoMessages; + return this; + } + + /** + * Sets {@link ClientOptions#recover}. + * + * @param recover the value to set. + * @return this builder. + */ + public RealtimeClientBuilder recover(String recover) { + options.recover = recover; + return this; + } + + /** + * Sets {@link ClientOptions#realtimeRequestTimeout}. + * + * @param realtimeRequestTimeout the value to set. + * @return this builder. + */ + public RealtimeClientBuilder realtimeRequestTimeout(long realtimeRequestTimeout) { + options.realtimeRequestTimeout = realtimeRequestTimeout; + return this; + } + + /** + * Sets {@link ClientOptions#disconnectedRetryTimeout}. + * + * @param disconnectedRetryTimeout the value to set. + * @return this builder. + */ + public RealtimeClientBuilder disconnectedRetryTimeout(long disconnectedRetryTimeout) { + options.disconnectedRetryTimeout = disconnectedRetryTimeout; + return this; + } + + /** + * Sets {@link ClientOptions#suspendedRetryTimeout}. + * + * @param suspendedRetryTimeout the value to set. + * @return this builder. + */ + public RealtimeClientBuilder suspendedRetryTimeout(long suspendedRetryTimeout) { + options.suspendedRetryTimeout = suspendedRetryTimeout; + return this; + } + + /** + * Sets {@link ClientOptions#channelRetryTimeout}. + * + * @param channelRetryTimeout the value to set. + * @return this builder. + */ + public RealtimeClientBuilder channelRetryTimeout(int channelRetryTimeout) { + options.channelRetryTimeout = channelRetryTimeout; + return this; + } + + /** + * Sets {@link ClientOptions#transportParams}. + * + * @param transportParams the value to set. + * @return this builder. + */ + public RealtimeClientBuilder transportParams(Param[] transportParams) { + options.transportParams = transportParams; + return this; + } + + /** + * Builds the client, which connects immediately unless {@link #autoConnect(boolean)} was + * set to false. + * + * @return an {@link AblyRealtime}. + * @throws AblyException if the options are invalid, for example if no authentication + * parameters were supplied. + */ + @SuppressWarnings("deprecation") // this factory is the replacement for that constructor + public AblyRealtime build() throws AblyException { + return new AblyRealtime(Side.injectSideAgent(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 new file mode 100644 index 000000000..055269878 --- /dev/null +++ b/server/src/test/java/io/ably/pubsub/server/PubSubServerTest.java @@ -0,0 +1,175 @@ +package io.ably.pubsub.server; + +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.util.Side; +import org.junit.Test; + +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * 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"; + + /** + * 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().key(FAKE_KEY).build(); + assertServerFlag(client.options.agents); + } + + @Test + public void realtimeClient_stampsServerAgent() throws AblyException { + AblyRealtime client = PubSubServer + .realtimeClientBuilder() + .key(FAKE_KEY) + .autoConnect(false) + .build(); + assertServerFlag(client.options.agents); + } + + @Test + public void key_isCarriedAsKeyAlone() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder().key(FAKE_KEY).build(); + assertEquals(FAKE_KEY, client.options.key); + assertNull(client.options.token); + assertServerFlag(client.options.agents); + } + + @Test + public void token_isCarriedAsTokenAlone() throws AblyException { + AblyRest client = PubSubServer.httpClientBuilder().token(FAKE_TOKEN).build(); + assertEquals(FAKE_TOKEN, client.options.token); + assertNull(client.options.key); + assertServerFlag(client.options.agents); + } + + @Test + public void callerAgentEntries_arePreserved() throws AblyException { + Map agents = new HashMap<>(); + agents.put("some-sdk", "1.2.3"); + AblyRest client = PubSubServer + .httpClientBuilder() + .key(FAKE_KEY) + .agents(agents) + .build(); + assertEquals("1.2.3", client.options.agents.get("some-sdk")); + assertServerFlag(client.options.agents); + } + + @Test + public void callerCannotOverrideTheSideEntry() throws AblyException { + Map agents = new HashMap<>(); + agents.put(Side.SERVER_AGENT_IDENTIFIER, "not-the-real-form"); + AblyRest client = PubSubServer + .httpClientBuilder() + .key(FAKE_KEY) + .agents(agents) + .build(); + // The stamp replaces the caller's value: the flag is present and back to versionless. + assertServerFlag(client.options.agents); + } + + @Test + public void callersAgentsMap_isNotMutated() throws AblyException { + Map callerAgents = new HashMap<>(); + callerAgents.put("some-sdk", "1.2.3"); + AblyRest client = PubSubServer + .httpClientBuilder() + .key(FAKE_KEY) + .agents(callerAgents) + .build(); + assertNotSame("the stamp must go into a copy, not the caller's map", + callerAgents, client.options.agents); + assertEquals(1, callerAgents.size()); + assertFalse(callerAgents.containsKey(Side.SERVER_AGENT_IDENTIFIER)); + } + + @Test + public void noAuthParameters_getTheCoreConstructorsOwnError() { + try { + PubSubServer.httpClientBuilder().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 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 { + 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 { + AblyRest client = PubSubServer + .httpClientBuilder() + .key(FAKE_KEY) + .tls(false) + .restHost("127.0.0.1") + .port(httpServer.getAddress().getPort()) + .build(); + client.time(); + + String agentHeader = observedAgentHeader.get(); + 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 (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, + agentHeader.contains(Side.SERVER_AGENT_IDENTIFIER + "/")); + assertTrue("missing core base identifier in: " + agentHeader, + agentHeader.contains("ably-pubsub-java/")); + } finally { + httpServer.stop(0); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index dfd7150f4..3b23e33dc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,10 +6,12 @@ pluginManagement { } } -rootProject.name = "ably-java" +rootProject.name = "ably-pubsub-java" -include("java") -include("android") +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..147f74c27 --- /dev/null +++ b/shared/src/main/java/io/ably/pubsub/internal/Side.java @@ -0,0 +1,103 @@ +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. + *

+ * 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/} 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 + * 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 agents = new LinkedHashMap<>(); + if (options.agents != null) { + agents.putAll(options.agents); + } + agents.put(identifier, null); + stamped.agents = agents; + return stamped; + } + + /** + * 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. + * @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) + throws AblyException { + ClientOptions options = new ClientOptions(keyOrToken); + options.agents = new LinkedHashMap<>(); + options.agents.put(identifier, null); + return options; + } +} diff --git a/uts/README.md b/uts/README.md index 462fd87a0..b44314635 100644 --- a/uts/README.md +++ b/uts/README.md @@ -181,7 +181,7 @@ everything lives under `uts/src/test/`. plugins { alias(libs.plugins.kotlin.jvm) } dependencies { - testImplementation(project(":java")) // the SDK under test + testImplementation(project(":core")) // the SDK under test testImplementation(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter-params") // @ParameterizedTest / @ValueSource (version from the JUnit BOM) @@ -210,7 +210,7 @@ Takeaways: - It depends on `:java` (the SDK) and `:network-client-core` (the pluggable transport interfaces the mocks implement). - The `--add-opens java.base/java.time` and `java.base/java.lang` flags grant reflective access into - those JDK packages for the test runtime. They mirror the same flags set in `java/build.gradle.kts` + those JDK packages for the test runtime. They mirror the same flags set in `core/build.gradle.kts` for the SDK's own test module (which additionally opens `java.net` and `java.lang.reflect`). - A system property carries an optional path to a **locally built** proxy binary (so you can test against an unreleased proxy). diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 4eca7f9c4..5b15d625e 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } dependencies { - testImplementation(project(":java")) + testImplementation(project(":core")) testImplementation(project(":network-client-core")) // Runtime-only so compile-time stays decoupled from the plugin internals; the LiveObjects test // helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection.