diff --git a/.ably/capabilities.yaml b/.ably/capabilities.yaml new file mode 100644 index 000000000..a2968b60c --- /dev/null +++ b/.ably/capabilities.yaml @@ -0,0 +1,123 @@ +%YAML 1.2 +--- +common-version: 1.2.0 +compliance: + Agent Identifier: + Agents: + Operating System: + .variants: Android + Runtime: + .variants: JRE + Authentication: + API Key: + Token: + Callback: + Literal: + URL: + Query Time: + Debugging: + Error Information: + Logs: + Protocol: + JSON: + Maximum Message Size: + MessagePack: + Realtime: + Authentication: + Get Confirmed Client Identifier: + Channel: + Attach: + Encryption: + History: + Mode: + Presence: + Enter: + Client: + Get: + History: + Subscribe: + Update: + Client: + Publish: + Retry Timeout: + State Events: + Subscribe: + Deltas: + Rewind: + Connection: + Get Identifier: + Lifecycle Control: + Ping: + Recovery: + State Events: + Message Echoes: + Message Queuing: + Push Notifications: + .variants: Android + Local Device State: + Transport Parameters: + REST: + Authentication: + Authorize: + Create Token Request: + Get Client Identifier: + Request Token: + Channel: + Encryption: + Get: + History: + Name: + Presence: + History: + Member List: + Publish: + Idempotence: + Push Notifications: + List Subscriptions: + Subscribe: + Release: + Opaque Request: + Push Notifications Administration: + Channel Subscription: + List: + List Channels: + Remove: + Save: + Device Registration: + Get: + List: + Remove: + Save: + Publish: + Request Identifiers: + .caveats: | + Returned `ErrorInfo` instances for failed requests do not include the request identifier. + We will fix this under https://github.com/ably/ably-java/issues/843. + Request Timeout: + Service: + Get Time: + Statistics: + Query: + Support Hyperlink on Request Failure: + Service: + Environment: + Fallbacks: + Hosts: + Internet Up Check: + Retry Count: + Retry Timeout: + Host: + Testing: + Disable TLS: + TCP Insecure Port: + TCP Secure Port: + Transport: + Connection Open Timeout: + Proxy: +variants: + Android: + .synopsis: | + Builds `aar` artifact(s) for use by applications running on the Android operating system. + JRE: + .synopsis: | + Builds `jar` artifact(s) for use by applications running in a Java Runtime Environment (JRE). diff --git a/.editorconfig b/.editorconfig index 0fdd49060..d107cd585 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ charset = utf-8 indent_style = space indent_size = 2 -[*.{java,groovy,gradle}] +[*.{java,groovy,gradle,kts}] indent_size = 4 [*.md] diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 000000000..0ae15c491 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,22 @@ +name: Check + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests diff --git a/.github/workflows/emulate.yml b/.github/workflows/emulate.yml new file mode 100644 index 000000000..6b07c3824 --- /dev/null +++ b/.github/workflows/emulate.yml @@ -0,0 +1,48 @@ +name: Emulate + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + android-api-level: [ 19, 21, 24, 29 ] + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.android-api-level }} + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + # Print emulator logs if tests fail + script: ./gradlew :android:connectedAndroidTest || (adb logcat -d System.out:I && exit 1) + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: android-build-reports-${{ matrix.android-api-level }} + path: android/build/reports/ diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml new file mode 100644 index 000000000..bf45ed810 --- /dev/null +++ b/.github/workflows/features.yml @@ -0,0 +1,14 @@ +name: Features + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + uses: ably/features/.github/workflows/sdk-features.yml@main + with: + repository-name: ably-java + secrets: inherit diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 000000000..89368a8a8 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,110 @@ +name: Integration Test + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + check-rest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - run: ./gradlew :java:testRestSuite + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: java-build-reports-rest + path: java/build/reports/ + + check-realtime: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - run: ./gradlew :java:testRealtimeSuite + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: java-build-reports-realtime + path: java/build/reports/ + check-rest-okhttp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - run: ./gradlew :java:testRestSuite -Pokhttp + + check-realtime-okhttp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - run: ./gradlew :java:testRealtimeSuite -Pokhttp + + check-liveobjects: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - run: ./gradlew runLiveObjectIntegrationTests diff --git a/.github/workflows/javadoc.yml b/.github/workflows/javadoc.yml new file mode 100644 index 000000000..504c014b4 --- /dev/null +++ b/.github/workflows/javadoc.yml @@ -0,0 +1,42 @@ +name: JavaDoc + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + permissions: + id-token: write + deployments: write + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-java + role-session-name: "${{ github.run_id }}-${{ github.run_number }}" + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build docs + run: ./gradlew javadoc + + - name: Upload Documentation + uses: ably/sdk-upload-action@v2 + with: + sourcePath: java/build/docs/javadoc + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: javadoc diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..44a6814fe --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,52 @@ +name: Manual Release to Maven Central + +on: + workflow_dispatch: + +jobs: + run-on-release: + runs-on: ubuntu-latest + if: github.repository == 'ably/ably-java' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract tag + id: tag + run: | + TAG=${GITHUB_REF#refs/tags/v} + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Read VERSION_NAME from gradle.properties + id: version + run: | + VERSION_NAME=$(grep '^VERSION_NAME' gradle.properties | cut -d'=' -f2 | tr -d '[:space:]') + echo "version=$VERSION_NAME" >> $GITHUB_OUTPUT + + - name: Compare version with tag + run: | + if [ "$VERSION" != "$TAG" ]; then + echo "VERSION ($VERSION) does not match tag ($TAG)." + exit 1 + fi + env: + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.tag.outputs.tag }} + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Publish and release to Maven Central + run: ./gradlew publishAndReleaseToMavenCentral + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} diff --git a/.gitignore b/.gitignore index 8ac3a92fb..3838b286a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ bin/ .project local.properties + +lombok.config diff --git a/.gitmodules b/.gitmodules index ea3d64e15..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "lib/src/test/resources/ably-common"] - path = lib/src/test/resources/ably-common - url = https://github.com/ably/ably-common.git diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c45266394..000000000 --- a/.travis.yml +++ /dev/null @@ -1,74 +0,0 @@ -language: android -sudo: true -android: - components: - - platform-tools - - tools - - build-tools-27.0.3 - - android-22 - - extra-android-m2repository - - sys-img-armeabi-v7a-android-22 - -jdk: - - oraclejdk8 - - openjdk7 - -env: - global: - - QEMU_AUDIO_DRV=none - matrix: - - BUILD_ANDROID=false - - BUILD_ANDROID=true - -matrix: - include: - - language: java - jdk: oraclejdk9 - env: BUILD_ANDROID=false - exclude: - - jdk: openjdk7 - env: BUILD_ANDROID=true - - jdk: oraclejdk9 - env: BUILD_ANDROID=true - -before_script: - - if [ "$BUILD_ANDROID" = "true" ]; then echo no | android create avd -f -n test -t android-22 --abi armeabi-v7a; fi - - if [ "$BUILD_ANDROID" = "true" ]; then emulator -avd test -no-window & fi - - if [ "$BUILD_ANDROID" = "true" ]; then android-wait-for-emulator; fi - - if [ "$BUILD_ANDROID" = "true" ]; then adb shell input keyevent 82 & fi - -script: if [ "$BUILD_ANDROID" = "true" ]; then ./ci/run-android-tests.sh; else ./ci/run-java-tests.sh; fi - -# Buffer overflow patch. Source: https://github.com/travis-ci/travis-ci/issues/5227#issuecomment-165135711 -before_install: - - if [ "$BUILD_ANDROID" = "false" ]; then cat /etc/hosts; fi - - if [ "$BUILD_ANDROID" = "false" ]; then sudo hostname "$(hostname | cut -c1-63)"; fi - - if [ "$BUILD_ANDROID" = "false" ]; then sudo sed -i -e "s/^\\(127\\.0\\.0\\.1.*\\)/\\1 $(hostname | cut -c1-63)/" /etc/hosts; fi - - if [ "$BUILD_ANDROID" = "false" ]; then cat /etc/hosts; fi - - if [ "$BUILD_ANDROID" = "true" ]; then yes | sdkmanager "platforms;android-22"; fi - # taken from https://github.com/gretty-gradle-plugin/gretty/commit/f680ab388bf1f7a46f505ee2fe1a4a29e9a0a41e - - sudo apt-get -qq update - - sudo apt-get install -y zip curl locate libbcprov-java - - | - sudo ln -s /usr/share/java/bcprov.jar /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/ext/bcprov.jar \ - && sudo awk -F . -v OFS=. 'BEGIN{n=2}/^security\.provider/ {split($3, posAndEquals, "=");$3=n++"="posAndEquals[2];print;next} 1' /etc/java-7-openjdk/security/java.security > /tmp/java.security \ - && sudo echo "security.provider.1=org.bouncycastle.jce.provider.BouncyCastleProvider" >> /tmp/java.security \ - && sudo mv /tmp/java.security /etc/java-7-openjdk/security/java.security - -notifications: - slack: - rooms: - - secure: EK0WQz1q0PGExQmiTokVnRZTzrBEtULoF3Q05SsrYWlwBy+8r+kFuToWDY8914R2ReKEjozCgtuwx3cuEF01ITW8pnNER1ogQuGVAwz8x73fOndPdJxGRJaCAdy4S2uG4JmRqECtihNnNjlbkQZst4lNsVhtnQF32x7M6f4bLkg= - on_success: change - on_failure: always - email: - recipients: - - paddy@ably.io - - cesare@ably.io - on_success: change - on_failure: always - -branches: - only: - - main - - /^.*-ci$/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b7879cc83..d7b482d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,695 @@ # Change Log +## [1.2.54](https://github.com/ably/ably-java/tree/v1.2.54) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.53...v1.2.54) + +This release includes minor improvements and bug fixes. + +**Implemented enhancements:** + +- Prevents NPE in the connectivity check [\#1111](https://github.com/ably/ably-java/issues/1111) +- Fixes async connection state transition side effects [\#1119](https://github.com/ably/ably-java/issues/1119) + +## [1.2.53](https://github.com/ably/ably-java/tree/v1.2.53) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.52...v1.2.53) + +**Implemented enhancements:** + +- Adds `ANNOTATION_PUBLISH` and `ANNOTATION_SUBSCRIBE` channel modes +- Adds support for message annotations via `channel.annotations` +- The message action `meta.occupancy` is now renamed to `meta`. Similarly, `MessageActions.META_OCCUPANCY` is now `MessageActions.META` + +## [1.2.52](https://github.com/ably/ably-java/tree/v1.2.52) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.51...v1.2.52) + +**Closed issues:** + +- `Java-WebSocket` holds lock while invoking listeners, it may cause deadlock [\#1079](https://github.com/ably/ably-java/issues/1079) + +## [1.2.51](https://github.com/ably/ably-java/tree/v1.2.51) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.50...v1.2.51) + +**Implemented enhancements:** + +- Made query params URL-encoded in the request API by default [\#1075](https://github.com/ably/ably-java/issues/1075) +- Implemented RTN11d spec point [\#1074](https://github.com/ably/ably-java/issues/1074) + +## [1.2.50](https://github.com/ably/ably-java/tree/v1.2.50) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.49...v1.2.50) + +**Closed issues:** + +- Warning on Android Proguard [\#1067](https://github.com/ably/ably-java/issues/1067) + +**Implemented enhancements:** + +- Added internal Kotlin Wrapper for the SDK + +## [1.2.49](https://github.com/ably/ably-java/tree/v1.2.49) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.48...v1.2.49) + +**Closed issues:** + +- Support message edits and deletes [\#1058](https://github.com/ably/ably-java/issues/1058) + +**Merged pull requests:** + +- chore: upgrade github actions versions [\#1061](https://github.com/ably/ably-java/pull/1061) ([ttypic](https://github.com/ttypic)) +- [ECO-5193] Support message edits and deletes [\#1059](https://github.com/ably/ably-java/pull/1059) ([sacOO7](https://github.com/sacOO7)) + +## [1.2.48](https://github.com/ably/ably-java/tree/v1.2.48) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.47...v1.2.48) + +**Closed issues:** + +- Flaky realtime tests for RealtimeChannelTest [\#1055](https://github.com/ably/ably-java/issues/1055) +- \[RTL13\] Handle server sent `DETACHED` event [\#1051](https://github.com/ably/ably-java/issues/1051) + +**Merged pull requests:** + +- \[ECO-5188\] MessageAction enum changes [\#1056](https://github.com/ably/ably-java/pull/1056) ([SimonWoolf](https://github.com/SimonWoolf)) + + +## [1.2.47](https://github.com/ably/ably-java/tree/v1.2.47) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.46...v1.2.47) + +**Fixed bugs:** + +- Java SDK - Duplicate messages on rewind after 1.2.34 [\#1050](https://github.com/ably/ably-java/issues/1050) + +## [1.2.46](https://github.com/ably/ably-java/tree/v1.2.46) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.45...v1.2.46) + +**Implemented enhancements:** + +- New experimental `Message` fields (`action`, `serial`, `createdAt`) have been added. + **Note:** These fields are not stable and are introduced to support the [Chat SDK](https://github.com/ably/ably-chat-kotlin). + Use with caution, as they may change in future releases. + +**Merged pull requests:** + +- \[ECO-5139\] feat: add `action` and `serial` fields [\#1048](https://github.com/ably/ably-java/pull/1048) ([ttypic](https://github.com/ttypic)) + + +## [1.2.45](https://github.com/ably/ably-java/tree/v1.2.45) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.44...v1.2.45) + +**Closed issues:** + +- [RTL5] Incomplete spec implementation for channel DETACH/ATTACH [\#1045](https://github.com/ably/ably-java/issues/1045) +- [RTL7h] Throw exception for optional callback [\#1040](https://github.com/ably/ably-java/issues/1040) + +**Merged pull requests:** + +- [ECO-5117] Fix channel ATTACH/DETACH state checks [\#1046](https://github.com/ably/ably-java/pull/1046) ([sacOO7](https://github.com/sacOO7)) + +## [1.2.44](https://github.com/ably/ably-java/tree/v1.2.44) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.43...v1.2.44) + +**Fixed bugs:** + +- Race condition when calling`AblyRealtime#connect()` on terminated state [\#1041](https://github.com/ably/ably-java/issues/1041) + +## [1.2.43](https://github.com/ably/ably-java/tree/v1.2.43) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.42...v1.2.43) + +**Implemented enhancements:** + +- Expand proxy support: Authenticated Proxy and Websockets \(`HTTP CONNECT` tunnel\) [\#120](https://github.com/ably/ably-java/issues/120) + +**Merged pull requests:** + +- feat: introduced retry rules for flaky android push tests [\#1036](https://github.com/ably/ably-java/pull/1036) ([ttypic](https://github.com/ttypic)) +- feat: OkHttp implementation for making HTTP calls and WebSocket connections [\#1035](https://github.com/ably/ably-java/pull/1035) ([ttypic](https://github.com/ttypic)) +- chore: update gradle wrapper [\#1034](https://github.com/ably/ably-java/pull/1034) ([ttypic](https://github.com/ttypic)) + +## [1.2.42](https://github.com/ably/ably-java/tree/v1.2.42) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.41...v1.2.42) + +**Implemented enhancements:** + +- Implement the `attachOnSubscribe` channel option \(TB4\) [\#1027](https://github.com/ably/ably-java/issues/1027) + +**Merged pull requests:** + +- Fix implicit attach on subscribe [\#1028](https://github.com/ably/ably-java/pull/1028) ([sacOO7](https://github.com/sacOO7)) +- ci: enable workflow\_dispatch [\#1025](https://github.com/ably/ably-java/pull/1025) ([owenpearson](https://github.com/owenpearson)) +- tests: Assert connection error code rather than message [\#1023](https://github.com/ably/ably-java/pull/1023) ([lmars](https://github.com/lmars)) + +## [1.2.41](https://github.com/ably/ably-java/tree/v1.2.41) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.40...v1.2.41) + +**Closed issues:** + +- For REST clients, all requests should include an `X-Ably-ClientId` header when basic auth is to be used \(RSA7e2\) [\#1015](https://github.com/ably/ably-java/issues/1015) + +**Merged pull requests:** + +- chore\(Auth\): get rid of unnecessary padding removal for Auth tokens [\#1021](https://github.com/ably/ably-java/pull/1021) ([ttypic](https://github.com/ttypic)) +- feat: add Google SDK console verification [\#1020](https://github.com/ably/ably-java/pull/1020) ([ttypic](https://github.com/ttypic)) +- feat: include `X-Ably-ClientId` for each request \(RSA7e2\) [\#1019](https://github.com/ably/ably-java/pull/1019) ([ttypic](https://github.com/ttypic)) + + +## [1.2.40](https://github.com/ably/ably-java/tree/v1.2.40) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.39...v1.2.40) + +**Fixed bugs:** + +- Connection remains open when close is sent immediately [\#1012](https://github.com/ably/ably-java/issues/1012) + +**Merged pull requests:** + +- \[ECO-4820\] fix\(ConnectionManager\): update the connection close implementation to follow RTN12f [\#1013](https://github.com/ably/ably-java/pull/1013) ([ttypic](https://github.com/ttypic)) + +## [1.2.39](https://github.com/ably/ably-java/tree/v1.2.39) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.38...v1.2.39) + +**Fixed bugs:** + +- onMessage Exception [\#1009](https://github.com/ably/ably-java/issues/1009) +- NullPointerException When Attempting to read from field 'java.lang.String io.ably.lib.types.ErrorInfo.message' [\#995](https://github.com/ably/ably-java/issues/995) + +## [1.2.38](https://github.com/ably/ably-java/tree/v1.2.38) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.37...v1.2.38) + +**Fixed bugs:** + +- v1.2.34-v1.2.37 are incompatible with Android API versions < 24 [\#1004](https://github.com/ably/ably-java/issues/1004) +- REST client not attempting fallback hosts upon `httpOpenTimeout` expiry [\#997](https://github.com/ably/ably-java/issues/997) + +**Closed issues:** + +- Gracefully shutdown Ably resources [\#917](https://github.com/ably/ably-java/issues/917) +- Read timed out [\#850](https://github.com/ably/ably-java/issues/850) + +## [1.2.37](https://github.com/ably/ably-java/tree/v1.2.37) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.36...v1.2.37) + +**Fixed bugs:** + +- Fix HttpRequest & HttpRetry timeouts [\#310](https://github.com/ably/ably-java/issues/310) + +## [1.2.36](https://github.com/ably/ably-java/tree/v1.2.36) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.35...v1.2.36) + +**Closed issues:** + +- Push Notification corner cases [\#993](https://github.com/ably/ably-java/issues/993) +- Protocol-v2: readd recoveryKey to make this a non-breaking change [\#868](https://github.com/ably/ably-java/issues/868) + +**Merged pull requests:** + +- \[ECO-4706\] fix: push notifications corner cases [\#994](https://github.com/ably/ably-java/pull/994) ([ttypic](https://github.com/ttypic)) + +## [1.2.35](https://github.com/ably/ably-java/tree/v1.2.35) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.34...v1.2.35) + +**Closed issues:** + +- Enable and fix tests in RealtimePresenceTest [\#869](https://github.com/ably/ably-java/issues/869) + +**Merged pull requests:** + +- Fix presence / ignored presence tests [\#989](https://github.com/ably/ably-java/pull/989) ([sacOO7](https://github.com/sacOO7)) + +## [1.2.34](https://github.com/ably/ably-java/tree/v1.2.34) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.33...v1.2.34) + +**Fixed bugs:** + +- Should send `DETACH` after receiving `ATTACHED` while in the `DETACHING` or `DETACHED` state \(`RTL5k`\) [\#846](https://github.com/ably/ably-java/issues/846) + +**Closed issues:** + +- LocalDevice reset will cause ClassCastException [\#985](https://github.com/ably/ably-java/issues/985) +- Implement no-connection-serial [\#981](https://github.com/ably/ably-java/issues/981) +- DeviceSecret key is required by protocol v2.0 [\#845](https://github.com/ably/ably-java/issues/845) + +**Merged pull requests:** + +- Fix shared pref storage [\#986](https://github.com/ably/ably-java/pull/986) ([sacOO7](https://github.com/sacOO7)) +- Feature/no connection serial [\#983](https://github.com/ably/ably-java/pull/983) ([sacOO7](https://github.com/sacOO7)) + +## [1.2.33](https://github.com/ably/ably-java/tree/v1.2.33) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.32...v1.2.33) + +**Closed issues:** + +- Throw exception on `released` Ably Channel methods [\#971](https://github.com/ably/ably-java/issues/971) + +**Merged pull requests:** + +- fix: prevent reattaching of detached channels [\#977](https://github.com/ably/ably-java/pull/977) ([ttypic](https://github.com/ttypic)) +- feat: throw exception when trying to attach on released channel [\#973](https://github.com/ably/ably-java/pull/973) ([ttypic](https://github.com/ttypic)) +- fix: deviceId and deviceToken consistence [\#972](https://github.com/ably/ably-java/pull/972) ([ttypic](https://github.com/ttypic)) + +## [1.2.32](https://github.com/ably/ably-java/tree/v1.2.32) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.31...v1.2.32) + +**Fixed bugs:** + +- Create Cipher instance in place, do not store it in `ChannelOptions` [\#969](https://github.com/ably/ably-java/pull/969) +- Late Disconnection [\#937](https://github.com/ably/ably-java/issues/937) + +**Closed issues:** + +- Stack traces not being sent to error logs [\#963](https://github.com/ably/ably-java/issues/963) + +## [1.2.31](https://github.com/ably/ably-java/tree/v1.2.31) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.30...v1.2.31) + +**Fixed bugs:** + +- Update error code for channel attachment timed out [\#959](https://github.com/ably/ably-java/issues/959) +- Update error code for message decoding failure [\#958](https://github.com/ably/ably-java/issues/958) +- Fix incremental backoff while reconnecting [\#954](https://github.com/ably/ably-java/issues/954) +- Add `suspendedRetryTimeout` and `httpMaxRetryDuration` client options [\#956](https://github.com/ably/ably-java/issues/956) + +**Merged pull requests:** + +- fix: use appropriate error code for channel attachment timeout [\#961](https://github.com/ably/ably-java/pull/961) ([AndyTWF](https://github.com/AndyTWF)) +- fix: use error code 40013 for message decoding failures [\#960](https://github.com/ably/ably-java/pull/960) ([AndyTWF](https://github.com/AndyTWF)) +- Fix incremental backoff jitter [\#955](https://github.com/ably/ably-java/pull/955) ([sacOO7](https://github.com/sacOO7)) +- Add missing clientOptions [\#957](https://github.com/ably/ably-java/pull/957) ([sacOO7](https://github.com/sacOO7)) + +## [1.2.30](https://github.com/ably/ably-java/tree/v1.2.30) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.29...v1.2.30) + +**Fixed bugs:** + +- Connection manager switches to fallback hosts on close [\#950](https://github.com/ably/ably-java/issues/950) + +**Merged pull requests:** + +- fix: fallback hosts always being used on transport error [\#951](https://github.com/ably/ably-java/pull/951) ([AndyTWF](https://github.com/AndyTWF)) + +## [1.2.29](https://github.com/ably/ably-java/tree/v1.2.29) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.28...v1.2.29) + +**Fixed bugs:** + +- RTN23a: Transport not disconnecting after TTL passed [\#932](https://github.com/ably/ably-java/issues/932) + +**Merged pull requests:** + +- fix: transport not disconnecting after ttl passed [\#939](https://github.com/ably/ably-java/pull/939) ([AndyTWF](https://github.com/AndyTWF)) +- fix\(ConnectionManager\): don't check state before sending close message [\#938](https://github.com/ably/ably-java/pull/938) ([owenpearson](https://github.com/owenpearson)) + +## [1.2.28](https://github.com/ably/ably-java/tree/v1.2.28) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.27...v1.2.28) + +**Fixed bugs:** + +- Realtime with authUrl with token in query string fails to connect [\#935](https://github.com/ably/ably-java/issues/935) + +## [1.2.27](https://github.com/ably/ably-java/tree/v1.2.27) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.26...v1.2.27) + +**Fixed bugs:** + +- equals\(\) for TokenDetails is broken [\#926](https://github.com/ably/ably-java/issues/926) +- Long-lived connections are immediately transitioned to `SUSPENDED` after disconnection [\#925](https://github.com/ably/ably-java/issues/925) + +**Merged pull requests:** + +- Suspend timer is set when transport is unavailable and last state was connected [\#928](https://github.com/ably/ably-java/pull/928) ([AndyTWF](https://github.com/AndyTWF)) +- Fix equals\(\) on token details [\#927](https://github.com/ably/ably-java/pull/927) ([ikbalkaya](https://github.com/ikbalkaya)) + + +## [1.2.26](https://github.com/ably/ably-java/tree/v1.2.26) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.25...v1.2.26) + +**Fixed bugs:** + +- Provide an error code and error message for failed queued messages [\#920](https://github.com/ably/ably-java/issues/920) + +**Merged pull requests:** + +- Add reason to pending message instead of creating an ErrorInfo [\#922](https://github.com/ably/ably-java/pull/922) ([ikbalkaya](https://github.com/ikbalkaya)) + +## [1.2.25](https://github.com/ably/ably-java/tree/v1.2.25) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.24...v1.2.25) + +**Fixed bugs:** + +- Released channel re-added to the channel map after DETACHED message [\#913](https://github.com/ably/ably-java/issues/913) + +**Merged pull requests:** + +- Drop messages where channel does not exist [\#914](https://github.com/ably/ably-java/pull/914) ([AndyTWF](https://github.com/AndyTWF)) +- Improve `1.2`-series Release Process [\#912](https://github.com/ably/ably-java/pull/912) ([QuintinWillison](https://github.com/QuintinWillison)) +- Fix link formatting in changelog [\#911](https://github.com/ably/ably-java/pull/911) ([AndyTWF](https://github.com/AndyTWF)) + +## [1.2.24](https://github.com/ably/ably-java/tree/v1.2.24) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.23...v1.2.24) + +**Fixed bugs:** + +- Presence messages superseded whilst channel in attaching state [\#908](https://github.com/ably/ably-java/issues/908) +- A failed resume incorrectly retries queued messages prior to reattachment [\#905](https://github.com/ably/ably-java/issues/905) +- Pending messages are not failed when transitioning to suspended [\#904](https://github.com/ably/ably-java/issues/904) + +**Merged pull requests:** + +- Presence message superseded [\#909](https://github.com/ably/ably-java/pull/909) ([AndyTWF](https://github.com/AndyTWF)) +- Improvements on connection resume failure [\#906](https://github.com/ably/ably-java/pull/906) ([ikbalkaya](https://github.com/ikbalkaya)) + + +## [1.2.23](https://github.com/ably/ably-java/tree/v1.2.23) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.22...v1.2.23) + +**Fixed bugs:** + +- Re-attach fails due to previous detach request [\#885](https://github.com/ably/ably-java/issues/885) +- Lib is not re-sending pending messages on new transport after a resume [\#474](https://github.com/ably/ably-java/issues/474) + +**Merged pull requests:** + +- Connection resumption improvements [\#900](https://github.com/ably/ably-java/pull/900) ([ikbalkaya](https://github.com/ikbalkaya)) +- Make EventEmitter.on\(\) documentation reflect implementation [\#889](https://github.com/ably/ably-java/pull/889) ([AndyTWF](https://github.com/AndyTWF)) +- Fix attach/detach race condition [\#887](https://github.com/ably/ably-java/pull/887) ([ikbalkaya](https://github.com/ikbalkaya)) + +## [1.2.22](https://github.com/ably/ably-java/tree/v1.2.22) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.21...v1.2.22) + +**Merged pull requests:** + +- Skip checking WS hostname when not using SSL [\#883](https://github.com/ably/ably-java/pull/883) ([cruickshankpg](https://github.com/cruickshankpg)) + +## [1.2.21](https://github.com/ably/ably-java/tree/v1.2.21) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.20...v1.2.21) + +**Fixed bugs:** + +- Presence.endSync throws NullPointerException when processing a message [\#853](https://github.com/ably/ably-java/issues/853) + +**Merged pull requests:** + +- added null check to prevent NullPointerExceptions [\#873](https://github.com/ably/ably-java/pull/873) ([davyskiba](https://github.com/davyskiba)) + +## [1.2.20](https://github.com/ably/ably-java/tree/v1.2.20) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.19...v1.2.20) + +Sorry for the release noise, but the big fix we thought we had made in [1.2.19](https://github.com/ably/ably-java/releases/tag/v1.2.19) turned out not to fix the problem... + +**Second Attempt at Bug Fix:** +Automatic presence re-enter after network connection is back does not work [\#857](https://github.com/ably/ably-java/issues/857) in Revert to protocol 1.0 [\#864](https://github.com/ably/ably-java/pull/864) ([QuintinWillison](https://github.com/QuintinWillison)) + +## [1.2.19](https://github.com/ably/ably-java/tree/v1.2.19) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.18...v1.2.19) + +**Implemented enhancements:** + +- Implement incremental backoff and jitter [\#795](https://github.com/ably/ably-java/issues/795) in [\#852](https://github.com/ably/ably-java/pull/852) ([qsdigor](https://github.com/qsdigor)) + +**Fixed bugs:** + +- Automatic presence re-enter after network connection is back does not work [\#857](https://github.com/ably/ably-java/issues/857) in Revert to protocol 1.1 [\#858](https://github.com/ably/ably-java/pull/858) ([KacperKluka](https://github.com/KacperKluka)) + +## [1.2.18](https://github.com/ably/ably-java/tree/v1.2.18) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.17...v1.2.18) + +This release improves our Javadoc API commentaries for this SDK. +Other than that, there are no functional changes (features, bug fixes, etc..). + +## [1.2.17](https://github.com/ably/ably-java/tree/v1.2.17) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.16...v1.2.17) + +**Fixed bugs:** + +- RSA4d is not implemented correctly [\#829](https://github.com/ably/ably-java/issues/829) +- JSONUtilsObject.add() silently discards data of unsupported type [\#501](https://github.com/ably/ably-java/issues/501) + +**Merged pull requests:** + +- Fail Ably connection if auth callback throws specific errors [\#834](https://github.com/ably/ably-java/pull/834) ([KacperKluka](https://github.com/KacperKluka)) + +## [1.2.16](https://github.com/ably/ably-java/tree/v1.2.16) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.15...v1.2.16) + +In this release, we have fixed a bug that was introduced in 1.2.15 that caused the SDK to return early from the +`Auth#renewAuth` method. + +- call waiter.close() after breaking from while loop [\#825](https://github.com/ably/ably-java/pull/825) ([ikbalkaya](https://github.com/ikbalkaya)) + + +## [1.2.15](https://github.com/ably/ably-java/tree/v1.2.15) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.14...v1.2.15) + +In this release we have added a new method that provides a completion handler for renewing an authentication token. +We also updated the documentation to clarify the thread policy for public method callbacks. + +- A new `renewAuth` method was added to `Auth` and the `renew` method was deprecated + +**Implemented enhancements:** + +- Add new renew async method [\#816](https://github.com/ably/ably-java/pull/816) ([ikbalkaya](https://github.com/ikbalkaya)) + +**Fixed bugs:** + +- Early return from onAuthUpdated creates issues [\#814](https://github.com/ably/ably-java/issues/814) + +**Closed issues:** + +- Invalid method implementation in README [\#819](https://github.com/ably/ably-java/issues/819) +- Document which thread is whole SDK or callbacks using [\#800](https://github.com/ably/ably-java/issues/800) + +**Merged pull requests:** + +- Update onChannelStateChanged readme with current implementation [\#820](https://github.com/ably/ably-java/pull/820) ([qsdigor](https://github.com/qsdigor)) +- Document thread policy for callbacks and add missing documentation for callbacks [\#818](https://github.com/ably/ably-java/pull/818) ([qsdigor](https://github.com/qsdigor)) + +## [v1.2.14](https://github.com/ably/ably-java/tree/v1.2.14) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.13...v1.2.14) + +We've made some changes to JDK and Android API Level minimum requirements in this release, +which might cause problems for those with very old build toolchains, +or application projects with really permissive minimum runtime requirements: + +- Java source and target compatibility level increased from 1.7 to **1.8** +- Android minimum SDK API Level increased from 16 to **19 (4.4 KitKat)** + +We've also fixed an oversight in our REST support whereby it previously was not possible to fully release resources +consumed by the background thread pool used for HTTP operations, neither explicitly nor passively via GC. +This was most noticeably a problem for applications which created several client instances during the lifespan of +their application process. + +**Fixed bugs:** + +- NoSuchMethodError in ably-android for API lower than 24 [\#802](https://github.com/ably/ably-java/issues/802), fixed by [\#808](https://github.com/ably/ably-java/pull/808) ([KacperKluka](https://github.com/KacperKluka)) +- Threads remain in parked \(waiting\) state indefinitely when `AblyRest` instance is freed [\#801](https://github.com/ably/ably-java/issues/801), addressed by adding `finalize()` and `AutoCloseable` support to `AblyRest` instances [\#807](https://github.com/ably/ably-java/pull/807) ([QuintinWillison](https://github.com/QuintinWillison)) +- Minimum API Level supported for Android is 19 \(KitKat, v.4.4\) [\#804](https://github.com/ably/ably-java/pull/804) ([QuintinWillison](https://github.com/QuintinWillison)) + +**Merged pull requests:** + +- Increase minimum JRE version to 1.8 [\#805](https://github.com/ably/ably-java/pull/805) ([QuintinWillison](https://github.com/QuintinWillison)) + +## [v1.2.13](https://github.com/ably/ably-java/tree/v1.2.13) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.12...v1.2.13) + +**Closed issues:** + +- Update dependency: com.google.code.gson:gson [\#777](https://github.com/ably/ably-java/issues/777) +- Update dependency: org.java-websocket:Java-WebSocket [\#776](https://github.com/ably/ably-java/issues/776) + +## [v1.2.12](https://github.com/ably/ably-java/tree/v1.2.12) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.11...v1.2.12) + +**Fixed bugs:** + +- Cannot automatically re-enter channel due to mismatched connectionId [\#761](https://github.com/ably/ably-java/issues/761) +- Ensure that weak SSL/TLS protocols are not used [\#749](https://github.com/ably/ably-java/issues/749) + +## [v1.2.11](https://github.com/ably/ably-java/tree/v1.2.11) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.10...v1.2.11) + +**Fixed bugs:** + +- `ConcurrentModificationException` when `unsubscribe` then `detach` channel presence listener [\#743](https://github.com/ably/ably-java/issues/743), fixed in [\#744](https://github.com/ably/ably-java/pull/744) ([QuintinWillison](https://github.com/QuintinWillison)) +- `IllegalStateException` in `Crypto` `CBCCipher`'s `decrypt` method [\#741](https://github.com/ably/ably-java/issues/741), fixed in [\#746](https://github.com/ably/ably-java/pull/746) ([QuintinWillison](https://github.com/QuintinWillison)) +- Incorrect use of locale sensitive String APIs [\#713](https://github.com/ably/ably-java/issues/713), fixed in [\#722](https://github.com/ably/ably-java/pull/722) ([martin-morek](https://github.com/martin-morek)) +- `push.listSubscriptionsImpl` method not respecting params [\#705](https://github.com/ably/ably-java/issues/705), fixed in [\#710](https://github.com/ably/ably-java/pull/710) ([martin-morek](https://github.com/martin-morek)) + +**Other merged pull requests:** + +- Fix indentation and typos in authCallback example [\#724](https://github.com/ably/ably-java/pull/724) ([QuintinWillison](https://github.com/QuintinWillison)) + +## [v1.2.10](https://github.com/ably/ably-java/tree/v1.2.10) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.9...v1.2.10) + +**Fixed bugs:** + +- Using Firebase installation ID as registration token: Users cannot reactivate the device after deactivating [\#715](https://github.com/ably/ably-java/issues/715) + +**Merged pull requests:** + +- Fix: Use `FirebaseMessaging\#getToken\(\)` to get registration token [\#717](https://github.com/ably/ably-java/pull/717) ([ben-xD](https://github.com/ben-xD)) + +## [v1.2.9](https://github.com/ably/ably-java/tree/v1.2.9) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.8...v1.2.9) + +**Fixed bugs:** + +- IllegalArgumentException: No enum constant io.ably.lib.http.HttpAuth.Type.BASİC [\#711](https://github.com/ably/ably-java/issues/711) +- ProGuard warnings emitted by Android build against 1.1.6 [\#529](https://github.com/ably/ably-java/issues/529) + +**Merged pull requests:** + +- Fix incorrect parsing of HTTP auth type for some locales [\#712](https://github.com/ably/ably-java/pull/712) ([QuintinWillison](https://github.com/QuintinWillison)) +- Suppressed warning in ProGuard [\#709](https://github.com/ably/ably-java/pull/709) ([martin-morek](https://github.com/martin-morek)) + +## [v1.2.8](https://github.com/ably/ably-java/tree/v1.2.8) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.7...v1.2.8) + +**Implemented enhancements:** + +- Update Stats fields with latest MessageTraffic types [\#394](https://github.com/ably/ably-java/issues/394) +- Replace ULID with Android's UUID [\#680](https://github.com/ably/ably-java/issues/680) + +**Fixed bugs:** + +- Push Activation State Machine exception handling needs improvement [\#685](https://github.com/ably/ably-java/issues/685) +- WebsocketNotConnectedException on send [\#430](https://github.com/ably/ably-java/issues/430) + +**Merged pull requests:** + +- Replaced ULID with UUID for deviceID [\#702](https://github.com/ably/ably-java/pull/702) ([martin-morek](https://github.com/martin-morek)) +- Separate handling WebsocketNotConnectedException [\#701](https://github.com/ably/ably-java/pull/701) ([martin-morek](https://github.com/martin-morek)) +- Updated Stats fields with the latest MessageTraffic types [\#698](https://github.com/ably/ably-java/pull/698) ([martin-morek](https://github.com/martin-morek)) + +## [v1.2.7](https://github.com/ably/ably-java/tree/v1.2.7) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.6...v1.2.7) + +**Implemented enhancements:** + +- Implement RSC7d \(Ably-Agent header\) [\#665](https://github.com/ably/ably-java/issues/665) +- Conform toString\(\) implementations [\#631](https://github.com/ably/ably-java/issues/631) + +**Fixed bugs:** + +- Remove use of forClass method in push activation state machine implementation [\#686](https://github.com/ably/ably-java/issues/686) +- Race condition releasing short lived channels [\#570](https://github.com/ably/ably-java/issues/570) +- Using a clientId should no longer be forcing token auth in the 1.1 spec [\#473](https://github.com/ably/ably-java/issues/473) +- Ensure correct feedback to developer when malformed key is supplied [\#382](https://github.com/ably/ably-java/issues/382) + +**Closed issues:** + +- Fail connection immediately if authorize\(\) called and 403 returned [\#620](https://github.com/ably/ably-java/issues/620) +- FCM getToken method is deprecated [\#597](https://github.com/ably/ably-java/issues/597) +- Support for encryption of shared preferences [\#593](https://github.com/ably/ably-java/issues/593) +- RSC7c TI1 addRequestIds on ClientOptions and requestId on ErrorInfo [\#574](https://github.com/ably/ably-java/issues/574) + +**Merged pull requests:** + +- Increase minimum SDK version to Android 4.1 \(Jelly Bean, API Level 16\) [\#691](https://github.com/ably/ably-java/pull/691) ([KacperKluka](https://github.com/KacperKluka)) +- Throws exception when AuthOptions are initialized with an empty string [\#690](https://github.com/ably/ably-java/pull/690) ([martin-morek](https://github.com/martin-morek)) +- Removed forName method [\#689](https://github.com/ably/ably-java/pull/689) ([martin-morek](https://github.com/martin-morek)) +- Updated Firebase cloud messaging dependency [\#687](https://github.com/ably/ably-java/pull/687) ([martin-morek](https://github.com/martin-morek)) +- Unified custom toString\(\) method implementations to use curly bracket… [\#683](https://github.com/ably/ably-java/pull/683) ([martin-morek](https://github.com/martin-morek)) +- Support for encryption of shared preferences [\#681](https://github.com/ably/ably-java/pull/681) ([martin-morek](https://github.com/martin-morek)) +- Add request\_id query param if addRequestIds is enabled [\#678](https://github.com/ably/ably-java/pull/678) ([martin-morek](https://github.com/martin-morek)) +- Using a clientId should no longer be forcing token auth [\#675](https://github.com/ably/ably-java/pull/675) ([martin-morek](https://github.com/martin-morek)) +- Checking if error code is 403 and failing connection [\#672](https://github.com/ably/ably-java/pull/672) ([martin-morek](https://github.com/martin-morek)) +- Add Ably-Agent header [\#671](https://github.com/ably/ably-java/pull/671) ([KacperKluka](https://github.com/KacperKluka)) +- Changing Capability.addResource\(\) to take varargs as last parameter [\#664](https://github.com/ably/ably-java/pull/664) ([Thunderforge](https://github.com/Thunderforge)) + +## [v1.2.6](https://github.com/ably/ably-java/tree/v1.2.6) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.5...v1.2.6) + +**Fixed bug:** channel presence members [\#669](https://github.com/ably/ably-java/pull/669) ([sacOO7](https://github.com/sacOO7)) +An issue affecting only users calling `get(boolean wait)` on `Presence` with `wait` set to `true`. + +## [v1.2.5](https://github.com/ably/ably-java/tree/v1.2.5) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.4...v1.2.5) + +**Fixed bugs:** + +- Crypto.getRandomMessageId isn't working as intended [\#654](https://github.com/ably/ably-java/issues/654) +- Hosts class is not thread safe [\#650](https://github.com/ably/ably-java/issues/650) +- AblyBase.InternalChannels is not thread-safe [\#649](https://github.com/ably/ably-java/issues/649) + +**Merged pull requests:** + +- Makes the Hosts class safe to be called from any thread [\#657](https://github.com/ably/ably-java/pull/657) ([QuintinWillison](https://github.com/QuintinWillison)) +- Fix getRandomMessageId [\#656](https://github.com/ably/ably-java/pull/656) ([sacOO7](https://github.com/sacOO7)) +- Improve channel map operations in respect of thread-safety [\#655](https://github.com/ably/ably-java/pull/655) ([QuintinWillison](https://github.com/QuintinWillison)) + +## [v1.2.4](https://github.com/ably/ably-java/tree/v1.2.4) + +[Full Changelog](https://github.com/ably/ably-java/compare/v1.2.3...v1.2.4) + +**Fixed bugs:** + +- Many instances of ConnectionWaiter spawned while app is running, with authentication token flow [\#651](https://github.com/ably/ably-java/issues/651) +- capability tokendetails adds to HTTP Request as a query parameter [\#647](https://github.com/ably/ably-java/issues/647) +- ClientOptions idempotentRestPublishing default may be wrong [\#590](https://github.com/ably/ably-java/issues/590) +- Presence blocking get sometimes has missing members [\#467](https://github.com/ably/ably-java/issues/467) +- Remove empty capability query parameter [\#648](https://github.com/ably/ably-java/pull/648) ([vzhikserg](https://github.com/vzhikserg)) +- Add unit test for idempotentRestPublishing in ClientOptions [\#636](https://github.com/ably/ably-java/pull/636) ([vzhikserg](https://github.com/vzhikserg)) +- Fix Member Presence [\#607](https://github.com/ably/ably-java/pull/607) ([sacOO7](https://github.com/sacOO7)) + +**Merged pull requests:** + +- Unregister ConnectionWaiter listeners once connected [\#652](https://github.com/ably/ably-java/pull/652) ([QuintinWillison](https://github.com/QuintinWillison)) +- Update references from 1 -\> l to match client spec [\#646](https://github.com/ably/ably-java/pull/646) ([natdempk](https://github.com/natdempk)) +- Add workflow status badges [\#645](https://github.com/ably/ably-java/pull/645) ([QuintinWillison](https://github.com/QuintinWillison)) +- Add maintainers file [\#644](https://github.com/ably/ably-java/pull/644) ([niksilver](https://github.com/niksilver)) +- Add workflows [\#643](https://github.com/ably/ably-java/pull/643) ([QuintinWillison](https://github.com/QuintinWillison)) +- Fix CI pipeline [\#642](https://github.com/ably/ably-java/pull/642) ([vzhikserg](https://github.com/vzhikserg)) +- Fix/doc 233 update readme [\#641](https://github.com/ably/ably-java/pull/641) ([tbedford](https://github.com/tbedford)) +- Log error message to get clear understanding of exception [\#632](https://github.com/ably/ably-java/pull/632) ([sacOO7](https://github.com/sacOO7)) +- Refactor MessageExtras [\#595](https://github.com/ably/ably-java/pull/595) ([sacOO7](https://github.com/sacOO7)) + ## [v1.2.3](https://github.com/ably/ably-java/tree/v1.2.3) [Full Changelog](https://github.com/ably/ably-java/compare/v1.2.2...v1.2.3) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..04ec04bb4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,273 @@ +# Contributing + +## Development Flow + +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`) +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. + +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: + + ./gradlew + +but on Windows there is a batch file: + + gradlew.bat + +The JRE-specific library JAR is built with: + + ./gradlew java:jar + +The Android-specific library AAR is built with: + + ./gradlew android:assemble + +(The `ANDROID_HOME` environment variable must be set appropriately.) + +## Adding a New Network Engine Implementation + +Currently, `ably-java` supports two different engines for network operations (HTTP calls and WebSocket connections): + +- **Default Engine**: Utilizes the built-in `HttpUrlConnection` for HTTP calls and the TooTallNate/Java-WebSocket library for WebSocket connections. +- **OkHttp Engine**: Utilizes the OkHttp library for both HTTP and WebSocket connections. + +These engines are designed to be swappable. By default, the library comes with the default engine, but you can easily replace it with the OkHttp engine: + +```kotlin +implementation("io.ably:ably-java:$ABLY_VERSION") { + exclude(group = "io.ably", module = "network-client-default") +} +runtimeOnly("io.ably:network-client-okhttp:$ABLY_VERSION") +``` + +### Publishing to local Maven repository for development and testing +To publish the library to your local Maven repository, you can use the following command: + + ./gradlew publishToMavenLocal + + +Alternatively, to publish only the specific (LiveObjects) module: + + ./gradlew :live-objects:publishToMavenLocal + + +- To use the locally published library in your project, you can add the following dependency in your `build.gradle` file: + +``` +repositories { + mavenLocal() +} +``` +- Note - Place `mavenLocal()` before `mavenCentral()` for resolution precedence. + +### How to Add a New Network Engine + +To add a new network engine, follow these steps: + +1. **Implement the interfaces**: + - Implement the `HttpEngineFactory` and `WebSocketEngineFactory` interfaces for your custom engine. + +2. **Register the engine**: + - Modify the `getFirstAvailable()` methods in these interfaces to include your new implementation. + +Once done, your custom network engine will be available for use within `ably-java`. + +### Code Standard + +#### Checkstyle + +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`. +It does not run for the Android build yet. + +You can run just the Checkstyle rules on their own using: + + ./gradlew checkstyleMain + +#### CodeNarc + +We use [CodeNarc](https://codenarc.org/) to enforce code style in our Gradle build scripts, which are all written in Groovy. + +You can run CodeNarc over all build scripts in this repository using: + + ./gradlew checkWithCodenarc + +For more details see the [`gradle-lint`](gradle-lint) project. + + +### Supported Platforms + +We regression-test the library against a selection of Java and Android platforms (which will change over time, but usually consists of the versions that are supported upstream). Please refer to [.travis.yml](./.travis.yml) for the set of versions that currently undergo CI testing.. + +We'll happily support (and investigate reported problems with) any reasonably-widely-used platform, Java or Android. +If you find any compatibility issues, please [do raise an issue](https://github.com/ably/ably-java/issues/new) in this repository or [contact Ably customer support](https://support.ably.io/) for advice. + +### IDE Support + +We have a root [`.editorconfig`](.editorconfig) file, supporting [EditorConfig](https://editorconfig.org/), which should be of assistance within most IDEs. e.g.: + +- [VS Code](https://code.visualstudio.com/) using the [EditorConfig plugin](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) +- [IntelliJ IDEA](https://www.jetbrains.com/idea/) using the [bundled plugin](https://www.jetbrains.com/help/idea/configuring-code-style.html#editorconfig). + +#### Developing this library with an IDE + +The gradle project files can be imported to create projects in IntelliJ IDEA, Eclipse and Android Studio. + +#### 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: + +- 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. + +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; +- select the Class as RealtimeSuite or RestSuite; +- select the relevant module for the classpath. + +In order to run the Android configuration it is necessary to set up the Android SDK path by selecting a project of module and opening the module settings. The Android SDK needs to be added under Platform Settings->SDKs. + +#### 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 + +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. + +Interactive run/debug configurations for the java project can be created as follows: +- select Run->Run configurations ... +- create a new JUnit configuration +- select the java 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. + +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 OK to Gradle Sync. + +This creates a single android project and module. + +Configuration of Run/Debug configurations for running the unit tests on Android is the same as for IntelliJ IDEA (above). + +## Running Tests + +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: + + ./gradlew + +but on Windows there is a batch file: + + gradlew.bat + +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 java:testRealtimeSuite + +To run tests against a specific host, specify in the environment: + + env ABLY_ENV=staging ./gradlew testRealtimeSuite + +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 + +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 + +### Interactive push tests + +End-to-end tests for push notifications (ie where the Android client is the target) can be tested interactively via a [separate app](https://github.com/ably/push-example-android). +There are [instructions there](https://github.com/ably/push-example-android#using-this-app-yourself) for setting up the necessary FCM account, configuring the credentials and other parameters, +in order to get end-to-end FCM notifications working. + +## Building an Android Archive (AAR) file locally + +An [Android Archive (AAR)](https://developer.android.com/studio/projects/android-library) can be used in other projects as a dependency, unlike APKs. It does not contain dependencies, so you may face build and runtime errors if dependencies are not installed in projects which make use of the AAR. + +- Set up the GPG signing configuration: + - Create a GPG key pair: `gpg --expert --full-generate-key`. + - Export a secret key ring file: `gpg --export-secret-keys -o ably-java-secring.gpg`. + - Add the details of the GPG key pair inside `./gradle/gradle.properties`: +```bash +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`. + +## Using `ably-java` / `ably-android` locally in other projects + +You may wish to make changes to Ably Java or Ably Android, and test it immediately in a separate project. For example, during development for [Ably Flutter](https://github.com/ably/ably-flutter) which depends on `ably-android`, a bug was found in `ably-android`. A small fix was done, the AAR was built and tested in [Ably Flutter](https://github.com/ably/ably-flutter). + +- Build the AAR: See [Building an Android Archive (AAR) file locally](#building-an-android-archive-aar-file-locally) +- Open the directory printed from the output of that command. Inside that folder, get the `ably-android-x.y.z.aar`, and place it your Android project's `libs/` directory. Create this directory if it doesn't exist. +- Add an `implementation` dependency on the `.aar`: +```groovy +implementation files('libs/ably-android-1.2.54.aar') +``` +- Add the `implementation` (not `testImplementation`) dependencies found in `dependencies.gradle` to your project. This is because the `.aar` does not contain dependencies. +- Build/run your application. + +## Release Process + +This library uses [semantic versioning](http://semver.org/). For each release, the following needs to be done: + +1. Create a branch for the release, named like `release/1.2.4` (where `1.2.4` is what you're releasing, being the new version) +2. Replace all references of the current version number with the new version number (check the [README.md](./README.md) and [gradle.properties](./gradle.properties)) and commit the changes +3. Run [`github_changelog_generator`](https://github.com/github-changelog-generator/github-changelog-generator) to automate the update of the [CHANGELOG](./CHANGELOG.md). This may require some manual intervention, both in terms of how the command is run and how the change log file is modified. Your mileage may vary: + - The command you will need to run will look something like this: `github_changelog_generator -u ably -p ably-java --since-tag v1.2.3 --output delta.md --token $GITHUB_TOKEN_WITH_REPO_ACCESS`. Generate token [here](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token). + - Using the command above, `--output delta.md` writes changes made after `--since-tag` to a new file. + - The contents of that new file (`delta.md`) then need to be manually inserted at the top of the `CHANGELOG.md`, changing the "Unreleased" heading and linking with the current version numbers. + - Also ensure that the "Full Changelog" link points to the new version tag instead of the `HEAD`. +4. Commit [CHANGELOG](./CHANGELOG.md) +5. Make a PR against `main` +6. Once the PR is approved, merge it into `main` +7. Create the release and the release tag on Github including populating the release notes +8. Use the [GitHub action](https://github.com/ably/ably-java/actions/workflows/release.yaml) to publish the release. Run the workflow on the latest release tag. +9. Create the entry on the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)) + +### Signing + +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 + +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`). + +The GPG key file is internal and private to Ably. + +### Sonatype Nexus for Maven Central + +We publish to Maven Central via Sonatype's [OSSRH](https://issues.sonatype.org/browse/OSSRH-52871) / [Nexus](https://oss.sonatype.org/#nexus-search;quick~io.ably) diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 000000000..6717bc416 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2015-2022 Ably Real-time Ltd (ably.com) diff --git a/LICENSE b/LICENSE index bf523cafe..d9a10c0d8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,176 @@ -Copyright 2015-2020 Ably Real-time Ltd (ably.com) + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - http://www.apache.org/licenses/LICENSE-2.0 + 1. Definitions. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 000000000..6edbb9593 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1 @@ +This repository is owned by the Ably SDK team. diff --git a/README.md b/README.md index bec4761c0..ba9e483e3 100644 --- a/README.md +++ b/README.md @@ -1,699 +1,183 @@ -# [Ably](https://www.ably.io) +![Ably Pub/Sub Java Header](images/javaSDK-github.png) +[![Latest Version](https://img.shields.io/maven-central/v/io.ably/ably-java)](https://central.sonatype.com/artifact/io.ably/ably-java) +[![License](https://badgen.net/github/license/ably/ably-java)](https://github.com/ably/ably-java/blob/main/LICENSE) -| Android | Java | -|---------|------| -| [ ![Download](https://api.bintray.com/packages/ably-io/ably/ably-android/images/download.svg) ](https://bintray.com/ably-io/ably/ably-android/_latestVersion) | [ ![Download](https://api.bintray.com/packages/ably-io/ably/ably-java/images/download.svg) ](https://bintray.com/ably-io/ably/ably-java/_latestVersion) | +# Ably Pub/Sub Java SDK -A Java Realtime and REST client library for [Ably Realtime](https://www.ably.io), the realtime messaging and data delivery service. This library currently targets the [Ably client library features spec](https://www.ably.io/documentation/client-lib-development-guide/features/) Version 1.2. You can jump to the '[Known Limitations](#known-limitations)' section to see the features this client library does not yet support or [view our client library SDKs feature support matrix](https://www.ably.io/download/sdk-feature-support-matrix) to see the list of all the available features. +Build any realtime experience using Ably’s Pub/Sub Java SDK. Supported on all popular platforms and frameworks, including Kotlin and Android. -## Supported Platforms +Ably Pub/Sub provides flexible APIs that deliver features such as pub-sub messaging, message history, presence, and push notifications. Utilizing Ably’s realtime messaging platform, applications benefit from its highly performant, reliable, and scalable infrastructure. -This SDK supports the following platforms: +Find out more: -**Java:** Java 7+ +* [Ably Pub/Sub docs.](https://ably.com/docs/basics) +* [Ably Pub/Sub examples.](https://ably.com/examples?product=pubsub) -**Android:** android-19 or newer as a target SDK, android-16 or newer as a target platform +--- -We regression-test the library against a selection of Java and Android platforms (which will change over time, but usually consists of the versions that are supported upstream). Please refer to [.travis.yml](./.travis.yml) for the set of versions that currently undergo CI testing.. +## Getting started -We'll happily support (and investigate reported problems with) any reasonably-widely-used platform, Java or Android. -If you find any compatibility issues, please [do raise an issue](https://github.com/ably/ably-java/issues/new) in this repository or [contact Ably customer support](https://support.ably.io/) for advice. +Everything you need to get started with Ably: -## Documentation +- [Quickstart in Pub/Sub using Java](https://ably.com/docs/getting-started/quickstart?lang=java) +* [SDK Setup for Java.](https://ably.com/docs/getting-started/setup?lang=java) -Visit https://www.ably.io/documentation for a complete API reference and more examples. +--- -## Installation ## +## Supported platforms -Reference the library by including a compile dependency reference in your gradle build file. +Ably aims to support a wide range of platforms. If you experience any compatibility issues, open an issue in the repository or contact [Ably support](https://ably.com/support). -For [Java](https://bintray.com/ably-io/ably/ably-java/_latestVersion): +The following platforms are supported: -``` -compile 'io.ably:ably-java:1.2.3' -``` - -For [Android](https://bintray.com/ably-io/ably/ably-android/_latestVersion): - -``` -compile 'io.ably:ably-android:1.2.3' -``` - -The library is hosted on the [Jcenter repository](https://bintray.com/ably-io/ably), so you need to ensure that the repo is referenced also; IDEs will typically include this by default: - -``` -repositories { - jcenter() -} -``` - -Previous releases of the Java library included a downloadable JAR; however we now only support installation via Maven/Gradle from the Jcenter repository. If you want to use a standalone fat JAR for (ie containing all dependencies), it can be generated via a gradle task (see [building](#building) below); note that this is the "Java" (JRE) library variant only; Android is now supported via an AAR and there is no self-contained AAR build option. - -## Dependencies - -For Java, JRE 7 or later is required. Note that the [Java Unlimited JCE extensions](http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html) -must be installed in the Java runtime environment. - -For Android, 4.0 (API level 14) or later is required. - -## Feature support - -This library targets the Ably 1.1 client library specification and supports all principal 1.1 features. - -## Using the Realtime API ## - -### Introduction ### - -Please refer to the [documentation](https://www.ably.io/documentation) for a full realtime API reference. - -The examples below assume a client has been created as follows: - -```java -AblyRealtime ably = new AblyRealtime("xxxxx"); -``` - -### Connection ### - -AblyRealtime will attempt to connect automatically once new instance is created. Also, it offers API for listening connection state changes. - -```java -ably.connection.on(new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - System.out.println("New state is " + change.current.name()); - switch (state.current) { - case connected: { - // Successful connection - break; - } - case failed: { - // Failed connection - break; - } - } - } -}); -``` - -And it offers API for listening specific connection state changes. - -```java -ably.connection.on(ConnectionState.connected, new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - /* Do something */ - } -}); -``` - -### Subscribing to a channel ### - -Given: - -```java -Channel channel = ably.channels.get("test"); -``` - -Subscribe to all events: - -```java -channel.subscribe(new MessageListener() { - @Override - public void onMessage(Message message) { - System.out.println("Received `" + message.name + "` message with data: " + message.data); - } -}); -``` - -or subscribe to certain events: - -```java -String[] events = new String[] {"event1", "event2"}; -channel.subscribe(events, new MessageListener() { - @Override - public void onMessage(Message message) { - System.out.println("Received `" + message.name + "` message with data: " + message.data); - } -}); -``` - -### Subscribing to a channel in delta mode ### - -Subscribing to a channel in delta mode enables [delta compression](https://www.ably.io/documentation/realtime/channels/channel-parameters/deltas). This is a way for a client to subscribe to a channel so that message payloads sent contain only the difference (ie the delta) between the present message and the previous message on the channel. - -Request a Vcdiff formatted delta stream using channel options when you get the channel: - -```java -Map params = new HashMap<>(); -params.put("delta", "vcdiff"); -ChannelOptions options = new ChannelOptions(); -options.params = params; -Channel channel = ably.channels.get("test", options); -``` +| Platform | Support | +|----------|---------| +| Java | >= 1.8 (JRE 8 or later) | +| Kotlin | All versions (>= 1.0 supported), but we recommend >= 1.8 for best compatibility. | +| Android | >=4.4 (API level 19) | -Beyond specifying channel options, the rest is transparent and requires no further changes to your application. The `message.data` instances that are delivered to your `MessageListener` continue to contain the values that were originally published. +> [!IMPORTANT] +> SDK versions < 1.2.35 will be [deprecated](https://ably.com/docs/platform/deprecate/protocol-v1) from November 1, 2025. -If you would like to inspect the `Message` instances in order to identify whether the `data` they present was rendered from a delta message from Ably then you can see if `extras.getDelta().getFormat()` equals `"vcdiff"`. +--- -### Publishing to a channel ### +## Installation -```java -channel.publish("greeting", "Hello World!", new CompletionListener() { - @Override - public void onSuccess() { - System.out.println("Message successfully sent"); - } - - @Override - public void onError(ErrorInfo reason) { - System.err.println("Unable to publish message; err = " + reason.message); - } -}); -``` - -### Querying the history ### - -```java -PaginatedResult result = channel.history(null); - -System.out.println(result.items().length + " messages received in first page"); -while(result.hasNext()) { - result = result.getNext(); - System.out.println(result.items().length + " messages received in next page"); -} -``` - -### Presence on a channel ### - -```java -channel.presence.enter("john.doe", new CompletionListener() { - @Override - public void onSuccess() { - // Successfully entered to the channel - } - - @Override - public void onError(ErrorInfo reason) { - // Failed to enter channel - } -}); -``` +The Java SDK is available as a [Maven dependency](https://mvnrepository.com/artifact/io.ably/ably-java). To get started with your project, install the package: -### Querying the presence history ### +### Install for Maven: -```java -PaginatedResult result = channel.presence.history(null); - -System.out.println(result.items().length + " messages received in first page"); -while(result.hasNext()) { - result = result.getNext(); - System.out.println(result.items().length + " messages received in next page"); -} -``` - -### Channel state ### - -`Channel` extends `EventEmitter` that emits channel state changes, and listening those events is possible with `ChannelStateListener` - -```java -ChannelStateListener listener = new ChannelStateListener() { - @Override - public void onChannelStateChanged(ChannelState state, ErrorInfo reason) { - System.out.println("Channel state changed to " + state.name()); - if (reason != null) System.out.println(reason.toString()); - } -}; +```xml + + io.ably + ably-java + 1.2.54 + ``` -You can register using +### Install for Gradle: -```java -channel.on(listener); +```gradle +implementation 'io.ably:ably-java:1.2.54' +implementation 'org.slf4j:slf4j-simple:2.0.7' ``` -and after you are done listening channel state events, you can unregister using -```java -channel.off(listener); -``` - -If you are interested with specific events, it is possible with providing extra `ChannelState` value. +Run the following to instantiate a client: ```java -channel.on(ChannelState.attached, listener); -``` - -## Using the REST API ## +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.types.ClientOptions; -### Introduction ### - -Please refer to the [documentation](https://www.ably.io/documentation) for a full REST API reference. - -The examples below assume a client and/or channel has been created as follows: - -```java -AblyRest ably = new AblyRest("xxxxx"); -Channel channel = ably.channels.get("test"); +ClientOptions options = new ClientOptions(apiKey); +AblyRealtime realtime = new AblyRealtime(options); ``` -### Publishing a message to a channel ### +--- -Given the message below +## Usage -```java -Message message = new Message("myEvent", "Hello"); -``` +The following code connects to Ably's realtime messaging service, subscribes to a channel to receive messages, and publishes a test message to that same channel. -Sharing synchronously, ```java -channel.publish(message); -``` +// Initialize Ably Realtime client +ClientOptions options = new ClientOptions("your-ably-api-key"); +options.clientId = "me"; +AblyRealtime realtimeClient = new AblyRealtime(options); -Sharing asynchronously, - -```java -channel.publishAsync(message, new CompletionListener() { - @Override - public void onSuccess() { - System.out.println("Message successfully received by Ably server."); - } - - @Override - public void onError(ErrorInfo reason) { - System.err.println("Unable to publish message to Ably server; err = " + reason.message); - } +// Wait for connection to be established +realtimeClient.connection.on(ConnectionEvent.connected, connectionStateChange -> { + System.out.println("Connected to Ably"); + + // Get a reference to the 'test-channel' channel + Channel channel = realtimeClient.channels.get("test-channel"); + + // Subscribe to all messages published to this channel + channel.subscribe(message -> { + System.out.println("Received message: " + message.data); + }); + + // Publish a test message to the channel + channel.publish("test-event", "hello world"); }); ``` +--- -### Querying the history ### - -```java -PaginatedResult result = channel.history(null); - -System.out.println(result.items().length + " messages received in first page"); -while(result.hasNext()) { - result = result.getNext(); - System.out.println(result.items().length + " messages received in next page"); -} -``` - -### Presence on a channel ### - -```java -PaginatedResult result = channel.presence.get(null); - -System.out.println(result.items().length + " messages received in first page"); -while(result.hasNext()) { - result = result.getNext(); - System.out.println(result.items().length + " messages received in next page"); -} -``` - -### Querying the presence history ### -```java -PaginatedResult result = channel.presence.history(null); +## Proxy support -System.out.println(result.items().length + " messages received in first page"); -while(result.hasNext()) { - result = result.getNext(); - System.out.println(result.items().length + " messages received in next page"); -} -``` +You can add proxy support to the Ably Java SDK by configuring `ProxyOptions` in your client setup, enabling connectivity through corporate firewalls and secured networks. -### Generate a Token and Token Request ### +
+Proxy support setup details. -```java -TokenDetails tokenDetails = ably.auth.requestToken(null, null); -System.out.println("Success; token = " + tokenRequest); -``` - -### Fetching your application's stats ### +To enable proxy support for both REST and Realtime clients in the Ably SDK, use the OkHttp library to handle HTTP requests and WebSocket connections. -```java -PaginatedResult stats = ably.stats(null); +Add the following dependency to your `build.gradle` file: -System.out.println(result.items().length + " messages received in first page"); -while(result.hasNext()) { - result = result.getNext(); - System.out.println(result.items().length + " messages received in next page"); +```groovy +dependencies { + runtimeOnly("io.ably:network-client-okhttp:1.2.54") } ``` -### Fetching the Ably service time ### +After adding the OkHttp dependency, enable proxy support by specifying proxy settings in the ClientOptions when initializing your Ably client. -```java -long serviceTime = ably.time(); -``` - -### Logging ### - -You can get log output from the library by modifying the log level: - -```java -import io.ably.lib.util.Log; - -ClientOptions opts = new ClientOptions(key); -opts.logLevel = Log.VERBOSE; -AblyRest ably = new AblyRest(opts); -... -``` - -By default, log output will go to `System.out` for the java library, and logcat for Android. - -You can redirect the log output to a logger of your own by specifying a custom log handler: - -```java -import io.ably.lib.util.Log.LogHandler; - -ClientOptions opts = new ClientOptions(key); -opts.logHandler = new LogHandler() { - public void println(int severity, String tag, String msg, Throwable tr) { - /* handle log output here ... */ - } -}; -AblyRest ably = new AblyRest(opts); -... -``` - -Note that any logger you specify in this way has global scope - it will set as a static of the library -and will apply to all Ably library instances. If you need to release your custom logger so that it can be -garbage-collected, you need to clear that static reference: +The following example sets up a proxy using the Pub/Sub Java SDK: ```java -import io.ably.lib.util.Log; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.transport.Defaults; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ProxyOptions; +import io.ably.lib.http.HttpAuth; -Log.setHandler(null); -``` - -## Using the Push API +public class AblyWithProxy { + public static void main(String[] args) throws Exception { + // Configure Ably Client options + ClientOptions options = new ClientOptions(); + + // Setup proxy settings + ProxyOptions proxy = new ProxyOptions(); + proxy.host = "your-proxy-host"; // Replace with your proxy host + proxy.port = 8080; // Replace with your proxy port + + // Optional: If the proxy requires authentication + proxy.username = "your-username"; // Replace with proxy username + proxy.password = "your-password"; // Replace with proxy password + proxy.prefAuthType = HttpAuth.Type.BASIC; // Choose your preferred authentication type (e.g., BASIC or DIGEST) -### Delivering push notifications + // Attach the proxy settings to the client options + options.proxy = proxy; -See https://www.ably.io/documentation/general/push/publish for detail. + // Create an instance of Ably using the configured options + AblyRest ably = new AblyRest(options); -Ably provides two models for delivering push notifications to devices. + // Alternatively, for real-time connections + AblyRealtime ablyRealtime = new AblyRealtime(options); -To publish a message to a channel including a push payload: - -``` -Message message = new Message("example", "realtime data"); -message.extras = io.ably.lib.util.JsonUtils.object() - .add("push", io.ably.lib.util.JsonUtils.object() - .add("notification", io.ably.lib.util.JsonUtils.object() - .add("title", "Hello from Ably!") - .add("body", "Example push notification from Ably.")) - .add("data", io.ably.lib.util.JsonUtils.object() - .add("foo", "bar") - .add("baz", "qux"))); - -rest.channels.get("pushenabled:foo").publishAsync(message, new CompletionListener() { - @Override - public void onSuccess() {} - - @Override - public void onError(ErrorInfo errorInfo) { - // Handle error. + // Use the Ably client as usual } -}); -``` - -To publish a push payload directly to a registered device: - -``` -Param[] recipient = new Param[]{new Param("deviceId", "xxxxxxxxxxx"); - -JsonObject payload = io.ably.lib.util.JsonUtils.object() - .add("notification", io.ably.lib.util.JsonUtils.object() - .add("title", "Hello from Ably!") - .add("body", "Example push notification from Ably.")) - .add("data", io.ably.lib.util.JsonUtils.object() - .add("foo", "bar") - .add("baz", "qux"))); - -rest.push.admin.publishAsync(recipient, payload, , new CompletionListener() { - @Override - public void onSuccess() {} - - @Override - public void onError(ErrorInfo errorInfo) { - // Handle error. - } - }); -``` - -### Activating a device and receiving notifications (Android only) - -See https://www.ably.io/documentation/general/push/activate-subscribe for detail. -In order to enable an app as a recipent of Ably push messages: - -- register your app with Firebase Cloud Messaging (FCM) and configure the FCM credentials in the app dashboard; -- include a service derived from `FirebaseMessagingService` and ensure it is started; -- include a method to handle registration notifications from Android, such as including a service derived from `AblyFirebaseInstanceIdService` and ensure it is started; -- initialise the device as an active push recipient: - -``` -realtime.setAndroidContext(context); -realtime.push.activate(); +} ``` -### Managing devices and subscriptions - -See https://www.ably.io/documentation/general/push/admin for details of the push admin API. - -## 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. - -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: - - ./gradlew - -but on Windows there is a batch file: - - gradlew.bat - -The JRE-specific library JAR is built with: - - ./gradlew java:jar - -There is also a task to build a fat JAR containing the dependencies: - - ./gradlew java:fullJar - -The Android-specific library AAR is built with: - - ./gradlew android:assemble - -(The `ANDROID_HOME` environment variable must be set appropriately.) - -## Code Standard - -### Checkstyle - -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`. -It does not run for the Android build yet. - -You can run just the Checkstyle rules on their own using: - - ./gradlew checkstyleMain - -### CodeNarc - -We use [CodeNarc](https://codenarc.org/) to enforce code style in our Gradle build scripts, which are all written in Groovy. - -You can run CodeNarc over all build scripts in this repository using: - - ./gradlew checkWithCodenarc - -For more details see the [`gradle-lint`](gradle-lint) project. - -### IDE Support - -We have a root [`.editorconfig`](.editorconfig) file, supporting [EditorConfig](https://editorconfig.org/), which should be of assistance within most IDEs. e.g.: - -- [VS Code](https://code.visualstudio.com/) using the [EditorConfig plugin](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) -- [IntelliJ IDEA](https://www.jetbrains.com/idea/) using the [bundled plugin](https://www.jetbrains.com/help/idea/configuring-code-style.html#editorconfig). - -## Tests - -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: - - ./gradlew - -but on Windows there is a batch file: - - gradlew.bat - -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 java:testRealtimeSuite - -To run tests against a specific host, specify in the environment: - - env ABLY_ENV=staging ./gradlew testRealtimeSuite - -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 - -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 - -### Interactive push tests - -End-to-end tests for push notifications (ie where the Android client is the target) can be tested interactively via a [separate app](https://github.com/ably/push-example-android). -There are [instructions there](https://github.com/ably/push-example-android#using-this-app-yourself) for setting up the necessary FCM account, configuring the credentials and other parameters, -in order to get end-to-end FCM notifications working. - -## Developing this library with an IDE - -The gradle project files can be imported to create projects in IntelliJ IDEA, Eclipse and Android Studio. - -### 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: - -- 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. - -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; -- select the Class as RealtimeSuite or RestSuite; -- select the relevant module for the classpath. - -In order to run the Android configuration it is necessary to set up the Android SDK path by selecting a project of module and opening the module settings. The Android SDK needs to be added under Platform Settings->SDKs. - -### 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 - -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. - -Interactive run/debug configurations for the java project can be created as follows: -- select Run->Run configurations ... -- create a new JUnit configuration -- select the java 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. - -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 OK to Gradle Sync. - -This creates a single android project and module. - -Configuration of Run/Debug configurations for running the unit tests on Android is the same as for IntelliJ IDEA (above). - -## Release process - -This library uses [semantic versioning](http://semver.org/). For each release, the following needs to be done: - -1. Create a branch for the release, named like `release/1.2.3` -2. Replace all references of the current version number with the new version number (check this file [README.md](./README.md) and [common.gradle](./common.gradle)) and commit the changes -3. Run [`github_changelog_generator`](https://github.com/skywinder/Github-Changelog-Generator) to update the [CHANGELOG](./CHANGELOG.md): - * This might work: `github_changelog_generator -u ably -p ably-java --header-label="# Changelog" --release-branch=release/1.2.3 --future-release=v1.2.3` - * But your mileage may vary as it can error. Perhaps more reliable is something like: `github_changelog_generator -u ably -p ably-java --since-tag v1.2.2 --output delta.md` and then manually merge the delta contents in to the main change log -4. Commit [CHANGELOG](./CHANGELOG.md) -5. Make a PR against `main` -6. Once the PR is approved, merge it into `main` -7. Add a tag and push to origin - e.g.: `git tag v1.2.3 && git push origin v1.2.3` -8. Create the release on Github including populating the release notes (needed so JFrog can pull them in) -9. Assemble and Upload ([see below](#publishing-to-jcenter-and-maven-central) for details) - but the overall order to follow is: - 1. Upload to Bintray and use the pushed tag, which will pull in the associated release notes - 2. Comment out local `repository` lines in the two `maven.gradle` files temporarily (this is horrible but is [to be fixed soon](https://github.com/ably/ably-java/issues/566)) - 3. Repeat the assemble stages to this time to push to Maven Central - -### Signing - -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 - -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`). - -The GPG key file is internal and private to Ably. - -### Publishing to JCenter and Maven Central - -We publish to: - -* JCenter via JFrog's [Bintray](https://bintray.com/ably-io/ably) -* Maven Central via Sonatype's [OSSRH](https://issues.sonatype.org/browse/OSSRH-52871) / [Nexus](https://oss.sonatype.org/#nexus-search;quick~io.ably) - -#### Releasing to JCenter (JFrog Bintray) - -The `java` release process goes as follows: - -* Go to the home page for the package; eg https://bintray.com/ably-io/ably/ably-java. Select Add a version, enter the new version such as "1.2.3" in name and save -* Run `./gradlew java:assembleRelease` locally to generate the files -* Open local relative folder in Finder, such as `./java/build/release/1.2.3/io/ably/ably-java/1.2.3` -* Go to the new version in JFrog Bintray; eg https://bintray.com/ably-io/ably/ably-java/1.2.3, then click on the link to upload via the UI in the "Upload files" section -* Drag in the files from Finder, just the `.jar` files and the `.pom` file. JFrog will fill in the "Target Path" box after you drop the files in. Click the "Upload" button. -* You will see a notice something like "4 unpublished files in your version. Will be deleted in 6 days and 22 hours. Publish all or Delete all unpublished files.", make sure you click "Publish all". Wait a few minutes and check that what's uploaded looks like what was uploaded for previous releases. The `maven-metadata` files are created by JFrog. -* Update the README text in Bintray (version number needs incrementing). - -Similarly for the `android` release at https://bintray.com/ably-io/ably/ably-android: - -* Run `./gradlew android:assembleRelease` locally to generate the files, and drag in the files in -`./android/build/release/1.2.3/io/ably/ably-android/1.2.3`. -* In this case upload the `.jar` files, the `.pom` file and the `.aar` file. - -#### Releasing to Maven Central (Sonatype Nexus) - -Bearing in mind the earlier instructions around commenting out lines in the `maven.gradle` files (temporary requirement) you then need to find the new staging repository in -[Nexus Repository Manager](https://oss.sonatype.org/#stagingRepositories) -and do a few things with it: +
-1. Check that it contains Android and Java releases. -2. "Close" it - this will take a few minutes during which time it will say (after a refresh of your browser) that "Activity: Operation in Progress". -3. Once it has closed you will have "Release" available. You can allow it to "automatically drop" after successful release. A refresh or two later of the browser and the staging repository will have disappeared from the list (i.e. it's been dropped which implies it was released successfully). -4. A [search for Ably packages](https://oss.sonatype.org/#nexus-search;quick~io.ably) should now list the new version for both `ably-android` and `ably-java`. +--- -### Creating the release on Github +## Contribute -Visit [https://github.com/ably/ably-java/tags](https://github.com/ably/ably-java/tags) and `Add release notes` for the release including links to the changelog entry and the JCenter releases. +Read the [CONTRIBUTING.md](./CONTRIBUTING.md) guidelines to contribute to Ably. -## Support, feedback and troubleshooting +--- -Please visit http://support.ably.io/ for access to our knowledgebase and to ask for any assistance. +## Releases -You can also view the [community reported Github issues](https://github.com/ably/ably-java/issues). +The [CHANGELOG.md](/ably/ably-java/blob/main/CHANGELOG.md) contains details of the latest releases for this SDK. You can also view all Ably releases on [changelog.ably.com](https://changelog.ably.com). -To see what has changed in recent versions of Bundler, see the [CHANGELOG](CHANGELOG.md). +--- -## Contributing +## Support, feedback, and troubleshooting -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. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request +For help or technical support, visit Ably's [support page](https://ably.com/support) or [GitHub Issues](https://github.com/ably/ably-java/issues) for community-reported bugs and discussions. diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index c2037065a..000000000 --- a/android/build.gradle +++ /dev/null @@ -1,102 +0,0 @@ -buildscript { - repositories { - mavenCentral() - mavenLocal() - jcenter() - google() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' - } -} - -apply plugin: 'com.android.library' -apply from: '../common.gradle' - -ext { - artifactId = 'ably-android' -} - -allprojects { - repositories { - jcenter() - google() - } -} - -android { - compileSdkVersion 22 - buildToolsVersion '28.0.3' - - defaultConfig { - buildConfigField 'String', 'LIBRARY_NAME', '"android"' - buildConfigField 'String', 'VERSION', "\"$version\"" - minSdkVersion 14 - targetSdkVersion 24 - versionCode 1 - versionName version - setProperty('archivesBaseName', "ably-android-$versionName") - testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner' - testInstrumentationRunnerArgument 'class', 'io.ably.lib.test.android.AndroidPushTest' - //testInstrumentationRunnerArgument "class", "io.ably.lib.test.rest.RestSuite,io.ably.lib.test.realtime.RealtimeSuite,io.ably.lib.test.android.AndroidSuite,io.ably.lib.test.android.AndroidPushTest" - testInstrumentationRunnerArgument 'timeout_msec', '300000' -// testInstrumentationRunnerArgument "ABLY_ENV", "\"$System.env.ABLY_ENV\"" - consumerProguardFiles 'proguard.txt' - } - - buildTypes { - release { - minifyEnabled false - } - } - - lintOptions { - abortOnError false - } - - sourceSets { - main { - java { - srcDirs = ['src/main/java', '../lib/src/main/java'] - } - } - androidTest { - java { - srcDirs = ['src/androidTest/java', '../lib/src/test/java'] - } - assets { - srcDirs = ['../lib/src/test/resources'] - } - } - } -} - -/* Fix for android test logging. Source: https://code.google.com/p/android/issues/detail?id=182307 */ -tasks.withType(com.android.build.gradle.internal.tasks.AndroidTestTask) { task -> - task.doFirst { - logging.level = LogLevel.INFO - } - task.doLast { - logging.level = LogLevel.LIFECYCLE - } -} - -apply from: '../dependencies.gradle' -apply from: './dependencies.gradle' -dependencies { - implementation 'com.google.firebase:firebase-messaging:17.3.4' - androidTestImplementation 'com.android.support.test:runner:0.5' - androidTestImplementation 'com.android.support.test:rules:0.5' - androidTestImplementation 'com.crittercism.dexmaker:dexmaker:1.4' - androidTestImplementation 'com.crittercism.dexmaker:dexmaker-dx:1.4' - androidTestImplementation 'com.crittercism.dexmaker:dexmaker-mockito:1.4' -} - -configurations { - all*.exclude group: 'org.hamcrest', module: 'hamcrest-core' - androidTestImplementation { - extendsFrom testImplementation - } -} - -apply from: 'maven.gradle' diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..fb70f02e0 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.lib" + defaultConfig { + minSdk = 19 + compileSdk = 34 + buildConfigField("String", "LIBRARY_NAME", "\"android\"") + buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") + testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["class"] = "io.ably.lib.test.android.AndroidPushTest" + testInstrumentationRunnerArguments["timeout_msec"] = "300000" + consumerProguardFiles("proguard.txt") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + buildFeatures { + buildConfig = true + } + + lint { + abortOnError = false + } + + testOptions.targetSdk = 34 + + sourceSets { + getByName("main") { + java.srcDirs("src/main/java", "../lib/src/main/java") + } + getByName("androidTest") { + java.srcDirs("src/androidTest/java", "../lib/src/test/java") + assets.srcDirs("../lib/src/test/resources") + } + } +} + +dependencies { + api(libs.gson) + implementation(libs.bundles.common) + compileOnly(libs.jetbrains) + testImplementation(libs.bundles.tests) + implementation(project(":network-client-core")) + runtimeOnly(project(":network-client-default")) + implementation(libs.firebase.messaging) + androidTestImplementation(libs.bundles.instrumental.android) +} + +configurations { + all { + exclude(group = "org.hamcrest", module = "hamcrest-core") + } + getByName("androidTestImplementation") { + extendsFrom(configurations.getByName("testImplementation")) + } +} diff --git a/android/dependencies.gradle b/android/dependencies.gradle deleted file mode 100644 index 2f4356387..000000000 --- a/android/dependencies.gradle +++ /dev/null @@ -1,3 +0,0 @@ -dependencies { - implementation 'io.azam.ulidj:ulidj:[1.0,2.0[' -} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..c08c36bea --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +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/android/maven.gradle b/android/maven.gradle deleted file mode 100644 index 5f86d60f7..000000000 --- a/android/maven.gradle +++ /dev/null @@ -1,139 +0,0 @@ -apply plugin: 'maven' -apply plugin: 'signing' - -final String GROUP_ID = 'io.ably' -final String ARTIFACT_ID = 'ably-android' -final String LOCAL_RELEASE_DESTINATION = "${buildDir}/release/${version}" -final String MAVEN_USER = hasProperty('ossrhUsername') ? ossrhUsername : '' -final String MAVEN_PASSWORD = hasProperty('ossrhPassword') ? ossrhPassword : '' - -/* - * Task which signs and uploads the Android artifacts to Nexus OSSRH. - */ -uploadArchives { - signing { - sign configurations.archives - } - repositories.mavenDeployer { - logger.lifecycle('OSSRH auth with username: ' + MAVEN_USER) - - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - - repository(url: 'https://oss.sonatype.org/service/local/staging/deploy/maven2/') { - authentication(userName: MAVEN_USER, password: MAVEN_PASSWORD) - } - - snapshotRepository(url: 'https://oss.sonatype.org/content/repositories/snapshots/') { - authentication(userName: MAVEN_USER, password: MAVEN_PASSWORD) - } - pom.groupId = GROUP_ID - pom.artifactId = ARTIFACT_ID - pom.version = version - - // Add other pom properties here if you want (developer details / licenses) - pom.project { - name 'Ably Android client library' - description 'An Android Realtime and REST client library for [Ably.io](https://www.ably.io), the realtime messaging service.' - packaging 'aar' - inceptionYear '2015' - url 'https://www.github.com/ably/ably-java' - developers { - developer { - name 'Paddy Byers' - email 'paddy@ably.io' - url 'https://github.com/paddybyers' - id 'paddybyers' - } - } - scm { - url 'scm:git:https://github.com/ably/ably-java' - connection 'scm:git:https://github.com/ably/ably-java' - developerConnection 'scm:git:git@github.com:ably/ably-java' - } - organization { - name 'Ably' - url 'http://ably.io' - } - issueManagement { - system 'Github' - url 'https://github.com/ably/ably-java/issues' - } - licenses { - license { - name 'The Apache Software License, Version 2.0' - url 'https://raw.github.com/ably/ably-java/main/LICENSE' - distribution 'repo' - } - } - } - - pom.whenConfigured { p -> - p.dependencies = p.dependencies.findAll { - // Exclude dependency on lib subproject. - dep -> dep.artifactId != 'lib' - }.findAll { - // Exclude Google services since we don't want to impose a particular - // version on users. Ideally we would specify a version range, - // but the Google services Gradle plugin doesn't seem to - // support that. - // TODO: Make sure this works when installing from Maven! - dep -> dep.artifactId != 'play-services-gcm' && dep.artifactId != 'firebase-messaging' - } - } - - // Export to local Maven cache - // COMMENT OUT THIS LINE AND THE ONE BELOW IN ORDER TO RELEASE TO SONATYPE NEXUS STAGING - // TODO https://github.com/ably/ably-java/issues/566 - repository(url: repositories.mavenLocal().url) - - // Export files to local storage - // COMMENT OUT THIS LINE AND THE ONE ABOVE IN ORDER TO RELEASE TO SONATYPE NEXUS STAGING - // TODO https://github.com/ably/ably-java/issues/566 - repository(url: "file://${LOCAL_RELEASE_DESTINATION}") - } -} - -task zipRelease(type: Zip) { - from LOCAL_RELEASE_DESTINATION - destinationDir buildDir - archiveName "release-${version}.zip" -} - -tasks.whenTaskAdded { task -> - if (task.name == 'assembleRelease') { - task.doLast { - logger.quiet("Release ${version} can be found at ${LOCAL_RELEASE_DESTINATION}/") - logger.quiet("Release ${version} zipped can be found ${buildDir}/release-${version}.zip") - } - - task.dependsOn(uploadArchives) - task.dependsOn(zipRelease) - } -} - -task sourcesJar(type: Jar) { - classifier = 'sources' - from android.sourceSets.main.java.srcDirs -} - -task javadoc(type: Javadoc) { - source = android.sourceSets.main.java.srcDirs - classpath += project.files(android.bootClasspath.join(File.pathSeparator)) - failOnError false -} - -afterEvaluate { - javadoc.classpath += files(android.libraryVariants.collect { variant -> - variant.javaCompile.classpath.files - }) -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir -} - -artifacts { - archives sourcesJar - archives javadocJar -} diff --git a/android/proguard.txt b/android/proguard.txt index 7515522e7..09698f596 100644 --- a/android/proguard.txt +++ b/android/proguard.txt @@ -3,5 +3,6 @@ -keep class org.msgpack.core.** {*;} -keepclasseswithmembers class io.ably.lib.rest.Auth** {*;} -keep class com.google.gson.** {*;} --keep class io.azam.ulidj.** {*;} -dontwarn org.msgpack.core.buffer.** +-dontwarn org.slf4j.** +-dontwarn lombok.** diff --git a/android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java b/android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java new file mode 100644 index 000000000..8f1982ec4 --- /dev/null +++ b/android/src/androidTest/java/io/ably/lib/push/LocalDeviceStorageTest.java @@ -0,0 +1,155 @@ +package io.ably.lib.push; + +import android.content.Context; +import android.support.test.runner.AndroidJUnit4; +import io.ably.lib.types.RegistrationToken; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.reflect.Field; +import java.util.HashMap; + +import static android.support.test.InstrumentationRegistry.getContext; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class LocalDeviceStorageTest { + private static Context context; + private static ActivationContext activationContext; + + + private HashMap hashMap = new HashMap<>(); + + private Storage inMemoryStorage = new Storage() { + @Override + public void put(String key, String value) { + hashMap.put(key, value); + } + + @Override + public void put(String key, int value) { + hashMap.put(key, value); + } + + @Override + public String get(String key, String defaultValue) { + Object value = hashMap.get(key); + return value != null ? (String) value : defaultValue; + } + + @Override + public int get(String key, int defaultValue) { + Object value = hashMap.get(key); + return value != null ? (int) value : defaultValue; + } + + @Override + public void clear(String[] keys) { + hashMap = new HashMap<>(); + } + }; + + @BeforeClass + public static void setUp() { + context = getContext(); + activationContext = new ActivationContext(context.getApplicationContext()); + } + + @Test + public void shared_preferences_storage_used_by_default() { + LocalDevice localDevice = new LocalDevice(activationContext, null); + /* initialize properties in storage */ + localDevice.create(); + + /* verify custom storage is not used */ + assertTrue(hashMap.isEmpty()); + + /* load properties */ + assertNotNull(localDevice.id); + assertNotNull(localDevice.deviceSecret); + } + + @Test + public void shared_preferences_storage_works_correctly() { + LocalDevice localDevice = new LocalDevice(activationContext, null); + + RegistrationToken registrationToken= new RegistrationToken(RegistrationToken.Type.FCM, "ABLY"); + /* initialize properties in storage */ + localDevice.create(); + localDevice.setAndPersistRegistrationToken(registrationToken); + + /* verify custom storage is not used */ + assertTrue(hashMap.isEmpty()); + + /* load properties */ + assertNotNull(localDevice.id); + assertNotNull(localDevice.deviceSecret); + assertTrue(localDevice.isCreated()); + assertEquals("FCM", localDevice.getRegistrationToken().type.name()); + assertEquals("ABLY", localDevice.getRegistrationToken().token); + + /* reset all properties */ + localDevice.reset(); + + /* properties were cleared */ + assertNull(localDevice.id); + assertNull(localDevice.deviceSecret); + assertNull(localDevice.getRegistrationToken()); + } + + @Test + public void custom_storage_used_if_provided() { + LocalDevice localDevice = new LocalDevice(activationContext, inMemoryStorage); + /* initialize properties in storage */ + localDevice.create(); + + /* verify in memory storage is used */ + assertFalse(hashMap.isEmpty()); + + /* load properties */ + assertNotNull(localDevice.id); + assertNotNull(localDevice.deviceSecret); + + String deviceId = localDevice.id; + String deviceSecret = localDevice.deviceSecret; + + /* values are the same */ + assertEquals(deviceId, hashMap.get("ABLY_DEVICE_ID")); + assertEquals(deviceSecret, hashMap.get("ABLY_DEVICE_SECRET")); + } + + @Test + public void custom_storage_works_correctly() { + LocalDevice localDevice = new LocalDevice(activationContext, inMemoryStorage); + + RegistrationToken registrationToken= new RegistrationToken(RegistrationToken.Type.FCM, "ABLY"); + /* initialize properties in storage */ + localDevice.create(); + localDevice.setAndPersistRegistrationToken(registrationToken); + + /* verify custom storage is used */ + assertFalse(hashMap.isEmpty()); + + /* load properties */ + assertNotNull(localDevice.id); + assertNotNull(localDevice.deviceSecret); + assertTrue(localDevice.isCreated()); + assertEquals("FCM", localDevice.getRegistrationToken().type.name()); + assertEquals("ABLY", localDevice.getRegistrationToken().token); + + /* reset all properties */ + localDevice.reset(); + /* verify custom storage was cleared out */ + assertTrue(hashMap.isEmpty()); + + /* properties were cleared */ + assertNull(localDevice.id); + assertNull(localDevice.deviceSecret); + assertNull(localDevice.getRegistrationToken()); + } +} diff --git a/android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java b/android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java new file mode 100644 index 000000000..6584ae3b0 --- /dev/null +++ b/android/src/androidTest/java/io/ably/lib/test/RetryTestRule.java @@ -0,0 +1,49 @@ +package io.ably.lib.test; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + + +public class RetryTestRule implements TestRule { + + private final int timesToRunTestCount; + + /** + * If `times` is 0, then we should run the test once. + */ + public RetryTestRule(int times) { + this.timesToRunTestCount = times + 1; + } + + @Override + public Statement apply(Statement base, Description description) { + return statement(base, description); + } + + private Statement statement(Statement base, Description description) { + return new Statement() { + + @Override + public void evaluate() throws Throwable { + Throwable latestException = null; + + for (int runCount = 0; runCount < timesToRunTestCount; runCount++) { + try { + base.evaluate(); + return; + } catch (Throwable t) { + latestException = t; + System.err.printf("%s: test failed on run: `%d`. Will run a maximum of `%d` times.%n", description.getDisplayName(), runCount, timesToRunTestCount); + t.printStackTrace(); + } + } + + if (latestException != null) { + System.err.printf("%s: giving up after `%d` failures%n", description.getDisplayName(), timesToRunTestCount); + throw latestException; + } + } + }; + } +} diff --git a/android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java b/android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java index b1a813beb..6efcc83f1 100644 --- a/android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java +++ b/android/src/androidTest/java/io/ably/lib/test/android/AndroidPushTest.java @@ -1,14 +1,20 @@ package io.ably.lib.test.android; -import android.content.*; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; import android.preference.PreferenceManager; -import android.support.v4.content.LocalBroadcastManager; -import android.test.AndroidTestCase; - +import android.support.test.filters.SdkSuppress; +import android.support.test.runner.AndroidJUnit4; import android.util.Log; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.google.gson.JsonObject; +import io.ably.lib.debug.DebugOptions; import io.ably.lib.http.HttpCore; -import io.ably.lib.push.*; +import io.ably.lib.push.ActivationContext; +import io.ably.lib.push.ActivationStateMachine; import io.ably.lib.push.ActivationStateMachine.AfterRegistrationSyncFailed; import io.ably.lib.push.ActivationStateMachine.CalledActivate; import io.ably.lib.push.ActivationStateMachine.CalledDeactivate; @@ -21,46 +27,69 @@ import io.ably.lib.push.ActivationStateMachine.NotActivated; import io.ably.lib.push.ActivationStateMachine.RegistrationSynced; import io.ably.lib.push.ActivationStateMachine.State; +import io.ably.lib.push.ActivationStateMachine.SyncRegistrationFailed; import io.ably.lib.push.ActivationStateMachine.WaitingForDeregistration; import io.ably.lib.push.ActivationStateMachine.WaitingForDeviceRegistration; import io.ably.lib.push.ActivationStateMachine.WaitingForNewPushDeviceDetails; import io.ably.lib.push.ActivationStateMachine.WaitingForPushDeviceDetails; import io.ably.lib.push.ActivationStateMachine.WaitingForRegistrationSync; -import io.ably.lib.push.ActivationStateMachine.SyncRegistrationFailed; -import io.ably.lib.rest.DeviceDetails; -import io.ably.lib.types.*; -import io.ably.lib.util.Base64Coder; -import io.azam.ulidj.ULID; -import junit.extensions.TestSetup; -import junit.framework.TestSuite; - -import junit.framework.Test; - -import java.util.ArrayList; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.TimeUnit; - -import io.ably.lib.debug.DebugOptions; +import io.ably.lib.push.LocalDevice; +import io.ably.lib.push.Push; +import io.ably.lib.push.PushBase; +import io.ably.lib.push.PushChannel; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.rest.Channel; +import io.ably.lib.rest.DeviceDetails; +import io.ably.lib.test.RetryTestRule; import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.Helpers.AsyncWaiter; import io.ably.lib.test.common.Helpers.CompletionWaiter; import io.ably.lib.test.common.Setup; import io.ably.lib.test.util.TestCases; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Callback; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Param; +import io.ably.lib.types.RegistrationToken; +import io.ably.lib.util.Base64Coder; import io.ably.lib.util.IntentUtils; import io.ably.lib.util.JsonUtils; import io.ably.lib.util.Serialisation; +import java9.util.stream.StreamSupport; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +import static android.support.test.InstrumentationRegistry.getContext; import static io.ably.lib.test.common.Helpers.assertArrayUnorderedEquals; import static io.ably.lib.test.common.Helpers.assertInstanceOf; import static io.ably.lib.test.common.Helpers.assertSize; import static io.ably.lib.util.Serialisation.gson; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@RunWith(AndroidJUnit4.class) +public class AndroidPushTest { + private static final int TIMEOUT_SECONDS = 30; -public class AndroidPushTest extends AndroidTestCase { + @Rule + public RetryTestRule retryRule = new RetryTestRule(2); private class TestActivation { private Helpers.RawHttpTracker httpTracker; @@ -77,6 +106,7 @@ public class Options { public DebugOptions clientOptions; public boolean clearPersisted = true; public TestActivationContext activationContext; + public boolean resetMachineState = false; } TestActivation(Helpers.AblyFunction configure) { @@ -101,6 +131,9 @@ public class Options { activationContext.reset(); } machine = new TestActivationStateMachine(activationContext); + if (activationOptions.resetMachineState) { + machine.resetState(); + } activationContext.setActivationStateMachine(machine); rest = new AblyRest(options); @@ -112,7 +145,11 @@ public class Options { adminRest.auth.authorize(new Auth.TokenParams() {{ clientId = Auth.WILDCARD_CLIENTID; }}, null); - } catch(AblyException e) {} + } catch(final AblyException e) { + // Re-throw as an unchecked exception. + // We want the test suite to fail if this constructor fails. + throw new RuntimeException(e); + } } private void registerAndWait() throws AblyException { @@ -149,41 +186,31 @@ private void moveToAfterRegistrationUpdateFailed() throws AblyException { } } - public static Test suite() { - TestSuite suite = new TestSuite(); - suite.addTest(new TestSetup(new TestSuite(AndroidPushTest.class)) { - protected void setUp() throws Exception { - setUpBeforeClass(); - } - protected void tearDown() throws Exception { - tearDownAfterClass(); - } - }); - return suite; - } - // RSH2a - public void test_push_activate() throws InterruptedException, AblyException { + @Test + public void push_activate() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); BlockingQueue events = activation.machine.getEventReceiver(2); // CalledActivate + GotPushDeviceDetails assertInstanceOf(ActivationStateMachine.NotActivated.class, activation.machine.current); activation.rest.push.activate(); - Event event = events.poll(10, TimeUnit.SECONDS); + Event event = events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); assertInstanceOf(CalledActivate.class, event); } // RSH2b - public void test_push_deactivate() throws InterruptedException, AblyException { + @Test + public void push_deactivate() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); BlockingQueue events = activation.machine.getEventReceiver(1); assertInstanceOf(NotActivated.class, activation.machine.current); activation.rest.push.deactivate(); - Event event = events.poll(10, TimeUnit.SECONDS); + Event event = events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); assertInstanceOf(CalledDeactivate.class, event); } // RSH2c / RSH8g - public void test_push_onNewRegistrationToken() throws InterruptedException, AblyException { + @Test + public void push_onNewRegistrationToken() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); BlockingQueue events = activation.machine.getEventReceiver(1); final BlockingQueue> tokenCallbacks = new ArrayBlockingQueue<>(1) ; @@ -201,19 +228,21 @@ public Void apply(Callback callback) throws AblyException { }; activation.rest.push.activate(true); // This registers the listener for registration tokens. - assertInstanceOf(CalledActivate.class, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(CalledActivate.class, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); - Callback tokenCallback = tokenCallbacks.poll(10, TimeUnit.SECONDS); + final Callback tokenCallback = tokenCallbacks.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull("Token callback not received before timeout.", tokenCallback); tokenCallback.onSuccess("foo"); - assertInstanceOf(GotPushDeviceDetails.class, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(GotPushDeviceDetails.class, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); tokenCallback.onSuccess("bar"); - assertInstanceOf(GotPushDeviceDetails.class, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(GotPushDeviceDetails.class, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); } // RSH2d / RSH8h - public void test_push_onNewRegistrationTokenFailed() throws InterruptedException, AblyException { + @Test + public void push_onNewRegistrationTokenFailed() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); BlockingQueue events = activation.machine.getEventReceiver(1); final BlockingQueue> tokenCallbacks = new ArrayBlockingQueue<>(1) ; @@ -231,18 +260,20 @@ public Void apply(Callback callback) throws AblyException { }; activation.rest.push.activate(true); // This registers the listener for registration tokens. - assertInstanceOf(CalledActivate.class, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(CalledActivate.class, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); - Callback tokenCallback = tokenCallbacks.poll(10, TimeUnit.SECONDS); + final Callback tokenCallback = tokenCallbacks.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull("Token callback not received before timeout.", tokenCallback); tokenCallback.onError(new ErrorInfo("foo", 123, 123)); - Event event = events.poll(10, TimeUnit.SECONDS); + Event event = events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); assertInstanceOf(ActivationStateMachine.GettingPushDeviceDetailsFailed.class, event); assertEquals(123,((ActivationStateMachine.GettingPushDeviceDetailsFailed) event).reason.code); } // RSH2e / RSH8i - public void test_push_syncOnStartup() throws InterruptedException, AblyException { + @Test + public void push_syncOnStartup() throws InterruptedException, AblyException { final BlockingQueue> tokenCallbacks = new ArrayBlockingQueue<>(1) ; Helpers.AblyFunction configureActivation = new Helpers.AblyFunction() { @@ -318,7 +349,8 @@ public Void apply(Callback callback) throws AblyException { } // RSH8a, RSH8c - public void test_push_device_persistence() throws InterruptedException, AblyException { + @Test + public void push_device_persistence() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(new Helpers.AblyFunction() { @Override public Void apply(TestActivation.Options options) throws AblyException { @@ -362,7 +394,8 @@ public Void apply(TestActivation.Options options) throws AblyException { } // RSH8d - public void test_push_late_clientId_persisted() throws InterruptedException, AblyException { + @Test + public void push_late_clientId_persisted() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); assertNull(activation.rest.auth.clientId); @@ -386,7 +419,8 @@ public Void apply(TestActivation.Options options) throws AblyException { } // RSH8e - public void test_push_late_clientId_emits_GotPushDeviceDetails() throws InterruptedException, AblyException { + @Test + public void push_late_clientId_emits_GotPushDeviceDetails() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); // Fake-register the device. @@ -410,7 +444,8 @@ public void test_push_late_clientId_emits_GotPushDeviceDetails() throws Interrup } // RSH8f - public void test_push_clientId_from_server() throws InterruptedException, AblyException { + @Test + public void push_clientId_from_server() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); JsonObject body = new JsonObject(); @@ -438,7 +473,8 @@ public void test_push_clientId_from_server() throws InterruptedException, AblyEx } // RSH3a1 - public void test_NotActivated_on_CalledDeactivate() { + @Test + public void NotActivated_on_CalledDeactivate() { TestActivation activation = new TestActivation(); ActivationStateMachine.State state = new NotActivated(activation.machine); @@ -456,7 +492,9 @@ public void test_NotActivated_on_CalledDeactivate() { } // RSH3a2a - public void test_NotActivated_on_CalledActivate_with_DeviceToken() throws Exception { + // DISABLED - see: https://github.com/ably/ably-java/issues/739 + // @Test + public void NotActivated_on_CalledActivate_with_DeviceToken() throws Exception { class TestCase extends TestCases.Base { private final String persistedClientId; private final String instanceClientId; @@ -514,6 +552,10 @@ public Void apply(TestActivation.Options options) throws AblyException { public Void apply(TestActivation.Options options) throws AblyException { options.clientOptions.clientId = instanceClientId; options.clearPersisted = false; + // We're creating a second TestActivation (in this test) which creates a second + // ActivationStateMachine. This machine will try to read the persisted state from the + // first one which will result in test failure. To fix it we're resetting the machine. + options.resetMachineState = true; return null; } }); @@ -574,7 +616,7 @@ public Void apply(TestActivation.Options options) throws AblyException { activation.httpTracker.unlockRequests(); } - assertInstanceOf(expectedEvent, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(expectedEvent, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull(handled.waitFor()); } // else: RSH3a2a1 validation failed @@ -664,7 +706,8 @@ public Void apply(TestActivation.Options options) throws AblyException { } // RSH3a3a - public void test_NotActivated_on_GotPushDeviceDetails() throws InterruptedException { + @Test + public void NotActivated_on_GotPushDeviceDetails() throws InterruptedException { TestActivation activation = new TestActivation(); State state = new NotActivated(activation.machine); @@ -675,7 +718,8 @@ public void test_NotActivated_on_GotPushDeviceDetails() throws InterruptedExcept } // RSH3a2b - public void test_NotActivated_on_CalledActivate_with_registrationToken() throws InterruptedException, AblyException { + @Test + public void NotActivated_on_CalledActivate_with_registrationToken() throws InterruptedException, AblyException { TestActivation activation = new TestActivation(); activation.rest.push.getActivationContext().onNewRegistrationToken(RegistrationToken.Type.FCM, "testToken"); @@ -694,7 +738,8 @@ public void test_NotActivated_on_CalledActivate_with_registrationToken() throws } // RSH3a2c - public void test_NotActivated_on_CalledActivate_without_registrationToken() throws InterruptedException { + @Test + public void NotActivated_on_CalledActivate_without_registrationToken() throws InterruptedException { TestActivation activation = new TestActivation(); State state = new NotActivated(activation.machine); State to = state.transition(new CalledActivate()); @@ -705,7 +750,8 @@ public void test_NotActivated_on_CalledActivate_without_registrationToken() thro } // RSH3b1 - public void test_WaitingForPushDeviceDetails_on_CalledActivate() { + @Test + public void WaitingForPushDeviceDetails_on_CalledActivate() { TestActivation activation = new TestActivation(); State state = new WaitingForPushDeviceDetails(activation.machine); State to = state.transition(new CalledActivate()); @@ -717,7 +763,8 @@ public void test_WaitingForPushDeviceDetails_on_CalledActivate() { } // RSH3b2 - public void test_WaitingForPushDeviceDetails_on_CalledDeactivate() { + @Test + public void WaitingForPushDeviceDetails_on_CalledDeactivate() { TestActivation activation = new TestActivation(); State state = new WaitingForPushDeviceDetails(activation.machine); @@ -736,7 +783,8 @@ public void test_WaitingForPushDeviceDetails_on_CalledDeactivate() { } // RSH3b3 - public void test_WaitingForPushDeviceDetails_on_GotPushDeviceDetails() throws Exception { + @Test + public void WaitingForPushDeviceDetails_on_GotPushDeviceDetails() throws Exception { class TestCase extends TestCases.Base { private final ErrorInfo registerError; private final boolean useCustomRegistrar; @@ -824,7 +872,7 @@ public Void apply(Callback callback) throws AblyException { activation.httpTracker.unlockRequests(); } - assertInstanceOf(expectedEvent, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(expectedEvent, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull(handled.waitFor()); // RSH3c2a @@ -887,7 +935,8 @@ public Void apply(Callback callback) throws AblyException { } // RSH3c1 - public void test_WaitingForDeviceRegistration_on_CalledActivate() { + @Test + public void WaitingForDeviceRegistration_on_CalledActivate() { TestActivation activation = new TestActivation(); State state = new WaitingForDeviceRegistration(activation.machine); State to = state.transition(new CalledActivate()); @@ -899,7 +948,8 @@ public void test_WaitingForDeviceRegistration_on_CalledActivate() { } // RSH3d1 - public void test_WaitingForNewPushDeviceDetails_on_CalledActivate() { + @Test + public void WaitingForNewPushDeviceDetails_on_CalledActivate() { TestActivation activation = new TestActivation(); State state = new WaitingForNewPushDeviceDetails(activation.machine); @@ -918,7 +968,8 @@ public void test_WaitingForNewPushDeviceDetails_on_CalledActivate() { } // RSH3d2 - public void test_WaitingForNewPushDeviceDetails_on_CalledDeactivate() throws Exception { + @Test + public void WaitingForNewPushDeviceDetails_on_CalledDeactivate() throws Exception { new DeactivateTest(WaitingForNewPushDeviceDetails.class) { @Override protected void setUpMachineState(TestCase testCase) throws AblyException { @@ -928,7 +979,9 @@ protected void setUpMachineState(TestCase testCase) throws AblyException { } // RSH3d3 - public void test_WaitingForNewPushDeviceDetails_on_GotPushDeviceDetails() throws Exception { + @Test + @SdkSuppress(minSdkVersion = 21) + public void WaitingForNewPushDeviceDetails_on_GotPushDeviceDetails() throws Exception { new UpdateRegistrationTest() { @Override protected void setUpMachineState(TestCase testCase) throws AblyException { @@ -939,7 +992,8 @@ protected void setUpMachineState(TestCase testCase) throws AblyException { } // RSH3e1 - public void test_WaitingForRegistrationUpdate_on_CalledActivate() { + @Test + public void WaitingForRegistrationUpdate_on_CalledActivate() { TestActivation activation = new TestActivation(); State state = new WaitingForRegistrationSync(activation.machine, null); @@ -958,7 +1012,8 @@ public void test_WaitingForRegistrationUpdate_on_CalledActivate() { } // RSH3e2 - public void test_WaitingForRegistrationUpdate_on_RegistrationUpdated() { + @Test + public void WaitingForRegistrationUpdate_on_RegistrationUpdated() { TestActivation activation = new TestActivation(); State state = new WaitingForRegistrationSync(activation.machine, null); @@ -970,7 +1025,8 @@ public void test_WaitingForRegistrationUpdate_on_RegistrationUpdated() { } // RSH3e3 - public void test_WaitingForRegistrationUpdate_on_UpdatingRegistrationFailed() { + @Test + public void WaitingForRegistrationUpdate_on_UpdatingRegistrationFailed() { TestActivation activation = new TestActivation(); State state = new WaitingForRegistrationSync(activation.machine, null); ErrorInfo reason = new ErrorInfo("test", 123); @@ -991,7 +1047,8 @@ public void test_WaitingForRegistrationUpdate_on_UpdatingRegistrationFailed() { } // RSH3f1 - public void test_AfterRegistrationUpdateFailed_on_GotPushDeviceDetails() throws Exception { + @Test + public void AfterRegistrationUpdateFailed_on_GotPushDeviceDetails() throws Exception { new UpdateRegistrationTest() { @Override protected void setUpMachineState(TestCase testCase) throws AblyException { @@ -1003,7 +1060,8 @@ protected void setUpMachineState(TestCase testCase) throws AblyException { } // RSH3f1 - public void test_AfterRegistrationUpdateFailed_on_CalledActivate() throws Exception { + @Test + public void AfterRegistrationUpdateFailed_on_CalledActivate() throws Exception { new UpdateRegistrationTest("PUSH_ACTIVATE") { @Override protected void setUpMachineState(TestCase testCase) throws AblyException { @@ -1020,7 +1078,8 @@ protected String sendInitialEvent(UpdateRegistrationTest.TestCase testCase) thro } // RSH3f1 - public void test_AfterRegistrationUpdateFailed_on_CalledDeactivate() throws Exception { + @Test + public void AfterRegistrationUpdateFailed_on_CalledDeactivate() throws Exception { new DeactivateTest(AfterRegistrationSyncFailed.class) { @Override protected void setUpMachineState(TestCase testCase) throws AblyException { @@ -1031,7 +1090,8 @@ protected void setUpMachineState(TestCase testCase) throws AblyException { } // RSH3g1 - public void test_WaitingForDeregistration_on_CalledDeactivate() throws Exception { + @Test + public void WaitingForDeregistration_on_CalledDeactivate() throws Exception { TestActivation activation = new TestActivation(); State state = new WaitingForDeregistration(activation.machine, null); @@ -1042,7 +1102,8 @@ public void test_WaitingForDeregistration_on_CalledDeactivate() throws Exception } // RSH3g2 - public void test_WaitingForDeregistration_on_Deregistered() throws Exception { + @Test + public void WaitingForDeregistration_on_Deregistered() throws Exception { TestActivation activation = new TestActivation(); State state = new WaitingForDeregistration(activation.machine, null); @@ -1064,7 +1125,8 @@ public void test_WaitingForDeregistration_on_Deregistered() throws Exception { } // RSH3g3 - public void test_WaitingForDeregistration_on_DeregistrationFailed() throws Exception { + @Test + public void WaitingForDeregistration_on_DeregistrationFailed() throws Exception { class TestCase extends TestCases.Base { private TestActivation testActivation; private State previousState; @@ -1112,7 +1174,8 @@ public void run() throws Exception { } // RSH4a1 - public void test_PushChannel_subscribeDevice_not_registered() throws AblyException { + @Test + public void PushChannel_subscribeDevice_not_registered() throws AblyException { TestActivation activation = new TestActivation(); Channel channel = activation.rest.channels.get("pushenabled:foo"); @@ -1130,7 +1193,8 @@ public void test_PushChannel_subscribeDevice_not_registered() throws AblyExcepti } // RSH4a2 - public void test_PushChannel_subscribeDevice_ok() throws AblyException { + @Test + public void PushChannel_subscribeDevice_ok() throws AblyException { TestActivation activation = new TestActivation(); Channel channel = activation.rest.channels.get("pushenabled:foo"); PushBase.ChannelSubscription sub = null; @@ -1155,7 +1219,8 @@ public void test_PushChannel_subscribeDevice_ok() throws AblyException { } // RSH4b1 - public void test_PushChannel_subscribeClient_not_registered() throws AblyException { + @Test + public void PushChannel_subscribeClient_not_registered() throws AblyException { TestActivation activation = new TestActivation(); Channel channel = activation.rest.channels.get("pushenabled:foo"); @@ -1167,7 +1232,8 @@ public void test_PushChannel_subscribeClient_not_registered() throws AblyExcepti } // RSH4b2 - public void test_PushChannel_subscribeClient_ok() throws AblyException { + @Test + public void PushChannel_subscribeClient_ok() throws AblyException { TestActivation activation = new TestActivation(); final String testClientId = "testClient"; activation.rest.auth.setClientId(testClientId); @@ -1196,7 +1262,8 @@ public void test_PushChannel_subscribeClient_ok() throws AblyException { } // RSH4c1 - public void test_PushChannel_unsubscribeDevice_not_registered() throws AblyException { + @Test + public void PushChannel_unsubscribeDevice_not_registered() throws AblyException { TestActivation activation = new TestActivation(); Channel channel = activation.rest.channels.get("pushenabled:foo"); PushBase.ChannelSubscription sub = PushBase.ChannelSubscription.forDevice(channel.name, activation.rest.push.getLocalDevice().id); @@ -1209,7 +1276,8 @@ public void test_PushChannel_unsubscribeDevice_not_registered() throws AblyExcep } // RSH4c2 - public void test_PushChannel_unsubscribeDevice_ok() throws AblyException { + @Test + public void PushChannel_unsubscribeDevice_ok() throws AblyException { TestActivation activation = new TestActivation(); Channel channel = activation.rest.channels.get("pushenabled:foo"); PushBase.ChannelSubscription sub = null; @@ -1236,7 +1304,8 @@ public void test_PushChannel_unsubscribeDevice_ok() throws AblyException { } // RSH4d1 - public void test_PushChannel_unsubscribeClient_not_registered() throws AblyException { + @Test + public void PushChannel_unsubscribeClient_not_registered() throws AblyException { TestActivation activation = new TestActivation(); Channel channel = activation.rest.channels.get("pushenabled:foo"); PushBase.ChannelSubscription sub = PushBase.ChannelSubscription.forClientId(channel.name, activation.rest.push.getLocalDevice().clientId); @@ -1249,7 +1318,8 @@ public void test_PushChannel_unsubscribeClient_not_registered() throws AblyExcep } // RSH4d2 - public void test_PushChannel_unsubscribeClient_ok() throws AblyException { + @Test + public void PushChannel_unsubscribeClient_ok() throws AblyException { TestActivation activation = new TestActivation(); final String testClientId = "testClient"; activation.rest.auth.setClientId(testClientId); @@ -1280,7 +1350,8 @@ public void test_PushChannel_unsubscribeClient_ok() throws AblyException { } // RSH4e - public void test_PushChannel_listSubscriptions() throws Exception { + @Test + public void PushChannel_listSubscriptions() throws Exception { class TestCase extends TestCases.Base { private boolean useClientId; private TestActivation testActivation; @@ -1293,8 +1364,11 @@ public TestCase(String name, boolean useClientId) { @Override public void run() throws Exception { testActivation = new TestActivation(); + + final String testClientId = "testClient"; + final String testChannel = "pushenabled:foo"; + if (useClientId) { - final String testClientId = "testClient"; testActivation.rest.auth.setClientId(testClientId); testActivation.rest.auth.authorize(new Auth.TokenParams() {{ clientId = testClientId; }}, null); } else { @@ -1316,12 +1390,12 @@ public void run() throws Exception { String deviceId = testActivation.rest.push.getLocalDevice().id; Push.ChannelSubscription[] fixtures = new Push.ChannelSubscription[] { - PushBase.ChannelSubscription.forDevice("pushenabled:foo", deviceId), - PushBase.ChannelSubscription.forDevice("pushenabled:foo", "other"), + PushBase.ChannelSubscription.forDevice(testChannel, deviceId), + PushBase.ChannelSubscription.forDevice(testChannel, "other"), PushBase.ChannelSubscription.forDevice("pushenabled:bar", deviceId), - PushBase.ChannelSubscription.forClientId("pushenabled:foo", "testClient"), - PushBase.ChannelSubscription.forClientId("pushenabled:foo", "otherClient"), - PushBase.ChannelSubscription.forClientId("pushenabled:bar", "testClient"), + PushBase.ChannelSubscription.forClientId(testChannel, testClientId), + PushBase.ChannelSubscription.forClientId(testChannel, "otherClient"), + PushBase.ChannelSubscription.forClientId("pushenabled:bar", testClientId), }; try { @@ -1331,12 +1405,20 @@ public void run() throws Exception { testActivation.adminRest.push.admin.channelSubscriptions.save(sub); } - Push.ChannelSubscription[] got = testActivation.rest.channels.get("pushenabled:foo").push.listSubscriptions().items(); + Param[] params = Param.array(new Param("deviceId", deviceId)); + params = Param.set(params, "channel", testChannel); + + if(useClientId) { + params = Param.set(params, "clientId", testClientId); + } + + Push.ChannelSubscription[] got = testActivation.rest.channels.get(testChannel) + .push.listSubscriptions(params).items(); ArrayList expected = new ArrayList<>(2); - expected.add(PushBase.ChannelSubscription.forDevice("pushenabled:foo", deviceId)); + expected.add(PushBase.ChannelSubscription.forDevice(testChannel, deviceId)); if (useClientId) { - expected.add(PushBase.ChannelSubscription.forClientId("pushenabled:foo", "testClient")); + expected.add(PushBase.ChannelSubscription.forClientId(testChannel, testClientId)); } assertArrayUnorderedEquals(expected.toArray(), got); @@ -1357,7 +1439,9 @@ public void run() throws Exception { testCases.run(); } - public void test_Realtime_push_interface() throws Exception { + @Test + @SdkSuppress(minSdkVersion = 21) + public void Realtime_push_interface() throws Exception { AblyRealtime realtime = new AblyRealtime(new ClientOptions() {{ autoConnect = false; key = "madeup"; @@ -1368,25 +1452,9 @@ public void test_Realtime_push_interface() throws Exception { assertInstanceOf(PushChannel.class, realtime.channels.get("test").push); } - public void test_push_AfterRegistrationUpdateFailed_migrate_to_AfterRegistrationSyncFailed() { - new TestActivation(); // Just for the side effect of clearing persisted state. - - SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(getContext().getApplicationContext()).edit(); - editor.putString(ActivationStateMachine.PersistKeys.CURRENT_STATE, "io.ably.lib.push.ActivationStateMachine$AfterRegistrationUpdateFailed"); - assertTrue(editor.commit()); - - TestActivation activation = new TestActivation(new Helpers.AblyFunction() { - @Override - public Void apply(TestActivation.Options options) throws AblyException { - options.clearPersisted = false; - return null; - } - }); - assertInstanceOf(AfterRegistrationSyncFailed.class, activation.machine.current); - } - // https://github.com/ably/ably-java/issues/598 - public void test_restore_non_nullary_event() { + @Test + public void restore_non_nullary_event() { TestActivation activation = new TestActivation(); assertInstanceOf(NotActivated.class, activation.machine.current); @@ -1407,9 +1475,13 @@ public Void apply(TestActivation.Options options) throws AblyException { } }); - // Since the event doesn't have a nullary constructor, it should be dropped. assertInstanceOf(NotActivated.class, activation.machine.current); - assertSize(0, activation.machine.pendingEvents); + // Since the event doesn't have a nullary constructor, it should be dropped. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + assertEquals(0, activation.machine.pendingEvents.stream().filter(e -> e instanceof SyncRegistrationFailed).count()); + } else { + assertEquals(0, StreamSupport.stream(activation.machine.pendingEvents).filter(e -> e instanceof SyncRegistrationFailed).count()); + } } // This is all copied and pasted from ParameterizedTest, since I can't inherit from it. @@ -1418,10 +1490,12 @@ public Void apply(TestActivation.Options options) throws AblyException { protected static Setup.TestVars testVars; + @BeforeClass public static void setUpBeforeClass() throws Exception { testVars = Setup.getTestVars(); } + @AfterClass public static void tearDownAfterClass() throws Exception { Setup.clearTestVars(); } @@ -1448,7 +1522,7 @@ private class TestActivationContext extends ActivationContext { this.onGetRegistrationToken = new Helpers.AblyFunction, Void>() { @Override public Void apply(Callback callback) throws AblyException { - callback.onSuccess(ULID.random()); + callback.onSuccess(UUID.randomUUID().toString()); return null; } }; @@ -1519,6 +1593,10 @@ public synchronized boolean handleEvent(Event event) { return ok; } + public void resetState(){ + super.reset(); + } + @Override public boolean reset() { waiter = null; @@ -1655,7 +1733,7 @@ public void run() throws Exception { testActivation.httpTracker.unlockRequests(); } - assertInstanceOf(expectedEvent, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(expectedEvent, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull(handled.waitFor()); if (deregisterError == null) { @@ -1816,7 +1894,7 @@ public void run() throws Exception { testActivation.httpTracker.unlockRequests(); } - assertInstanceOf(expectedEvent, events.poll(10, TimeUnit.SECONDS)); + assertInstanceOf(expectedEvent, events.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull(handled.waitFor()); if (updateError != null) { diff --git a/android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java b/android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java index 9359f2feb..9c96459b8 100644 --- a/android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java +++ b/android/src/androidTest/java/io/ably/lib/test/android/AndroidSuite.java @@ -3,7 +3,6 @@ import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; -import org.junit.runners.JUnit4; import static org.junit.Assert.fail; import static org.junit.Assert.assertTrue; @@ -64,7 +63,7 @@ public void android_http_header_test() { Map headers = server.getHeaders(); assertNotNull("Verify ably server was reached", headers); - String header = headers.get(Defaults.ABLY_LIB_HEADER.toLowerCase()); + String header = headers.get(Defaults.ABLY_AGENT_HEADER.toLowerCase(Locale.ROOT)); assertTrue("Verify correct library header was passed to the server", header != null && header.startsWith("android")); } catch (AblyException e) { diff --git a/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java b/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java new file mode 100644 index 000000000..8eb5c6304 --- /dev/null +++ b/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java @@ -0,0 +1,58 @@ +package io.ably.lib.test.android; + +import io.ably.lib.push.ActivationStateMachine.CalledActivate; +import io.ably.lib.push.ActivationStateMachine.CalledDeactivate; +import io.ably.lib.push.ActivationStateMachine.Deregistered; +import io.ably.lib.push.ActivationStateMachine.Event; +import io.ably.lib.push.ActivationStateMachine.GotDeviceRegistration; +import io.ably.lib.push.ActivationStateMachine.GotPushDeviceDetails; +import io.ably.lib.push.ActivationStateMachine.RegistrationSynced; +import io.ably.lib.push.ActivationStateMachine.GettingDeviceRegistrationFailed; +import io.ably.lib.push.ActivationStateMachine.GettingPushDeviceDetailsFailed; +import io.ably.lib.push.ActivationStateMachine.SyncRegistrationFailed; +import io.ably.lib.push.ActivationStateMachine.DeregistrationFailed; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class EventTest { + + @Test + public void events_subclasses_correctly_constructed_by_name() throws ClassNotFoundException, InstantiationException { + + CalledActivate calledActivateEvent = new CalledActivate(); + Event calledActivateReconstructed = Event.constructEventByName(calledActivateEvent.getPersistedName()); + assertEquals(calledActivateEvent.getClass(), calledActivateReconstructed.getClass()); + + CalledDeactivate calledDeactivateEvent = new CalledDeactivate(); + Event calledDeactivateReconstructed = Event.constructEventByName(calledDeactivateEvent.getPersistedName()); + assertEquals(calledDeactivateEvent.getClass(), calledDeactivateReconstructed.getClass()); + + GotPushDeviceDetails gotPushDeviceDetailsEvent = new GotPushDeviceDetails(); + Event gotPushDeviceDetailsReconstructed = Event.constructEventByName(gotPushDeviceDetailsEvent.getPersistedName()); + assertEquals(gotPushDeviceDetailsEvent.getClass(), gotPushDeviceDetailsReconstructed.getClass()); + + RegistrationSynced registrationSyncedEvent = new RegistrationSynced(); + Event registrationSyncedReconstructed = Event.constructEventByName(registrationSyncedEvent.getPersistedName()); + assertEquals(registrationSyncedEvent.getClass(), registrationSyncedReconstructed.getClass()); + + Deregistered DeregisteredEvent = new Deregistered(); + Event DeregisteredReconstructed = Event.constructEventByName(DeregisteredEvent.getPersistedName()); + assertEquals(DeregisteredEvent.getClass(), DeregisteredReconstructed.getClass()); + } + + @Test + public void events_with_constructor_parameter_do_not_have_persisted_name() { + assertNull(new GotDeviceRegistration(null, null).getPersistedName()); + assertNull(new GettingDeviceRegistrationFailed(null).getPersistedName()); + assertNull(new GettingPushDeviceDetailsFailed(null).getPersistedName()); + assertNull(new SyncRegistrationFailed(null).getPersistedName()); + assertNull(new DeregistrationFailed(null).getPersistedName()); + } + + @Test + public void unknown_events_cannot_be_constructed_by_name() { + assertNull(Event.constructEventByName("notDefinedName")); + } +} diff --git a/android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java b/android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java new file mode 100644 index 000000000..527e3da2f --- /dev/null +++ b/android/src/androidTest/java/io/ably/lib/types/RegistrationTokenTypeTest.java @@ -0,0 +1,59 @@ +package io.ably.lib.types; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class RegistrationTokenTypeTest { + + @Test + public void fromNameParseCorrectly() { + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("FCM")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("fCM")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("fcM")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("fcm")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("FcM")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("Fcm")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("FCm")); + assertEquals(RegistrationToken.Type.FCM, RegistrationToken.Type.fromName("fCm")); + + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("GCM")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("gCM")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("gcM")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("gcm")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("GcM")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("Gcm")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("GCm")); + assertEquals(RegistrationToken.Type.GCM, RegistrationToken.Type.fromName("gCm")); + } + + @Test + public void fromNameParseFailure() { + assertNull(RegistrationToken.Type.fromName("FCM ")); + assertNull(RegistrationToken.Type.fromName(" FCM ")); + assertNull(RegistrationToken.Type.fromName("\tFCM ")); + assertNull(RegistrationToken.Type.fromName("FĆM")); + assertNull(RegistrationToken.Type.fromName("FĆM\t")); + assertNull(RegistrationToken.Type.fromName("FCM\\")); + assertNull(RegistrationToken.Type.fromName("FĆM\'")); + assertNull(RegistrationToken.Type.fromName("FĆM\"")); + assertNull(RegistrationToken.Type.fromName("\nFCM")); + assertNull(RegistrationToken.Type.fromName("GÇM")); + assertNull(RegistrationToken.Type.fromName("\\GCM")); + assertNull(RegistrationToken.Type.fromName("\'GCM")); + assertNull(RegistrationToken.Type.fromName(" GCM")); + assertNull(RegistrationToken.Type.fromName("\"GCM")); + assertNull(RegistrationToken.Type.fromName("GÇM\r")); + assertNull(RegistrationToken.Type.fromName("GÇM\f")); + assertNull(RegistrationToken.Type.fromName("GÇM\n")); + assertNull(RegistrationToken.Type.fromName(null)); + } + + @Test + public void toNameProducesNameCorrectly() { + assertEquals("fcm", new RegistrationToken(RegistrationToken.Type.FCM, "token").type.toName()); + assertEquals("gcm", new RegistrationToken(RegistrationToken.Type.GCM, "token").type.toName()); + } + +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c904374e2..6fc6facb7 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,7 +1,4 @@ - - + - diff --git a/android/src/main/java/io/ably/lib/platform/Platform.java b/android/src/main/java/io/ably/lib/platform/Platform.java index 8f3446999..1fe58f122 100644 --- a/android/src/main/java/io/ably/lib/platform/Platform.java +++ b/android/src/main/java/io/ably/lib/platform/Platform.java @@ -1,19 +1,12 @@ package io.ably.lib.platform; import android.content.Context; -import io.ably.lib.push.ActivationContext; -import io.ably.lib.push.ActivationStateMachine; -import io.ably.lib.push.Push; -import io.ably.lib.rest.AblyBase; -import io.ably.lib.push.LocalDevice; import io.ably.lib.transport.NetworkConnectivity; import io.ably.lib.transport.NetworkConnectivity.DelegatedNetworkConnectivity; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; import io.ably.lib.util.Log; -import java.util.WeakHashMap; - public class Platform { public static final String name = "android"; @@ -48,7 +41,7 @@ public boolean hasApplicationContext() { /** * Get the NetworkConnectivity tracker instance for this context - * @return + * @return A {@link NetworkConnectivity} object */ public NetworkConnectivity getNetworkConnectivity() { return networkConnectivity; diff --git a/android/src/main/java/io/ably/lib/push/AblyFirebaseInstanceIdService.java b/android/src/main/java/io/ably/lib/push/AblyFirebaseInstanceIdService.java deleted file mode 100644 index 282afff2b..000000000 --- a/android/src/main/java/io/ably/lib/push/AblyFirebaseInstanceIdService.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.ably.lib.push; - -import android.content.Context; -import io.ably.lib.types.RegistrationToken; -import io.ably.lib.util.Log; - -public class AblyFirebaseInstanceIdService { - - /** - * Update Ably with the Registration Token - * @param context - * @param token - */ - public static void onNewRegistrationToken(Context context, String token) { - if(token != null && token.length() > 10) { - Log.i(TAG, "Firebase token registered: " + token.substring(0,10)); - } - ActivationContext.getActivationContext(context.getApplicationContext()).onNewRegistrationToken(RegistrationToken.Type.FCM, token); - } - - private static final String TAG = AblyFirebaseInstanceIdService.class.getName(); -} diff --git a/android/src/main/java/io/ably/lib/push/ActivationContext.java b/android/src/main/java/io/ably/lib/push/ActivationContext.java index c89e2f287..addb7d4eb 100644 --- a/android/src/main/java/io/ably/lib/push/ActivationContext.java +++ b/android/src/main/java/io/ably/lib/push/ActivationContext.java @@ -3,20 +3,20 @@ import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; -import com.google.android.gms.tasks.OnCompleteListener; -import com.google.android.gms.tasks.Task; -import com.google.firebase.FirebaseApp; -import com.google.firebase.iid.FirebaseInstanceId; -import com.google.firebase.iid.InstanceIdResult; + +import androidx.annotation.VisibleForTesting; +import com.google.firebase.messaging.FirebaseMessaging; + +import java.util.WeakHashMap; + import io.ably.lib.rest.AblyRest; import io.ably.lib.types.AblyException; import io.ably.lib.types.Callback; +import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.RegistrationToken; import io.ably.lib.util.Log; -import java.util.WeakHashMap; - public class ActivationContext { public ActivationContext(Context context) { this.context = context; @@ -31,7 +31,9 @@ Context getContext() { public synchronized LocalDevice getLocalDevice() { if(localDevice == null) { Log.v(TAG, "getLocalDevice(): creating new instance and returning that"); - localDevice = new LocalDevice(this); + Storage storage = ably != null ? ably.options.localStorage : null; + + localDevice = new LocalDevice(this, storage); } else { Log.v(TAG, "getLocalDevice(): returning existing instance"); } @@ -63,6 +65,8 @@ AblyRest getAbly() throws AblyException { Log.v(TAG, "getAbly(): returning existing Ably instance"); return ably; } else { + // In this case, we received a new FCM token while the app is offline, + // so we have to initialize the Ably client to send it to the server. Log.v(TAG, "getAbly(): creating new Ably instance"); } @@ -72,9 +76,21 @@ AblyRest getAbly() throws AblyException { throw AblyException.fromErrorInfo(new ErrorInfo("Unable to get Ably library instance; no device identity token", 40000, 400)); } Log.v(TAG, "getAbly(): returning Ably instance using deviceIdentityToken"); + // TODO: We need to persist Ably client options such as the environment with `deviceIdentityToken` and use these options during initialization. return (ably = new AblyRest(deviceIdentityToken)); } + /** + * @return AblyRest instance with device identity token auth. We use this instance to perform + * deregistration calls in push activation flow. + */ + AblyRest getDeviceIdentityTokenBasedAblyClient(String deviceIdentityToken) throws AblyException { + ClientOptions clientOptions = ably.options.copy(); + clientOptions.clearAuthOptions(); + clientOptions.token = deviceIdentityToken; + return new AblyRest(clientOptions); + } + public boolean setClientId(String clientId, boolean propagateGotPushDeviceDetails) { Log.v(TAG, "setClientId(): clientId=" + clientId + ", propagateGotPushDeviceDetails=" + propagateGotPushDeviceDetails); boolean updated = !clientId.equals(this.clientId); @@ -113,6 +129,10 @@ public void onNewRegistrationToken(RegistrationToken.Type type, String token) { getActivationStateMachine().handleEvent(new ActivationStateMachine.GotPushDeviceDetails()); } + /** + * Should be used in tests only + */ + @VisibleForTesting public void reset() { Log.v(TAG, "reset()"); @@ -151,20 +171,15 @@ public static ActivationContext getActivationContext(Context applicationContext, protected void getRegistrationToken(final Callback callback) { Log.v(TAG, "getRegistrationToken(): callback=" + callback); - FirebaseInstanceId.getInstance().getInstanceId() - .addOnCompleteListener(new OnCompleteListener() { - @Override - public void onComplete(Task task) { - Log.v(TAG, "getRegistrationToken(): firebase called onComplete(): task=" + task); - if(task.isSuccessful()) { - /* Get new Instance ID token */ - String token = task.getResult().getToken(); - callback.onSuccess(token); - } else { - callback.onError(ErrorInfo.fromThrowable(task.getException())); - } - } - }); + FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> { + Log.v(TAG, "getRegistrationToken(): FirebaseMessaging#getToken() completed: task=" + task); + if(task.isSuccessful()) { + String registrationToken = task.getResult(); + callback.onSuccess(registrationToken); + } else { + callback.onError(ErrorInfo.fromThrowable(task.getException())); + } + }); } public static void setActivationContext(Context applicationContext, ActivationContext activationContext) { @@ -179,6 +194,6 @@ public static void setActivationContext(Context applicationContext, ActivationCo protected final SharedPreferences prefs; protected final Context context; - private static WeakHashMap activationContexts = new WeakHashMap(); + private static final WeakHashMap activationContexts = new WeakHashMap<>(); private static final String TAG = ActivationContext.class.getName(); } diff --git a/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java b/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java index d478b4549..afc07a1ea 100644 --- a/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java +++ b/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java @@ -5,81 +5,240 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; -import android.support.v4.content.LocalBroadcastManager; -import com.google.android.gms.tasks.OnCompleteListener; -import com.google.android.gms.tasks.Task; -import com.google.firebase.iid.InstanceIdResult; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.google.gson.JsonObject; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.util.ArrayDeque; - import com.google.gson.JsonPrimitive; -import io.ably.lib.http.*; +import io.ably.lib.http.Http; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpScheduler; +import io.ably.lib.http.HttpUtils; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.DeviceDetails; -import io.ably.lib.types.*; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Callback; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Param; +import io.ably.lib.types.RegistrationToken; import io.ably.lib.util.IntentUtils; import io.ably.lib.util.Log; +import io.ably.lib.util.ParamsUtils; import io.ably.lib.util.Serialisation; +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.Locale; + public class ActivationStateMachine { public static class CalledActivate extends ActivationStateMachine.Event { + public static final String NAME = "CalledActivate"; + public static ActivationStateMachine.CalledActivate useCustomRegistrar(boolean useCustomRegistrar, SharedPreferences prefs) { prefs.edit().putBoolean(ActivationStateMachine.PersistKeys.PUSH_CUSTOM_REGISTRAR, useCustomRegistrar).apply(); return new ActivationStateMachine.CalledActivate(); } + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } } public static class CalledDeactivate extends ActivationStateMachine.Event { + public static final String NAME = "CalledDeactivate"; + static ActivationStateMachine.CalledDeactivate useCustomRegistrar(boolean useCustomRegistrar, SharedPreferences prefs) { prefs.edit().putBoolean(ActivationStateMachine.PersistKeys.PUSH_CUSTOM_REGISTRAR, useCustomRegistrar).apply(); return new ActivationStateMachine.CalledDeactivate(); } + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } } - public static class GotPushDeviceDetails extends ActivationStateMachine.Event {} + public static class GotPushDeviceDetails extends ActivationStateMachine.Event { + public static final String NAME = "GotPushDeviceDetails"; + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + } public static class GotDeviceRegistration extends ActivationStateMachine.Event { + final String deviceId; final String deviceIdentityToken; - GotDeviceRegistration(String token) { this.deviceIdentityToken = token; } + + public GotDeviceRegistration(String deviceId, String token) { + this.deviceId = deviceId; + this.deviceIdentityToken = token; + } + + @Override + public String toString() { + return "GotDeviceRegistration{" + + "deviceIdentityToken='" + deviceIdentityToken + '\'' + + '}'; + } } public static class GettingDeviceRegistrationFailed extends ActivationStateMachine.ErrorEvent { - GettingDeviceRegistrationFailed(ErrorInfo reason) { super(reason); } + public GettingDeviceRegistrationFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "GettingDeviceRegistrationFailed: " + super.toString(); + } } public static class GettingPushDeviceDetailsFailed extends ActivationStateMachine.ErrorEvent { - GettingPushDeviceDetailsFailed(ErrorInfo reason) { super(reason); } + public GettingPushDeviceDetailsFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "GettingPushDeviceDetailsFailed: " + super.toString(); + } } - public static class RegistrationSynced extends ActivationStateMachine.Event {} + public static class RegistrationSynced extends ActivationStateMachine.Event { + public static final String NAME = "RegistrationSynced"; + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + } public static class SyncRegistrationFailed extends ActivationStateMachine.ErrorEvent { public SyncRegistrationFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "SyncRegistrationFailed: " + super.toString(); + } } - public static class Deregistered extends ActivationStateMachine.Event {} + public static class Deregistered extends ActivationStateMachine.Event { + public static final String NAME = "Deregistered"; + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + } public static class DeregistrationFailed extends ActivationStateMachine.ErrorEvent { public DeregistrationFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "DeregistrationFailed: " + super.toString(); + } } - public abstract static class Event {} + public abstract static class Event { + /** + * The name to be used when persisting this class, or null if this class should not be persisted. + */ + public String getPersistedName() { + return null; + } + + /** + * @param className The name of the class to rehydrate. + * @return A new Event instance, or null if className is not supported. + */ + public static Event constructEventByName(String className) { + switch (className) { + case CalledActivate.NAME: + return new CalledActivate(); + + case CalledDeactivate.NAME: + return new CalledDeactivate(); + + case GotPushDeviceDetails.NAME: + return new GotPushDeviceDetails(); + + case RegistrationSynced.NAME: + return new RegistrationSynced(); + + case Deregistered.NAME: + return new Deregistered(); + } + + // the class name provided was not recognised + return null; + } + } public abstract static class ErrorEvent extends ActivationStateMachine.Event { public final ErrorInfo reason; ErrorEvent(ErrorInfo reason) { this.reason = reason; } + + @Override + public String toString() { + return "ErrorEvent{" + + "reason=" + reason + + '}'; + } } public static class NotActivated extends ActivationStateMachine.PersistentState { public NotActivated(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "NotActivated"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledDeactivate) { - machine.callDeactivatedCallback(null); - return this; + LocalDevice device = machine.getDevice(); + + // RSH3a1c + if (device.isRegistered()) { + machine.deregister(); + return new ActivationStateMachine.WaitingForDeregistration(machine, this); + // RSH3a1d + } else { + device.reset(); + machine.callDeactivatedCallback(null); + return this; + } } else if (event instanceof ActivationStateMachine.CalledActivate) { LocalDevice device = machine.getDevice(); @@ -108,6 +267,19 @@ public ActivationStateMachine.State transition(ActivationStateMachine.Event even public static class WaitingForPushDeviceDetails extends ActivationStateMachine.PersistentState { public WaitingForPushDeviceDetails(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "WaitingForPushDeviceDetails"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + public ActivationStateMachine.State transition(final ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { return this; @@ -138,10 +310,7 @@ public ActivationStateMachine.State transition(final ActivationStateMachine.Even ably.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if(ably.options.pushFullWait) { - params = Param.push(null, "fullWait", "true"); - } + Param[] params = ParamsUtils.enrichParams(null, ably.options); /* this is authenticated using the Ably library credentials, plus the deviceSecret in the request body */ http.post("/push/deviceRegistrations", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, body, new Serialisation.HttpResponseHandler(), true, callback); } @@ -163,7 +332,7 @@ public void onSuccess(JsonObject response) { activationContext.setClientId(responseClientId, false); } } - machine.handleEvent(new ActivationStateMachine.GotDeviceRegistration(deviceIdentityTokenJson.getAsJsonPrimitive("token").getAsString())); + machine.handleEvent(new ActivationStateMachine.GotDeviceRegistration(device.id, deviceIdentityTokenJson.getAsJsonPrimitive("token").getAsString())); } @Override public void onError(ErrorInfo reason) { @@ -181,12 +350,24 @@ public void onError(ErrorInfo reason) { public static class WaitingForDeviceRegistration extends ActivationStateMachine.State { public WaitingForDeviceRegistration(ActivationStateMachine machine) { super(machine); } + + @Override + public String toString() { + return "WaitingForDeviceRegistration"; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { return this; } else if (event instanceof ActivationStateMachine.GotDeviceRegistration) { LocalDevice device = machine.getDevice(); - device.setDeviceIdentityToken(((ActivationStateMachine.GotDeviceRegistration) event).deviceIdentityToken); + ActivationStateMachine.GotDeviceRegistration gotDeviceRegistration = (ActivationStateMachine.GotDeviceRegistration) event; + if (device.id.equals(gotDeviceRegistration.deviceId)) { + device.setDeviceIdentityToken(gotDeviceRegistration.deviceIdentityToken); + } else { + Log.e(TAG, "error registering " + device.id + ": " + "deviceId has been changed during registration, it was " + gotDeviceRegistration.deviceId); + throw new IllegalStateException("DeviceId has been changed during registration"); + } machine.callActivatedCallback(null); return new ActivationStateMachine.WaitingForNewPushDeviceDetails(machine); } else if (event instanceof ActivationStateMachine.GettingDeviceRegistrationFailed) { @@ -199,12 +380,24 @@ public ActivationStateMachine.State transition(ActivationStateMachine.Event even public static class WaitingForNewPushDeviceDetails extends ActivationStateMachine.PersistentState { public WaitingForNewPushDeviceDetails(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "WaitingForNewPushDeviceDetails"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return "WaitingForNewPushDeviceDetails"; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { machine.callActivatedCallback(null); return this; } else if (event instanceof ActivationStateMachine.CalledDeactivate) { - LocalDevice device = machine.getDevice(); machine.deregister(); return new ActivationStateMachine.WaitingForDeregistration(machine, this); } else if (event instanceof ActivationStateMachine.GotPushDeviceDetails) { @@ -225,6 +418,13 @@ public WaitingForRegistrationSync(ActivationStateMachine machine, Event fromEven this.fromEvent = fromEvent; } + @Override + public String toString() { + return "WaitingForRegistrationSync{" + + "fromEvent=" + fromEvent + + '}'; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { if (fromEvent instanceof CalledActivate) { @@ -256,6 +456,19 @@ public ActivationStateMachine.State transition(ActivationStateMachine.Event even public static class AfterRegistrationSyncFailed extends ActivationStateMachine.PersistentState { public AfterRegistrationSyncFailed(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "AfterRegistrationSyncFailed"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate || event instanceof ActivationStateMachine.GotPushDeviceDetails) { machine.validateRegistration(); @@ -276,6 +489,13 @@ public WaitingForDeregistration(ActivationStateMachine machine, ActivationStateM this.previousState = previousState; } + @Override + public String toString() { + return "WaitingForDeregistration{" + + "previousState=" + previousState + + '}'; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledDeactivate) { return this; @@ -308,6 +528,30 @@ public State(ActivationStateMachine machine) { private static abstract class PersistentState extends ActivationStateMachine.State { PersistentState(ActivationStateMachine machine) { super(machine); } + + /** + * @param className The name of the class to rehydrate. + * @return A new Event instance, or null if className is not supported. + */ + public static State constructStateByName(final String className, final ActivationStateMachine machine) { + switch (className) { + case NotActivated.NAME: + return new NotActivated(machine); + + case WaitingForPushDeviceDetails.NAME: + return new WaitingForPushDeviceDetails(machine); + + case WaitingForNewPushDeviceDetails.NAME: + return new WaitingForNewPushDeviceDetails(machine); + + case AfterRegistrationSyncFailed.NAME: + return new AfterRegistrationSyncFailed(machine); + } + + return null; + } + + abstract String getPersistedName(); } private void callActivatedCallback(ErrorInfo reason) { @@ -329,19 +573,20 @@ private void sendErrorIntent(String name, ErrorInfo error) { } private void invokeCustomRegistration(final DeviceDetails device, final boolean isNew) { + final String deviceId = device.id; registerOnceReceiver("PUSH_DEVICE_REGISTERED", new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { ErrorInfo error = IntentUtils.getErrorInfo(intent); if (error == null) { - Log.i(TAG, "custom registration for " + device.id); + Log.i(TAG, "custom registration for " + deviceId); if (isNew) { - handleEvent(new ActivationStateMachine.GotDeviceRegistration(intent.getStringExtra("deviceIdentityToken"))); + handleEvent(new ActivationStateMachine.GotDeviceRegistration(deviceId, intent.getStringExtra("deviceIdentityToken"))); } else { handleEvent(new RegistrationSynced()); } } else { - Log.e(TAG, "error from custom registration for " + device.id + ": " + error.toString()); + Log.e(TAG, "error from custom registration for " + deviceId + ": " + error.toString()); if (isNew) { handleEvent(new ActivationStateMachine.GettingDeviceRegistrationFailed(error)); } else { @@ -428,11 +673,7 @@ private void updateRegistration() { ably.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (ably.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } - + Param[] params = ParamsUtils.enrichParams(null, ably.options); http.patch("/push/deviceRegistrations/" + device.id, ably.push.pushRequestHeaders(true), params, body, null, false, callback); } }).async(new Callback() { @@ -476,11 +717,7 @@ private void validateRegistration() { ably.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (ably.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } - + Param[] params = ParamsUtils.enrichParams(null, ably.options); final HttpCore.RequestBody body = HttpUtils.requestBodyFromGson(device.toJsonObject(), ably.options.useBinaryProtocol); http.put("/push/deviceRegistrations/" + device.id, ably.push.pushRequestHeaders(true), params, body, new Serialisation.HttpResponseHandler(), true, callback); } @@ -514,7 +751,8 @@ private void deregister() { } else { final AblyRest ably; try { - ably = activationContext.getAbly(); + // RSH3d2b: use `deviceIdentityToken` to perform request + ably = activationContext.getDeviceIdentityTokenBasedAblyClient(device.deviceIdentityToken); } catch(AblyException ae) { ErrorInfo reason = ae.errorInfo; Log.e(TAG, "exception registering " + device.id + ": " + reason.toString()); @@ -523,12 +761,11 @@ private void deregister() { } ably.http.request(new Http.Execute() { @Override - public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = new Param[0]; - if (ably.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } - http.del("/push/deviceRegistrations/" + device.id, ably.push.pushRequestHeaders(true), params, null, true, callback); + public void execute(HttpScheduler http, Callback callback) { + Param[] params = ParamsUtils.enrichParams(new Param[0], ably.options); + Param[] headers = HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol); + final Param[] deviceIdentityHeaders = device.deviceIdentityHeaders(); + http.del("/push/deviceRegistrations/" + device.id, HttpUtils.mergeHeaders(headers, deviceIdentityHeaders), params, null, true, callback); } }).async(new Callback() { @Override @@ -538,8 +775,14 @@ public void onSuccess(Void response) { } @Override public void onError(ErrorInfo reason) { - Log.e(TAG, "error deregistering " + device.id + ": " + reason.toString()); - handleEvent(new ActivationStateMachine.DeregistrationFailed(reason)); + // RSH3d2c1: ignore unauthorized or invalid credentials errors + if (reason.statusCode == 401 || reason.code == 40005) { + Log.w(TAG, "unauthorized error during deregistration " + device.id + ": " + reason); + handleEvent(new ActivationStateMachine.Deregistered()); + } else { + Log.e(TAG, "error deregistering " + device.id + ": " + reason); + handleEvent(new ActivationStateMachine.DeregistrationFailed(reason)); + } } }); } @@ -564,7 +807,7 @@ private void loadPersisted() { } private void enqueueEvent(ActivationStateMachine.Event event) { - Log.d(TAG, "enqueuing event: " + event.getClass().getSimpleName()); + Log.d(TAG, "enqueuing event: " + event); pendingEvents.add(event); } @@ -582,7 +825,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { handlingEvent = true; try { - Log.d(TAG, String.format("handling event %s from %s", event.getClass().getSimpleName(), current.getClass().getSimpleName())); + Log.d(TAG, "handling event " + event + " from state " + current); ActivationStateMachine.State maybeNext = current.transition(event); if (maybeNext == null) { @@ -590,7 +833,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { return persist(); } - Log.d(TAG, String.format("transition: %s -(%s)-> %s", current.getClass().getSimpleName(), event.getClass().getSimpleName(), maybeNext.getClass().getSimpleName())); + Log.d(TAG, "transition: " + current + " -(" + event + ")-> " + maybeNext + "."); current = maybeNext; while (true) { @@ -599,7 +842,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { break; } - Log.d(TAG, "attempting to consume pending event: " + pending.getClass().getSimpleName()); + Log.d(TAG, "attempting to consume pending event: " + pending); maybeNext = current.transition(pending); if (maybeNext == null) { @@ -607,7 +850,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { } pendingEvents.poll(); - Log.d(TAG, String.format("transition: %s -(%s)-> %s", current.getClass().getSimpleName(), pending.getClass().getSimpleName(), maybeNext.getClass().getSimpleName())); + Log.d(TAG, "transition: " + current + " -(" + pending + ")-> " + maybeNext + "."); current = maybeNext; } @@ -637,62 +880,49 @@ private boolean persist() { SharedPreferences.Editor editor = activationContext.getPreferences().edit(); if (current instanceof ActivationStateMachine.PersistentState) { - editor.putString(ActivationStateMachine.PersistKeys.CURRENT_STATE, current.getClass().getName()); + final PersistentState persistableState = (PersistentState)current; + editor.putString(ActivationStateMachine.PersistKeys.CURRENT_STATE, persistableState.getPersistedName()); } editor.putInt(ActivationStateMachine.PersistKeys.PENDING_EVENTS_LENGTH, pendingEvents.size()); int i = 0; for (ActivationStateMachine.Event e : pendingEvents) { - editor.putString( - String.format("%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), - e.getClass().getName() - ); - + final String name = e.getPersistedName(); + if (name != null) { + editor.putString( + String.format(Locale.ROOT, "%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), + name + ); + } i++; } return editor.commit(); } + /** + * Returns persisted state or `NotActivated` if there is no persisted state or the name of the currently persisted + * state is not recognised. + */ private ActivationStateMachine.State getPersistedState() { - try { - Class stateClass; - - String className = activationContext.getPreferences().getString(ActivationStateMachine.PersistKeys.CURRENT_STATE, ""); - if (className.endsWith("$AfterRegistrationUpdateFailed")) { - stateClass = AfterRegistrationSyncFailed.class; - } else { - stateClass = (Class) Class.forName(className); - } - - Constructor constructor = stateClass.getConstructor(ActivationStateMachine.class); - return constructor.newInstance(this); - } catch (Exception e) { - return new ActivationStateMachine.NotActivated(this); - } + final String className = activationContext.getPreferences().getString(ActivationStateMachine.PersistKeys.CURRENT_STATE, ""); + final State instance = PersistentState.constructStateByName(className, this); + return instance == null ? new ActivationStateMachine.NotActivated(this) : instance; } private ArrayDeque getPersistedPendingEvents() { int length = activationContext.getPreferences().getInt(ActivationStateMachine.PersistKeys.PENDING_EVENTS_LENGTH, 0); ArrayDeque deque = new ArrayDeque<>(length); for (int i = 0; i < length; i++) { - try { - String className = activationContext.getPreferences().getString(String.format("%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), ""); - Class eventClass = (Class) Class.forName(className); - ActivationStateMachine.Event event; - try { - event = eventClass.newInstance(); - } catch(InstantiationException e) { - // We aren't properly persisting events with a non-nullary constructor. Those events - // are supposed to be handled by states that aren't persisted (until - // https://github.com/ably/ably-java/issues/546 is fixed), so it should be safe to - // just drop them. - Log.d(TAG, String.format("discarding improperly persisted event: %s", className)); - continue; - } + String className = activationContext.getPreferences().getString(String.format(Locale.ROOT, "%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), ""); + ActivationStateMachine.Event event = Event.constructEventByName(className); + if (event != null) { deque.add(event); - } catch(Exception e) { - throw new RuntimeException(e); + } else { + // This is likely to be a difference between builds of the SDK. Perhaps related to obfuscated event + // names having been previously persisted on this device. See: + // https://github.com/ably/ably-java/issues/686 + Log.w(TAG, "Failed to construct push activation state machine event from persisted class name '" + className + "'."); } } return deque; diff --git a/android/src/main/java/io/ably/lib/push/LocalDevice.java b/android/src/main/java/io/ably/lib/push/LocalDevice.java index 8f82fa334..bed9d4554 100644 --- a/android/src/main/java/io/ably/lib/push/LocalDevice.java +++ b/android/src/main/java/io/ably/lib/push/LocalDevice.java @@ -1,38 +1,44 @@ package io.ably.lib.push; import android.content.Context; -import android.content.SharedPreferences; import android.content.res.Configuration; -import android.preference.PreferenceManager; - import com.google.gson.JsonObject; - -import java.lang.reflect.Field; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; - import io.ably.lib.rest.DeviceDetails; -import io.ably.lib.types.AblyException; +import io.ably.lib.types.Param; import io.ably.lib.types.RegistrationToken; import io.ably.lib.util.Base64Coder; import io.ably.lib.util.Log; -import io.ably.lib.types.Param; -import io.azam.ulidj.ULID; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.UUID; + +/** + * Contains the device identity token and secret of a device. LocalDevice extends {@link DeviceDetails}. + */ public class LocalDevice extends DeviceDetails { + /** + * A unique device secret generated by the Ably SDK. + */ public String deviceSecret; + /** + * A unique device identity token used to communicate with APNS or FCM. + */ public String deviceIdentityToken; + private final Storage storage; + private final ActivationContext activationContext; - public LocalDevice(ActivationContext activationContext) { + public LocalDevice(ActivationContext activationContext, Storage storage) { super(); Log.v(TAG, "LocalDevice(): initialising"); this.platform = "android"; this.formFactor = isTablet(activationContext.getContext()) ? "tablet" : "phone"; this.activationContext = activationContext; this.push = new DeviceDetails.Push(); + this.storage = storage != null ? storage : new SharedPreferenceStorage(activationContext); loadPersisted(); } @@ -47,26 +53,24 @@ public JsonObject toJsonObject() { private void loadPersisted() { /* Spec: RSH8a */ - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activationContext.getContext()); - - String id = prefs.getString(SharedPrefKeys.DEVICE_ID, null); + String id = storage.get(SharedPrefKeys.DEVICE_ID, null); this.id = id; if(id != null) { Log.v(TAG, "loadPersisted(): existing deviceId found; id: " + id); - deviceSecret = prefs.getString(SharedPrefKeys.DEVICE_SECRET, null); + deviceSecret = storage.get(SharedPrefKeys.DEVICE_SECRET, null); } else { Log.v(TAG, "loadPersisted(): existing deviceId not found."); } - this.clientId = prefs.getString(SharedPrefKeys.CLIENT_ID, null); - this.deviceIdentityToken = prefs.getString(SharedPrefKeys.DEVICE_TOKEN, null); + this.clientId = storage.get(SharedPrefKeys.CLIENT_ID, null); + this.deviceIdentityToken = storage.get(SharedPrefKeys.DEVICE_TOKEN, null); RegistrationToken.Type type = RegistrationToken.Type.fromOrdinal( - prefs.getInt(SharedPrefKeys.TOKEN_TYPE, -1)); + storage.get(SharedPrefKeys.TOKEN_TYPE, -1)); Log.d(TAG, "loadPersisted(): token type = " + type); if(type != null) { RegistrationToken token = null; - String tokenString = prefs.getString(SharedPrefKeys.TOKEN, null); + String tokenString = storage.get(SharedPrefKeys.TOKEN, null); Log.d(TAG, "loadPersisted(): token string = " + tokenString); if(tokenString != null) { token = new RegistrationToken(type, tokenString); @@ -103,42 +107,33 @@ private void clearRegistrationToken() { void setAndPersistRegistrationToken(RegistrationToken token) { Log.v(TAG, "setAndPersistRegistrationToken(): token=" + token); setRegistrationToken(token); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activationContext.getContext()); - prefs.edit() - .putInt(SharedPrefKeys.TOKEN_TYPE, token.type.ordinal()) - .putString(SharedPrefKeys.TOKEN, token.token) - .apply(); + storage.put(SharedPrefKeys.TOKEN_TYPE, token.type.ordinal()); + storage.put(SharedPrefKeys.TOKEN, token.token); } void setClientId(String clientId) { Log.v(TAG, "setClientId(): clientId=" + clientId); this.clientId = clientId; - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activationContext.getContext()); - prefs.edit().putString(SharedPrefKeys.CLIENT_ID, clientId).apply(); + storage.put(SharedPrefKeys.CLIENT_ID, clientId); } public void setDeviceIdentityToken(String token) { Log.v(TAG, "setDeviceIdentityToken(): token=" + token); this.deviceIdentityToken = token; - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activationContext.getContext()); - prefs.edit().putString(SharedPrefKeys.DEVICE_TOKEN, token).apply(); + storage.put(SharedPrefKeys.DEVICE_TOKEN, token); } boolean isCreated() { return id != null; } - boolean create() { + void create() { /* Spec: RSH8b */ Log.v(TAG, "create()"); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activationContext.getContext()); - SharedPreferences.Editor editor = prefs.edit(); - - editor.putString(SharedPrefKeys.DEVICE_ID, (id = ULID.random())); - editor.putString(SharedPrefKeys.CLIENT_ID, (clientId = activationContext.clientId)); - editor.putString(SharedPrefKeys.DEVICE_SECRET, (deviceSecret = generateSecret())); - - return editor.commit(); + storage.put(SharedPrefKeys.DEVICE_ID, (id = UUID.randomUUID().toString())); + storage.put(SharedPrefKeys.CLIENT_ID, (clientId = activationContext.clientId)); + storage.put(SharedPrefKeys.DEVICE_SECRET, (deviceSecret = generateSecret())); + storage.put(SharedPrefKeys.DEVICE_TOKEN, (deviceIdentityToken = null)); } public void reset() { @@ -149,15 +144,7 @@ public void reset() { this.clientId = null; this.clearRegistrationToken(); - SharedPreferences.Editor editor = activationContext.getPreferences().edit(); - for (Field f : SharedPrefKeys.class.getDeclaredFields()) { - try { - editor.remove((String) f.get(null)); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - } - editor.commit(); + storage.clear(SharedPrefKeys.getAllKeys()); } boolean isRegistered() { @@ -183,6 +170,12 @@ private static class SharedPrefKeys { static final String DEVICE_TOKEN = "ABLY_DEVICE_IDENTITY_TOKEN"; static final String TOKEN_TYPE = "ABLY_REGISTRATION_TOKEN_TYPE"; static final String TOKEN = "ABLY_REGISTRATION_TOKEN"; + + static String[] getAllKeys() { + return new String[]{ + DEVICE_ID, CLIENT_ID, DEVICE_SECRET, DEVICE_TOKEN, TOKEN_TYPE, TOKEN + }; + } } private static String generateSecret() { diff --git a/android/src/main/java/io/ably/lib/push/Push.java b/android/src/main/java/io/ably/lib/push/Push.java index 6552a3696..bce5da72e 100644 --- a/android/src/main/java/io/ably/lib/push/Push.java +++ b/android/src/main/java/io/ably/lib/push/Push.java @@ -13,16 +13,34 @@ import java.util.Arrays; +/** + * Enables a device to be registered and deregistered from receiving push notifications. + */ public class Push extends PushBase { public Push(AblyBase rest) { super(rest); } + /** + * Activates the device for push notifications with FCM or APNS, obtaining a unique identifier from them. + * Subsequently registers the device with Ably and stores the deviceIdentityToken in local storage. + *

+ * Spec: RSH2a + * @throws AblyException + */ public void activate() throws AblyException { activate(false); } + /** + * Activates the device for push notifications with FCM or APNS, obtaining a unique identifier from them. + * Subsequently registers the device with Ably and stores the deviceIdentityToken in local storage. + *

+ * Spec: RSH2a + * @param useCustomRegistrar + * @throws AblyException + */ public void activate(boolean useCustomRegistrar) throws AblyException { Log.v(TAG, "activate(): useCustomRegistrar=" + useCustomRegistrar); Context context = getApplicationContext(); @@ -30,10 +48,23 @@ public void activate(boolean useCustomRegistrar) throws AblyException { getStateMachine().handleEvent(ActivationStateMachine.CalledActivate.useCustomRegistrar(useCustomRegistrar, prefs)); } + /** + * Deactivates the device from receiving push notifications with Ably and FCM or APNS. + *

+ * Spec: RSH2b + * @throws AblyException + */ public void deactivate() throws AblyException { deactivate(false); } + /** + * Deactivates the device from receiving push notifications with Ably and FCM or APNS. + *

+ * Spec: RSH2b + * @param useCustomRegistrar + * @throws AblyException + */ public void deactivate(boolean useCustomRegistrar) throws AblyException { Log.v(TAG, "deactivate(): useCustomRegistrar=" + useCustomRegistrar); Context context = getApplicationContext(); @@ -80,6 +111,13 @@ public ActivationContext getActivationContext() throws AblyException { return activationContext; } + /** + * Retrieves a {@link LocalDevice} object that represents the current state of the device as a target for push notifications. + *

+ * Spec: RSH8 + * @return A {@link LocalDevice} object. + * @throws AblyException + */ public LocalDevice getLocalDevice() throws AblyException { return getActivationContext().getLocalDevice(); } diff --git a/android/src/main/java/io/ably/lib/push/PushChannel.java b/android/src/main/java/io/ably/lib/push/PushChannel.java index 5765a7a83..8ac796368 100644 --- a/android/src/main/java/io/ably/lib/push/PushChannel.java +++ b/android/src/main/java/io/ably/lib/push/PushChannel.java @@ -1,14 +1,25 @@ package io.ably.lib.push; -import android.content.Context; import com.google.gson.JsonObject; -import io.ably.lib.http.*; +import io.ably.lib.http.BasePaginatedQuery; +import io.ably.lib.http.Http; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpScheduler; +import io.ably.lib.http.HttpUtils; import io.ably.lib.realtime.CompletionListener; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Channel; import io.ably.lib.rest.DeviceDetails; -import io.ably.lib.types.*; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.AsyncPaginatedResult; +import io.ably.lib.types.Callback; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import io.ably.lib.util.ParamsUtils; +/** + * Enables devices to subscribe to push notifications for a channel. + */ public class PushChannel { protected final Channel channel; protected final AblyRest rest; @@ -18,10 +29,25 @@ public PushChannel(Channel channel, AblyRest rest) { this.rest = rest; } + /** + * Subscribes all devices associated with the current device's clientId to push notifications for the channel. + *

+ * Spec: RSH7b + * @throws AblyException + */ public void subscribeClient() throws AblyException { subscribeClientImpl().sync(); } + /** + * Asynchronously subscribes all devices associated with the current device's clientId to push notifications for the channel. + *

+ * Spec: RSH7b + * @param listener A listener may optionally be passed in to this call to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ public void subscribeClientAsync(CompletionListener listener) { subscribeClientImpl().async(new CompletionListener.ToCallback(listener)); } @@ -37,10 +63,25 @@ protected Http.Request subscribeClientImpl() { return postSubscription(bodyJson); } + /** + * Subscribes the device to push notifications for the channel. + *

+ * Spec: RSH7a + * @throws AblyException + */ public void subscribeDevice() throws AblyException { subscribeDeviceImpl().sync(); } + /** + * Asynchronously subscribes the device to push notifications for the channel. + *

+ * Spec: RSH7a + * @param listener A listener may optionally be passed in to this call to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ public void subscribeDeviceAsync(CompletionListener listener) { subscribeDeviceImpl().async(new CompletionListener.ToCallback(listener)); } @@ -64,19 +105,31 @@ protected Http.Request postSubscription(JsonObject bodyJson) { return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + Param[] params = ParamsUtils.enrichParams(null, rest.options); http.post("/push/channelSubscriptions", rest.push.pushRequestHeaders(true), params, body, null, true, callback); } }); } + /** + * Unsubscribes all devices associated with the current device's clientId from receiving push notifications for the channel. + *

+ * Spec: RSH7d + * @throws AblyException + */ public void unsubscribeClient() throws AblyException { unsubscribeClientImpl().sync(); } + /** + * Asynchronously unsubscribes all devices associated with the current device's clientId from receiving push notifications for the channel. + *

+ * Spec: RSH7d + * @param listener A listener may optionally be passed in to this call to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ public void unsubscribeClientAsync(CompletionListener listener) { unsubscribeClientImpl().async(new CompletionListener.ToCallback(listener)); } @@ -90,10 +143,25 @@ protected Http.Request unsubscribeClientImpl() { } } + /** + * Unsubscribes the device from receiving push notifications for the channel. + *

+ * Spec: RSH7c + * @throws AblyException + */ public void unsubscribeDevice() throws AblyException { unsubscribeDeviceImpl().sync(); } + /** + * Unsubscribes the device from receiving push notifications for the channel. + *

+ * Spec: RSH7c + * @param listener A listener may optionally be passed in to this call to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ public void unsubscribeDeviceAsync(CompletionListener listener) { unsubscribeDeviceImpl().async(new CompletionListener.ToCallback(listener)); } @@ -109,10 +177,7 @@ protected Http.Request unsubscribeDeviceImpl() { } protected Http.Request delSubscription(Param[] params) { - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } - final Param[] finalParams = params; + final Param[] finalParams = ParamsUtils.enrichParams(params, rest.options); return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { @@ -121,33 +186,55 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExce }); } + /** + * Retrieves all push subscriptions for the channel. + *

+ * Spec: RSH7e + * @return A {@link PaginatedResult} object containing an array of {@link Push.ChannelSubscription} objects. + * @throws AblyException + */ public PaginatedResult listSubscriptions() throws AblyException { return listSubscriptions(new Param[] {}); } + /** + * Retrieves all push subscriptions for the channel. + * Subscriptions can be filtered using a params object. + *

+ * Spec: RSH7e + * @param params An array of {@link Param} objects. + * @return A {@link PaginatedResult} object containing an array of {@link Push.ChannelSubscription} objects. + * @throws AblyException + */ public PaginatedResult listSubscriptions(Param[] params) throws AblyException { return listSubscriptionsImpl(params).sync(); } + /** + * Asynchronously retrieves all push subscriptions for the channel. + *

+ * Spec: RSH7e + * @param callback A Callback returning {@link AsyncPaginatedResult} object containing an array of {@link Push.ChannelSubscription} objects. + * @throws AblyException + */ public void listSubscriptionsAsync(Callback> callback) { listSubscriptionsAsync(new Param[] {}, callback); } + /** + * Asynchronously retrieves all push subscriptions for the channel. + * Subscriptions can be filtered using a params object. + *

+ * Spec: RSH7e + * @param params An array of {@link Param} objects. + * @param callback A Callback returning {@link AsyncPaginatedResult} object containing an array of {@link Push.ChannelSubscription} objects. + * @throws AblyException + */ public void listSubscriptionsAsync(Param[] params, Callback> callback) { listSubscriptionsImpl(params).async(callback); } protected BasePaginatedQuery.ResultRequest listSubscriptionsImpl(Param[] params) { - try { - params = Param.set(params, "deviceId", getDevice().id); - } catch(AblyException e) { - return new BasePaginatedQuery.ResultRequest.Failed(e); - } - params = Param.set(params, "channel", channel.name); - String clientId = rest.auth.clientId; - if (clientId != null) { - params = Param.set(params, "clientId", clientId); - } params = Param.set(params, "concatFilters", "true"); return new BasePaginatedQuery(rest.http, "/push/channelSubscriptions", rest.push.pushRequestHeaders(true), params, Push.ChannelSubscription.httpBodyHandler).get(); diff --git a/android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java b/android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java new file mode 100644 index 000000000..6f2315ed8 --- /dev/null +++ b/android/src/main/java/io/ably/lib/push/SharedPreferenceStorage.java @@ -0,0 +1,47 @@ +package io.ably.lib.push; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + + +public class SharedPreferenceStorage implements Storage{ + + private final ActivationContext activationContext; + + public SharedPreferenceStorage(ActivationContext activationContext) { + this.activationContext = activationContext; + } + + private SharedPreferences sharedPreferences() { + return PreferenceManager.getDefaultSharedPreferences(activationContext.getContext()); + } + + @Override + public void put(String key, String value) { + sharedPreferences().edit().putString(key, value).apply(); + } + + @Override + public void put(String key, int value) { + sharedPreferences().edit().putInt(key, value).apply(); + } + + @Override + public String get(String key, String defaultValue) { + return sharedPreferences().getString(key, defaultValue); + } + + @Override + public int get(String key, int defaultValue) { + return sharedPreferences().getInt(key, defaultValue); + } + + @Override + public void clear(String[] keys) { + SharedPreferences.Editor editor = activationContext.getPreferences().edit(); + for (String key : keys) { + editor.remove(key); + } + editor.commit(); + } +} diff --git a/android/src/main/java/io/ably/lib/realtime/Channel.java b/android/src/main/java/io/ably/lib/realtime/Channel.java index 3348b2719..9d5ee6ab0 100644 --- a/android/src/main/java/io/ably/lib/realtime/Channel.java +++ b/android/src/main/java/io/ably/lib/realtime/Channel.java @@ -3,15 +3,19 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import io.ably.lib.push.PushChannel; +import io.ably.lib.objects.ObjectsPlugin; + public class Channel extends ChannelBase { /** - * The push instance for this channel. + * A {@link PushChannel} object. + *

+ * Spec: RSH4 */ public final PushChannel push; - Channel(AblyRealtime ably, String name, ChannelOptions options) throws AblyException { - super(ably, name, options); + Channel(AblyRealtime ably, String name, ChannelOptions options, ObjectsPlugin objectsPlugin) throws AblyException { + super(ably, name, options, objectsPlugin); this.push = ((io.ably.lib.rest.AblyRest) ably).channels.get(name, options).push; } diff --git a/android/src/main/java/io/ably/lib/rest/AblyRest.java b/android/src/main/java/io/ably/lib/rest/AblyRest.java index a53af4954..7f04feb2a 100644 --- a/android/src/main/java/io/ably/lib/rest/AblyRest.java +++ b/android/src/main/java/io/ably/lib/rest/AblyRest.java @@ -4,33 +4,43 @@ import io.ably.lib.push.LocalDevice; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; +import io.ably.lib.util.AndroidPlatformAgentProvider; import io.ably.lib.util.Log; +/** + * A client that offers a simple stateless API to interact directly with Ably's REST API. + * + * This class implements {@link AutoCloseable} so you can use it in + * try-with-resources constructs and have the JDK close it for you. + */ public class AblyRest extends AblyBase { /** - * Instance the Ably library using a key only. - * This is simply a convenience constructor for the - * simplest case of instancing the library with a key - * for basic authentication and no other options. - * @param key String key (obtained from application dashboard) + * Constructs a client object using an Ably API key or token string. + *

+ * Spec: RSC1 + * @param key The Ably API key or token string used to validate the client. * @throws AblyException */ public AblyRest(String key) throws AblyException { - super(key); + super(key, new AndroidPlatformAgentProvider()); } /** - * Instance the Ably library with the given options. - * @param options see {@link io.ably.lib.types.ClientOptions} for options + * Construct a client object using an Ably {@link ClientOptions} object. + *

+ * Spec: RSC1 + * @param options A {@link ClientOptions} object to configure the client connection to Ably. * @throws AblyException */ public AblyRest(ClientOptions options) throws AblyException { - super(options); + super(options, new AndroidPlatformAgentProvider()); } /** - * Get the local device, if any - * @return an instance of LocalDevice, or null if this device is not capable of activation as a push target + * Retrieves a {@link LocalDevice} object that represents the current state of the device as a target for push notifications. + *

+ * Spec: RSH8 + * @return A {@link LocalDevice} object. * @throws AblyException */ public LocalDevice device() throws AblyException { diff --git a/android/src/main/java/io/ably/lib/rest/Channel.java b/android/src/main/java/io/ably/lib/rest/Channel.java index f5dade377..9a56b8f2c 100644 --- a/android/src/main/java/io/ably/lib/rest/Channel.java +++ b/android/src/main/java/io/ably/lib/rest/Channel.java @@ -6,7 +6,9 @@ public class Channel extends ChannelBase { /** - * The push instance for this channel. + * A {@link PushChannel} object. + *

+ * Spec: RSH4 */ public final PushChannel push; @@ -14,4 +16,4 @@ public class Channel extends ChannelBase { super(ably, name, options); this.push = new PushChannel(this, (AblyRest)ably); } -} \ No newline at end of file +} diff --git a/android/src/main/java/io/ably/lib/types/RegistrationToken.java b/android/src/main/java/io/ably/lib/types/RegistrationToken.java index 8b684e67f..8b6981950 100644 --- a/android/src/main/java/io/ably/lib/types/RegistrationToken.java +++ b/android/src/main/java/io/ably/lib/types/RegistrationToken.java @@ -1,5 +1,7 @@ package io.ably.lib.types; +import java.util.Locale; + public class RegistrationToken { public Type type; public String token; @@ -23,14 +25,14 @@ public static Type fromOrdinal(int i) { public static Type fromName(String name) { try { - return Type.valueOf(name.toUpperCase()); + return Type.valueOf(name.toUpperCase(Locale.ROOT)); } catch(Throwable t) { return null; } } public String toName() { - return name().toLowerCase(); + return name().toLowerCase(Locale.ROOT); } } diff --git a/android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java b/android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java new file mode 100644 index 000000000..000d858da --- /dev/null +++ b/android/src/main/java/io/ably/lib/util/AndroidPlatformAgentProvider.java @@ -0,0 +1,10 @@ +package io.ably.lib.util; + +import android.os.Build; + +public class AndroidPlatformAgentProvider implements PlatformAgentProvider { + @Override + public String createPlatformAgent() { + return "android" + AgentHeaderCreator.AGENT_DIVIDER + Build.VERSION.SDK_INT; + } +} diff --git a/android/src/main/resources/META-INF/io/ably/ably-android/verification.properties b/android/src/main/resources/META-INF/io/ably/ably-android/verification.properties new file mode 100644 index 000000000..e0ffd3cf8 --- /dev/null +++ b/android/src/main/resources/META-INF/io/ably/ably-android/verification.properties @@ -0,0 +1,3 @@ +#This is the verification token for the io.ably:ably-android SDK. +#Thu Jul 18 04:04:19 PDT 2024 +token=LY4MEH7STVGANIZDZJWHTKZOUU diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 8cf9d7745..000000000 --- a/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - -plugins { - id 'io.codearte.nexus-staging' version '0.21.1' -} - -repositories { - google() - mavenCentral() -} - -nexusStaging { - packageGroup = 'io.ably' -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..c741178c7 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,43 @@ +import com.vanniktech.maven.publish.MavenPublishBaseExtension + +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.maven.publish) apply false + alias(libs.plugins.lombok) apply false + alias(libs.plugins.test.retry) apply false +} + +subprojects { + repositories { + google() + mavenCentral() + } + + tasks.withType { + // To prevent javadoc warnings with Java 8 + options { + this as StandardJavadocDocletOptions + addBooleanOption("Xdoclint:none", true) + addBooleanOption("quiet", true) + addStringOption("Xmaxwarns", "1") + } + } +} + +configure(subprojects) { + pluginManager.withPlugin("com.vanniktech.maven.publish") { + extensions.configure { + // Check if we're running a local publish task + val isLocalPublish = gradle.startParameter.taskNames.any { + it.contains("publishToMavenLocal") || it.contains("ToMavenLocal") + } + + if (!isLocalPublish) { + signAllPublications() + } + } + } +} diff --git a/ci/run-android-tests.sh b/ci/run-android-tests.sh deleted file mode 100755 index a90555fe2..000000000 --- a/ci/run-android-tests.sh +++ /dev/null @@ -1 +0,0 @@ -./gradlew connectedAndroidTest --info diff --git a/ci/run-java-tests.sh b/ci/run-java-tests.sh deleted file mode 100755 index 79bfc0111..000000000 --- a/ci/run-java-tests.sh +++ /dev/null @@ -1,8 +0,0 @@ -# We unset this, otherwise gradlew picks up settings also from the android "context", like here: https://travis-ci.org/ably/ably-java/jobs/353969106#L1980 -unset ANDROID_HOME - -ret=0 -./gradlew runUnitTests || ret=1 -./gradlew java:testRealtimeSuite || ret=1 -./gradlew java:testRestSuite || ret=1 -exit $ret diff --git a/ci/run-tests.sh b/ci/run-tests.sh deleted file mode 100755 index 20675a159..000000000 --- a/ci/run-tests.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -ex -export TERM=dumb - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -ret=0 -$DIR/../gradlew java:testRestSuite || ret=1 -$DIR/../gradlew java:testRealtimeSuite || ret=1 -exit $ret diff --git a/common.gradle b/common.gradle deleted file mode 100644 index b9f2e405c..000000000 --- a/common.gradle +++ /dev/null @@ -1,13 +0,0 @@ -repositories { - jcenter() - mavenCentral() -} - -group = 'io.ably' -version = '1.2.3' -description = 'Ably java client library' - -tasks.withType(Javadoc) { - // To prevent javadoc warnings with Java 8 - options.addStringOption('Xdoclint:none', '-quiet') -} diff --git a/dependencies.gradle b/dependencies.gradle deleted file mode 100644 index f92c795b1..000000000 --- a/dependencies.gradle +++ /dev/null @@ -1,16 +0,0 @@ -// These dependencies have to be in lib/build.gradle for compilation _and_ -// in java/build.gradle and android/build.gradle for maven. -dependencies { - implementation 'org.msgpack:msgpack-core:0.8.11' - implementation 'org.java-websocket:Java-WebSocket:1.4.0' - implementation 'com.google.code.gson:gson:2.8.6' - implementation 'com.davidehrmann.vcdiff:vcdiff-core:0.1.1' - testImplementation 'org.hamcrest:hamcrest-all:1.3' - testImplementation 'junit:junit:4.12' - testImplementation 'org.nanohttpd:nanohttpd:2.3.0' - testImplementation 'org.nanohttpd:nanohttpd-nanolets:2.3.0' - testImplementation 'org.nanohttpd:nanohttpd-websocket:2.3.0' - testImplementation 'org.mockito:mockito-core:1.10.19' - testImplementation 'net.jodah:concurrentunit:0.4.2' - testImplementation 'org.slf4j:slf4j-simple:1.7.30' -} diff --git a/gradle-lint/build.gradle b/gradle-lint/build.gradle index 365804bd3..6bbbda600 100644 --- a/gradle-lint/build.gradle +++ b/gradle-lint/build.gradle @@ -7,7 +7,7 @@ plugins { } repositories { - jcenter() + mavenCentral() } sourceSets { diff --git a/gradle.properties b/gradle.properties index 8bd86f680..d65e0f4aa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,21 @@ +GROUP=io.ably +VERSION_NAME=1.2.54 +POM_INCEPTION_YEAR=2015 +POM_URL=https://github.com/ably/ably-java +POM_SCM_URL=https://github.com/ably/ably-java/ +POM_SCM_CONNECTION=scm:git:git://github.com/ably/ably-java.git +POM_SCM_DEV_CONNECTION=scm:git:git@github.com:ably/ably-java.git + +POM_LICENSE_NAME=The Apache Software License, Version 2.0 +POM_LICENSE_URL=https://raw.github.com/ably/ably-java/main/LICENSE +POM_LICENSE_DIST=repo + +POM_DEVELOPER_ID=ably +POM_DEVELOPER_NAME=Ably +POM_DEVELOPER_URL=https://github.com/ably/ +SONATYPE_STAGING_PROFILE=io.ably + +mavenCentralPublishing=true + org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..6033754c7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,70 @@ +[versions] +agp = "8.5.2" +junit = "4.13.2" +gson = "2.9.0" +msgpack = "0.8.11" +java-websocket = "1.5.3" +vcdiff = "0.1.1" +hamcrest = "1.3" +nanohttpd = "2.3.0" +mockito = "1.10.19" +concurrentunit = "0.4.2" +slf4j = "1.7.30" +build-config = "5.4.0" +firebase-messaging = "22.0.0" +android-test = "1.0.2" +dexmaker = "1.4" +android-retrostreams = "1.7.4" +maven-publish = "0.34.0" +lombok = "8.10" +okhttp = "4.12.0" +test-retry = "1.6.0" +kotlin = "2.1.10" +coroutine = "1.9.0" +mockk = "1.14.2" +turbine = "1.2.0" +ktor = "3.1.3" +jetbrains-annoations = "26.0.2" + +[libraries] +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +msgpack = { group = "org.msgpack", name = "msgpack-core", version.ref = "msgpack" } +java-websocket = { group = "org.java-websocket", name = "Java-WebSocket", version.ref = "java-websocket" } +vcdiff-core = { group = "com.davidehrmann.vcdiff", name = "vcdiff-core", version.ref = "vcdiff" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +hamcrest-all = { group = "org.hamcrest", name = "hamcrest-all", version.ref = "hamcrest" } +nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" } +nanohttpd-nanolets = { group = "org.nanohttpd", name = "nanohttpd-nanolets", version.ref = "nanohttpd" } +nanohttpd-websocket = { group = "org.nanohttpd", name = "nanohttpd-websocket", version.ref = "nanohttpd" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +concurrentunit = { group = "net.jodah", name = "concurrentunit", version.ref = "concurrentunit" } +slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging", version.ref = "firebase-messaging" } +android-test-runner = { group = "com.android.support.test", name = "runner", version.ref = "android-test" } +android-test-rules = { group = "com.android.support.test", name = "rules", version.ref = "android-test" } +dexmaker = { group = "com.crittercism.dexmaker", name = "dexmaker", version.ref = "dexmaker" } +dexmaker-dx = { group = "com.crittercism.dexmaker", name = "dexmaker-dx", version.ref = "dexmaker" } +dexmaker-mockito = { group = "com.crittercism.dexmaker", name = "dexmaker-mockito", version.ref = "dexmaker" } +android-retrostreams = { group = "net.sourceforge.streamsupport", name = "android-retrostreams", version.ref = "android-retrostreams" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } +coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" } + +[bundles] +common = ["msgpack", "vcdiff-core"] +tests = ["junit", "hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"] +kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine", "ktor-client-cio", "ktor-client-core"] +instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"] + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } +build-config = { id = "com.github.gmazzo.buildconfig", version.ref = "build-config" } +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } +lombok = { id = "io.freefair.lombok", version.ref = "lombok" } +test-retry = { id = "org.gradle.test-retry", version.ref = "test-retry" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 87b738cbd..e708b1c02 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 33682bbbf..0ebc4df25 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Tue Sep 24 14:50:52 BST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index af6708ff2..4f906e0c8 100755 --- a/gradlew +++ b/gradlew @@ -1,5 +1,21 @@ #!/usr/bin/env sh +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + ############################################################################## ## ## Gradle start up script for UN*X @@ -28,7 +44,7 @@ APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m"' +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -66,6 +82,7 @@ esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then @@ -109,10 +126,11 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath @@ -138,19 +156,19 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + i=`expr $i + 1` done case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi @@ -159,14 +177,9 @@ save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } -APP_ARGS=$(save "$@") +APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 0f8d5937c..ac1b06f93 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -13,15 +29,18 @@ if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/images/javaSDK-github.png b/images/javaSDK-github.png new file mode 100644 index 000000000..4244467bc Binary files /dev/null and b/images/javaSDK-github.png differ diff --git a/java/build.gradle b/java/build.gradle deleted file mode 100644 index 0b2f76e27..000000000 --- a/java/build.gradle +++ /dev/null @@ -1,117 +0,0 @@ -buildscript { - repositories { - jcenter() - } -} - -plugins { - id 'de.fuerstenau.buildconfig' version '1.1.8' - id 'checkstyle' -} - -apply plugin: 'java' -apply plugin: 'idea' -apply from: '../common.gradle' -apply from: 'maven.gradle' - -sourceCompatibility = 1.7 -targetCompatibility = 1.7 - -apply from: '../dependencies.gradle' - -buildConfig { - packageName 'io.ably.lib' - clsName 'BuildConfig' - buildConfigField 'String', 'LIBRARY_NAME', 'java' -} - -sourceSets { - main { - java { - srcDirs = ['src/main/java', '../lib/src/main/java'] - } - } - test { - java { - srcDirs = ['src/test/java', '../lib/src/test/java'] - } - } -} - -// Default jar: add io.ably classes from :lib dependency. -jar { - baseName = 'ably-java' - from { - configurations.compile.collect { file -> - file.directory ? file : zipTree(file) - } - } - includes = ['**/io/ably/**'] - includeEmptyDirs false - exclude 'META-INF/**' -} - -// fullJar: add all classes from dependencies transitively. -task fullJar(type: Jar) { - baseName = 'ably-java' - classifier = 'full' - from { - configurations.compile.collect { file -> - file.directory ? file : zipTree(file) - } - } - with jar - exclude 'META-INF/**' -} - -assemble.dependsOn fullJar -assembleRelease.dependsOn checkstyleMain - -configurations { - fullConfiguration - testsConfiguration -} - -artifacts { - fullConfiguration fullJar -} - -task testRealtimeSuite(type: Test) { - filter { - includeTestsMatching '*RealtimeSuite' - } - beforeTest { descriptor -> - logger.lifecycle("-> $descriptor") - } - outputs.upToDateWhen { false } -} - -task testRestSuite(type: Test) { - filter { - includeTestsMatching '*RestSuite' - } - beforeTest { descriptor -> - logger.lifecycle("-> $descriptor") - } - outputs.upToDateWhen { false } -} - -/* -Test task to run pure unit tests, where pure means that they only run -locally and do not need to communicate with Ably servers. -This is achieved by excluding everything in the io.ably.lib.test package, -as it only contains the REST and Realtime suites. -*/ -task runUnitTests(type: Test) { - filter { - excludeTestsMatching 'io.ably.lib.test.*' - } - beforeTest { descriptor -> - // informational, so we're not flying blind at runtime - logger.lifecycle("-> $descriptor") - } - - // force tests to run every time this task is invoked - outputs.upToDateWhen { false } -} - diff --git a/java/build.gradle.kts b/java/build.gradle.kts new file mode 100644 index 000000000..6ca491377 --- /dev/null +++ b/java/build.gradle.kts @@ -0,0 +1,126 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + alias(libs.plugins.build.config) + alias(libs.plugins.maven.publish) + alias(libs.plugins.test.retry) + checkstyle + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.register("fatJar") { + archiveClassifier.set("fat") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + from(sourceSets.main.get().output) + + dependsOn(configurations.runtimeClasspath) + from({ + configurations.runtimeClasspath.get() + .filter { it.name.endsWith("jar") } + .filter { !it.name.contains("findbugs") } + .filter { !it.name.contains("jcip-annotations") } + .filter { !it.name.contains("slf4j") } + .map { zipTree(it) } + }) +} + +dependencies { + api(libs.gson) + implementation(libs.bundles.common) + compileOnly(libs.jetbrains) + implementation(project(":network-client-core")) + if (findProperty("okhttp") == null) { + runtimeOnly(project(":network-client-default")) + } else { + runtimeOnly(project(":network-client-okhttp")) + } + testImplementation(libs.bundles.tests) +} + +buildConfig { + useJavaOutput() + packageName = "io.ably.lib" + buildConfigField("String", "LIBRARY_NAME", "\"java\"") + buildConfigField("String", "VERSION", "\"${property("VERSION_NAME")}\"") +} + +sourceSets { + named("main") { + java { + srcDirs("src/main/java", "../lib/src/main/java") + } + } + named("test") { + java { + srcDirs("src/test/java", "../lib/src/test/java") + } + } +} + +tasks.checkstyleMain.configure { + exclude("io/ably/lib/BuildConfig.java") +} + +tasks.register("testRealtimeSuite") { + filter { + includeTestsMatching("*RealtimeSuite") + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } + testLogging { + exceptionFormat = TestExceptionFormat.FULL + } + retry { + maxRetries.set(3) + maxFailures.set(8) + failOnPassedAfterRetry.set(false) + failOnSkippedAfterRetry.set(false) + } +} + +tasks.register("testRestSuite") { + filter { + includeTestsMatching("*RestSuite") + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } + testLogging { + exceptionFormat = TestExceptionFormat.FULL + } + retry { + maxRetries.set(3) + maxFailures.set(8) + failOnPassedAfterRetry.set(false) + failOnSkippedAfterRetry.set(false) + } +} + +/* +Test task to run pure unit tests, where pure means that they only run +locally and do not need to communicate with Ably servers. +This is achieved by excluding everything in the io.ably.lib.test package, +as it only contains the REST and Realtime suites. +*/ +tasks.register("runUnitTests") { + filter { + excludeTestsMatching("io.ably.lib.test.*") + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} diff --git a/java/gradle.properties b/java/gradle.properties new file mode 100644 index 000000000..bff480295 --- /dev/null +++ b/java/gradle.properties @@ -0,0 +1,4 @@ +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/java/maven.gradle b/java/maven.gradle deleted file mode 100644 index f0b0e9117..000000000 --- a/java/maven.gradle +++ /dev/null @@ -1,114 +0,0 @@ -apply plugin: 'java' -apply plugin: 'maven' -apply plugin: 'signing' - -final String GROUP_ID = 'io.ably' -final String ARTIFACT_ID = 'ably-java' -final String LOCAL_RELEASE_DESTINATION = "${buildDir}/release/${version}" -final String MAVEN_USER = hasProperty('ossrhUsername') ? ossrhUsername : '' -final String MAVEN_PASSWORD = hasProperty('ossrhPassword') ? ossrhPassword : '' - -/* - * Task which signs and uploads the Java artifacts to Nexus OSSRH. - */ -uploadArchives { - signing { - sign configurations.archives - } - repositories.mavenDeployer { - logger.lifecycle('OSSRH auth with username: ' + MAVEN_USER) - - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - - repository(url: 'https://oss.sonatype.org/service/local/staging/deploy/maven2/') { - authentication(userName: MAVEN_USER, password: MAVEN_PASSWORD) - } - - snapshotRepository(url: 'https://oss.sonatype.org/content/repositories/snapshots/') { - authentication(userName: MAVEN_USER, password: MAVEN_PASSWORD) - } - - pom.groupId = GROUP_ID - pom.artifactId = ARTIFACT_ID - pom.version = version - - // Add other pom properties here if you want (developer details / licenses) - pom.project { - name 'Ably java client library' - description 'A Java Realtime and REST client library for [Ably.io](https://www.ably.io), the realtime messaging service.' - packaging 'jar' - inceptionYear '2015' - url 'https://www.github.com/ably/ably-java' - developers { - developer { - name 'Paddy Byers' - email 'paddy@ably.io' - url 'https://github.com/paddybyers' - id 'paddybyers' - } - } - scm { - url 'scm:git:https://github.com/ably/ably-java' - connection 'scm:git:https://github.com/ably/ably-java' - developerConnection 'scm:git:git@github.com:ably/ably-java' - } - organization { - name 'Ably' - url 'http://ably.io' - } - issueManagement { - system 'Github' - url 'https://github.com/ably/ably-java/issues' - } - licenses { - license { - name 'The Apache Software License, Version 2.0' - url 'https://raw.github.com/ably/ably-java/main/LICENSE' - distribution 'repo' - } - } - } - - // Exclude test dependencies - pom.whenConfigured { p -> - p.dependencies = p.dependencies.findAll { - dep -> dep.scope == 'runtime' - } - } - - // Export files to local storage - // COMMENT OUT THIS LINE IN ORDER TO RELEASE TO SONATYPE NEXUS STAGING - // TODO https://github.com/ably/ably-java/issues/566 - repository(url: "file://${LOCAL_RELEASE_DESTINATION}") - } -} - -task zipRelease(type: Zip) { - from LOCAL_RELEASE_DESTINATION - destinationDir buildDir - archiveName "release-${version}.zip" -} - -task assembleRelease { - doLast { - logger.quiet("Release ${version} can be found at ${LOCAL_RELEASE_DESTINATION}") - logger.quiet("Release ${version} zipped can be found ${buildDir}/release-${version}.zip") - } - dependsOn(uploadArchives) - dependsOn(zipRelease) -} - -task sourcesJar(type: Jar) { - classifier = 'sources' - from sourceSets.main.allSource -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir -} - -artifacts { - archives sourcesJar - archives javadocJar -} diff --git a/java/src/main/java/io/ably/lib/push/Push.java b/java/src/main/java/io/ably/lib/push/Push.java index a6a2ba584..f9385a7c6 100644 --- a/java/src/main/java/io/ably/lib/push/Push.java +++ b/java/src/main/java/io/ably/lib/push/Push.java @@ -2,6 +2,9 @@ import io.ably.lib.rest.AblyBase; +/** + * Enables a device to be registered and deregistered from receiving push notifications. + */ public class Push extends PushBase { public Push(AblyBase rest) { super(rest); diff --git a/java/src/main/java/io/ably/lib/realtime/Channel.java b/java/src/main/java/io/ably/lib/realtime/Channel.java index 9c7f64995..0f1d9a53e 100644 --- a/java/src/main/java/io/ably/lib/realtime/Channel.java +++ b/java/src/main/java/io/ably/lib/realtime/Channel.java @@ -1,11 +1,13 @@ package io.ably.lib.realtime; +import io.ably.lib.objects.ObjectsPlugin; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; +import org.jetbrains.annotations.Nullable; public class Channel extends ChannelBase { - Channel(AblyRealtime ably, String name, ChannelOptions options) throws AblyException { - super(ably, name, options); + Channel(AblyRealtime ably, String name, ChannelOptions options, @Nullable ObjectsPlugin objectsPlugin) throws AblyException { + super(ably, name, options, objectsPlugin); } public interface MessageListener extends ChannelBase.MessageListener {} diff --git a/java/src/main/java/io/ably/lib/rest/AblyRest.java b/java/src/main/java/io/ably/lib/rest/AblyRest.java index 9bd9d6b32..7ab6a3390 100644 --- a/java/src/main/java/io/ably/lib/rest/AblyRest.java +++ b/java/src/main/java/io/ably/lib/rest/AblyRest.java @@ -2,26 +2,34 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; +import io.ably.lib.util.JavaPlatformAgentProvider; +/** + * A client that offers a simple stateless API to interact directly with Ably's REST API. + * + * This class implements {@link AutoCloseable} so you can use it in + * try-with-resources constructs and have the JDK close it for you. + */ public class AblyRest extends AblyBase { /** - * Instance the Ably library using a key only. - * This is simply a convenience constructor for the - * simplest case of instancing the library with a key - * for basic authentication and no other options. - * @param key; String key (obtained from application dashboard) + * Constructs a client object using an Ably API key or token string. + *

+ * Spec: RSC1 + * @param key The Ably API key or token string used to validate the client. * @throws AblyException */ public AblyRest(String key) throws AblyException { - super(key); + super(key, new JavaPlatformAgentProvider()); } /** - * Instance the Ably library with the given options. - * @param options: see {@link io.ably.lib.types.ClientOptions} for options + * Construct a client object using an Ably {@link ClientOptions} object. + *

+ * Spec: RSC1 + * @param options A {@link ClientOptions} object to configure the client connection to Ably. * @throws AblyException */ public AblyRest(ClientOptions options) throws AblyException { - super(options); + super(options, new JavaPlatformAgentProvider()); } } diff --git a/java/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java b/java/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java new file mode 100644 index 000000000..c4b415d56 --- /dev/null +++ b/java/src/main/java/io/ably/lib/util/JavaPlatformAgentProvider.java @@ -0,0 +1,13 @@ +package io.ably.lib.util; + +public class JavaPlatformAgentProvider implements PlatformAgentProvider { + @Override + public String createPlatformAgent() { + String jreVersion = System.getProperty("java.version"); + if (jreVersion == null || jreVersion.trim().isEmpty()) { + return null; + } else { + return "jre" + AgentHeaderCreator.AGENT_DIVIDER + jreVersion.trim(); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java index 5ecc7b6d4..984e73a5f 100644 --- a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java +++ b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java @@ -1,10 +1,10 @@ package io.ably.lib.debug; -import java.net.HttpURLConnection; import java.util.List; import java.util.Map; import io.ably.lib.http.HttpCore; +import io.ably.lib.network.HttpRequest; import io.ably.lib.transport.ITransport; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; @@ -19,7 +19,7 @@ public interface RawProtocolListener { } public interface RawHttpListener { - HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody); + HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody); void onRawHttpResponse(String id, String method, HttpCore.Response response); void onRawHttpException(String id, String method, Throwable t); } @@ -31,4 +31,53 @@ public interface RawHttpListener { public RawProtocolListener protocolListener; public RawHttpListener httpListener; public ITransport.Factory transportFactory; + + public DebugOptions copy() { + DebugOptions copied = new DebugOptions(); + copied.protocolListener = protocolListener; + copied.httpListener = httpListener; + copied.transportFactory = transportFactory; + copied.clientId = clientId; + copied.logLevel = logLevel; + copied.logHandler = logHandler; + copied.tls = tls; + copied.restHost = restHost; + copied.realtimeHost = realtimeHost; + copied.port = port; + copied.tlsPort = tlsPort; + copied.autoConnect = autoConnect; + copied.useBinaryProtocol = useBinaryProtocol; + copied.queueMessages = queueMessages; + copied.echoMessages = echoMessages; + copied.recover = recover; + copied.proxy = proxy; + copied.environment = environment; + copied.idempotentRestPublishing = idempotentRestPublishing; + copied.httpOpenTimeout = httpOpenTimeout; + copied.httpRequestTimeout = httpRequestTimeout; + copied.httpMaxRetryDuration = httpMaxRetryDuration; + copied.httpMaxRetryCount = httpMaxRetryCount; + copied.realtimeRequestTimeout = realtimeRequestTimeout; + copied.disconnectedRetryTimeout = disconnectedRetryTimeout; + copied.suspendedRetryTimeout = suspendedRetryTimeout; + copied.fallbackHostsUseDefault = fallbackHostsUseDefault; + copied.fallbackRetryTimeout = fallbackRetryTimeout; + copied.defaultTokenParams = defaultTokenParams; + copied.channelRetryTimeout = channelRetryTimeout; + copied.asyncHttpThreadpoolSize = asyncHttpThreadpoolSize; + copied.pushFullWait = pushFullWait; + copied.localStorage = localStorage; + copied.addRequestIds = addRequestIds; + copied.authCallback = authCallback; + copied.authUrl = authUrl; + copied.authMethod = authMethod; + copied.key = key; + copied.token = token; + copied.tokenDetails = tokenDetails; + copied.authHeaders = authHeaders; + copied.authParams = authParams; + copied.queryTime = queryTime; + copied.useTokenAuth = useTokenAuth; + return copied; + } } diff --git a/lib/src/main/java/io/ably/lib/http/AsyncHttpScheduler.java b/lib/src/main/java/io/ably/lib/http/AsyncHttpScheduler.java index 58b25c07d..285cd856c 100644 --- a/lib/src/main/java/io/ably/lib/http/AsyncHttpScheduler.java +++ b/lib/src/main/java/io/ably/lib/http/AsyncHttpScheduler.java @@ -6,27 +6,62 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import io.ably.lib.util.Log; + /** * A HttpScheduler that uses a thread pool to run HTTP operations. */ -public class AsyncHttpScheduler extends HttpScheduler { +public class AsyncHttpScheduler extends HttpScheduler { public AsyncHttpScheduler(HttpCore httpCore, ClientOptions options) { - super(httpCore, new ThreadPoolExecutor(options.asyncHttpThreadpoolSize, options.asyncHttpThreadpoolSize, KEEP_ALIVE_TIME, TimeUnit.MILLISECONDS, new LinkedBlockingQueue())); - executor.allowsCoreThreadTimeOut(); + super(httpCore, new CloseableThreadPoolExecutor(options)); } - public void dispose() { - ThreadPoolExecutor threadPoolExecutor = executor; - threadPoolExecutor.shutdown(); - try { - threadPoolExecutor.awaitTermination(SHUTDOWN_TIME, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - threadPoolExecutor.shutdownNow(); - } + private AsyncHttpScheduler(HttpCore httpCore, CloseableExecutor executor) { + super(httpCore, executor); } private static final long KEEP_ALIVE_TIME = 2000L; - private static final long SHUTDOWN_TIME = 5000L; protected static final String TAG = AsyncHttpScheduler.class.getName(); + + /** + * [Internal Method] + *

+ * We use this method to implement proxy Realtime / Rest clients that add additional data to the underlying client. + */ + public AsyncHttpScheduler exchangeHttpCore(HttpCore httpCore) { + return new AsyncHttpScheduler(httpCore, this.executor); + } + + private static class CloseableThreadPoolExecutor implements CloseableExecutor { + private final ThreadPoolExecutor executor; + + CloseableThreadPoolExecutor(final ClientOptions options) { + executor = new ThreadPoolExecutor( + options.asyncHttpThreadpoolSize, + options.asyncHttpThreadpoolSize, + KEEP_ALIVE_TIME, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue() + ); + } + + @Override + public void execute(final Runnable command) { + executor.execute(command); + } + + @Override + public void close() throws Exception { + final int drainedCount = executor.shutdownNow().size(); + if (drainedCount > 0) { + Log.w(TAG, "close() drained (cancelled) task count: " + drainedCount); + } + } + + @Override + protected void finalize() throws Throwable { + close(); + } + } } diff --git a/lib/src/main/java/io/ably/lib/http/CloseableExecutor.java b/lib/src/main/java/io/ably/lib/http/CloseableExecutor.java new file mode 100644 index 000000000..1a473fc26 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/http/CloseableExecutor.java @@ -0,0 +1,6 @@ +package io.ably.lib.http; + +import java.util.concurrent.Executor; + +public interface CloseableExecutor extends Executor, AutoCloseable { +} diff --git a/lib/src/main/java/io/ably/lib/http/Http.java b/lib/src/main/java/io/ably/lib/http/Http.java index 425fd27ae..708ccf13b 100644 --- a/lib/src/main/java/io/ably/lib/http/Http.java +++ b/lib/src/main/java/io/ably/lib/http/Http.java @@ -7,7 +7,7 @@ /** * A high level wrapper of both a sync and an async HttpScheduler. */ -public class Http { +public class Http implements AutoCloseable { private final AsyncHttpScheduler asyncHttp; private final SyncHttpScheduler syncHttp; @@ -16,6 +16,20 @@ public Http(AsyncHttpScheduler asyncHttp, SyncHttpScheduler syncHttp) { this.syncHttp = syncHttp; } + @Override + public void close() throws Exception { + asyncHttp.close(); + } + + /** + * [Internal Method] + *

+ * We use this method to implement proxy Realtime / Rest clients that add additional data to the underlying client. + */ + public Http exchangeHttpCore(HttpCore httpCore) { + return new Http(asyncHttp.exchangeHttpCore(httpCore), new SyncHttpScheduler(httpCore)); + } + public class Request { private final Execute execute; @@ -60,7 +74,7 @@ public Request failedRequest(final AblyException e) { @Override public void execute(HttpScheduler http, final Callback callback) throws AblyException { //throw e; - http.executor.execute(new Runnable() { + http.execute(new Runnable() { @Override public void run() { callback.onError(e.errorInfo); diff --git a/lib/src/main/java/io/ably/lib/http/HttpAuth.java b/lib/src/main/java/io/ably/lib/http/HttpAuth.java index ced500dcb..1e6430ab7 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpAuth.java +++ b/lib/src/main/java/io/ably/lib/http/HttpAuth.java @@ -7,6 +7,7 @@ import java.util.Collection; import java.util.Date; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Random; @@ -20,7 +21,16 @@ public class HttpAuth { public enum Type { BASIC, DIGEST, - X_ABLY_TOKEN + X_ABLY_TOKEN; + + static Type parse(final String value) { + final String conformedValue = value.toUpperCase(Locale.ROOT).replace('-', '_'); + try { + return Type.valueOf(conformedValue); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException("Failed to parse conformed form '" + conformedValue + "' of raw value '" + value + "'.", e); + } + } } HttpAuth(String username, String password, Type prefType) { @@ -46,7 +56,7 @@ public static Map sortAuthenticateHeaders(Collection authe if(delimiterIdx == -1) { throw AblyException.fromErrorInfo(new ErrorInfo("Invalid authenticate header (no delimiter)", 40000, 400)); } String authType = header.substring(0, delimiterIdx).trim(); String authDetails = header.substring(delimiterIdx + 1).trim(); - sortedHeaders.put(Type.valueOf(authType.toUpperCase().replace('-', '_')), authDetails); + sortedHeaders.put(Type.parse(authType), authDetails); } return sortedHeaders; } diff --git a/lib/src/main/java/io/ably/lib/http/HttpCore.java b/lib/src/main/java/io/ably/lib/http/HttpCore.java index 744d44111..f3e4cf46b 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpCore.java +++ b/lib/src/main/java/io/ably/lib/http/HttpCore.java @@ -1,22 +1,14 @@ package io.ably.lib.http; -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.lang.reflect.Field; -import java.net.HttpURLConnection; -import java.net.InetSocketAddress; -import java.net.Proxy; -import java.net.URL; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import com.google.gson.JsonParseException; - import io.ably.lib.debug.DebugOptions; -import io.ably.lib.debug.DebugOptions.RawHttpListener; +import io.ably.lib.network.HttpBody; +import io.ably.lib.network.FailedConnectionException; +import io.ably.lib.network.HttpEngine; +import io.ably.lib.network.HttpEngineConfig; +import io.ably.lib.network.HttpEngineFactory; +import io.ably.lib.network.HttpRequest; +import io.ably.lib.network.HttpResponse; import io.ably.lib.rest.Auth; import io.ably.lib.transport.Defaults; import io.ably.lib.transport.Hosts; @@ -26,42 +18,116 @@ import io.ably.lib.types.ErrorResponse; import io.ably.lib.types.Param; import io.ably.lib.types.ProxyOptions; +import io.ably.lib.util.AgentHeaderCreator; +import io.ably.lib.util.Base64Coder; +import io.ably.lib.util.ClientOptionsUtils; import io.ably.lib.util.Log; +import io.ably.lib.util.PlatformAgentProvider; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; /** * HttpCore performs authenticated HTTP synchronously. Internal; use Http or HttpScheduler instead. */ public class HttpCore { + private static final String TAG = HttpCore.class.getName(); + + /************************* + * Private state + *************************/ + + static { + /* if on Android, check version */ + Field androidVersionField = null; + int androidVersion = 0; + try { + androidVersionField = Class.forName("android.os.Build$VERSION").getField("SDK_INT"); + androidVersion = androidVersionField.getInt(androidVersionField); + } catch (Exception e) { + } + if (androidVersionField != null && androidVersion < 8) { + /* HTTP connection reuse which was buggy pre-froyo */ + System.setProperty("httpCore.keepAlive", "false"); + } + } + + public final String scheme; + public final int port; + final ClientOptions options; + final Hosts hosts; + private final Auth auth; + private final PlatformAgentProvider platformAgentProvider; + private final HttpEngine engine; + private HttpAuth proxyAuth; + + /** + * This field is used for analytics purposes. + *

+ * It holds additional agents that should be added after the Realtime/Rest client is initialized. + * - **Static agents** are set in `ClientOptions`. + * - **Dynamic agents** are added later by higher-level SDKs like Chat or Asset Tracking + * and are provided in the `createWrapperSdkProxy` call. + */ + private Map dynamicAgents; + /************************* * Public API *************************/ - public HttpCore(ClientOptions options, Auth auth) throws AblyException { + public HttpCore(ClientOptions options, Auth auth, PlatformAgentProvider platformAgentProvider) throws AblyException { this.options = options; this.auth = auth; + this.platformAgentProvider = platformAgentProvider; this.scheme = options.tls ? "https://" : "http://"; this.port = Defaults.getPort(options); this.hosts = new Hosts(options.restHost, Defaults.HOST_REST, options); - - this.proxyOptions = options.proxy; - if(proxyOptions != null) { + ProxyOptions proxyOptions = options.proxy; + if (proxyOptions != null) { String proxyHost = proxyOptions.host; - if(proxyHost == null) { throw AblyException.fromErrorInfo(new ErrorInfo("Unable to configure proxy without proxy host", 40000, 400)); } + if (proxyHost == null) { + throw AblyException.fromErrorInfo(new ErrorInfo("Unable to configure proxy without proxy host", 40000, 400)); + } int proxyPort = proxyOptions.port; - if(proxyPort == 0) { throw AblyException.fromErrorInfo(new ErrorInfo("Unable to configure proxy without proxy port", 40000, 400)); } - this.proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); + if (proxyPort == 0) { + throw AblyException.fromErrorInfo(new ErrorInfo("Unable to configure proxy without proxy port", 40000, 400)); + } String proxyUser = proxyOptions.username; - if(proxyUser != null) { + if (proxyUser != null) { String proxyPassword = proxyOptions.password; - if(proxyPassword == null) { throw AblyException.fromErrorInfo(new ErrorInfo("Unable to configure proxy without proxy password", 40000, 400)); } + if (proxyPassword == null) { + throw AblyException.fromErrorInfo(new ErrorInfo("Unable to configure proxy without proxy password", 40000, 400)); + } proxyAuth = new HttpAuth(proxyUser, proxyPassword, proxyOptions.prefAuthType); } } + HttpEngineFactory engineFactory = HttpEngineFactory.getFirstAvailable(); + Log.v(TAG, String.format("Using %s HTTP Engine", engineFactory.getEngineType().name())); + this.engine = engineFactory.create(new HttpEngineConfig(ClientOptionsUtils.convertToProxyConfig(options))); + } + + private HttpCore(HttpCore underlyingHttpCore, Map dynamicAgents) { + this.options = underlyingHttpCore.options; + this.auth = underlyingHttpCore.auth; + this.platformAgentProvider = underlyingHttpCore.platformAgentProvider; + this.scheme = underlyingHttpCore.scheme; + this.port = underlyingHttpCore.port; + this.hosts = underlyingHttpCore.hosts; + this.proxyAuth = underlyingHttpCore.proxyAuth; + this.engine = underlyingHttpCore.engine; + this.dynamicAgents = dynamicAgents; } /** * Make a synchronous HTTP request specified by URL and proxy, retrying if necessary on WWW-Authenticate + * * @param url * @param method * @param headers @@ -72,21 +138,21 @@ public HttpCore(ClientOptions options, Auth auth) throws AblyException { */ public T httpExecuteWithRetry(URL url, String method, Param[] headers, RequestBody requestBody, ResponseHandler responseHandler, boolean requireAblyAuth) throws AblyException { boolean renewPending = true, proxyAuthPending = true; - if(requireAblyAuth) { + if (requireAblyAuth) { authorize(false); } - while(true) { + while (true) { try { - return httpExecute(url, getProxy(url), method, headers, requestBody, true, responseHandler); - } catch(AuthRequiredException are) { - if(are.authChallenge != null && requireAblyAuth) { - if(are.expired && renewPending) { + return httpExecute(url, method, headers, requestBody, true, responseHandler); + } catch (AuthRequiredException are) { + if (are.authChallenge != null && requireAblyAuth) { + if (are.expired && renewPending) { authorize(true); renewPending = false; continue; } } - if(are.proxyAuthChallenge != null && proxyAuthPending && proxyAuth != null) { + if (are.proxyAuthChallenge != null && proxyAuthPending && proxyAuth != null) { proxyAuth.processAuthenticateHeaders(are.proxyAuthChallenge); proxyAuthPending = false; continue; @@ -97,22 +163,21 @@ public T httpExecuteWithRetry(URL url, String method, Param[] headers, Reque } /** - * Sets host for this HTTP client + * Gets host for this HTTP client * - * @param host URL string + * @return */ - public void setPreferredHost(String host) { - hosts.setPreferredHost(host, false); + public String getPreferredHost() { + return hosts.getPreferredHost(); } /** - * Gets host for this HTTP client + * Sets host for this HTTP client * - * @return - + * @param host URL string */ - public String getPreferredHost() { - return hosts.getPreferredHost(); + public void setPreferredHost(String host) { + hosts.setPreferredHost(host, false); } /** @@ -132,20 +197,10 @@ void authorize(boolean renew) throws AblyException { auth.assertAuthorizationHeader(renew); } - synchronized void dispose() { - if(!isDisposed) { - isDisposed = true; - } - } - - public void finalize() { - dispose(); - } - /** * Make a synchronous HTTP request specified by URL and proxy + * * @param url - * @param proxy * @param method * @param headers * @param requestBody @@ -154,24 +209,14 @@ public void finalize() { * @return * @throws AblyException */ - public T httpExecute(URL url, Proxy proxy, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, ResponseHandler responseHandler) throws AblyException { - HttpURLConnection conn = null; - try { - conn = (HttpURLConnection)url.openConnection(proxy); - boolean withProxyCredentials = (proxy != Proxy.NO_PROXY) && (proxyAuth != null); - return httpExecute(conn, method, headers, requestBody, withCredentials, withProxyCredentials, responseHandler); - } catch(IOException ioe) { - throw AblyException.fromThrowable(ioe); - } finally { - if(conn != null) { - conn.disconnect(); - } - } + public T httpExecute(URL url, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, ResponseHandler responseHandler) throws AblyException { + boolean withProxyCredentials = engine.isUsingProxy() && (proxyAuth != null); + return httpExecute(url, method, headers, requestBody, withCredentials, withProxyCredentials, responseHandler); } /** * Make a synchronous HTTP request with a given HttpURLConnection - * @param conn + * * @param method * @param headers * @param requestBody @@ -180,120 +225,153 @@ public T httpExecute(URL url, Proxy proxy, String method, Param[] headers, R * @return * @throws AblyException */ - T httpExecute(HttpURLConnection conn, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, boolean withProxyCredentials, ResponseHandler responseHandler) throws AblyException { - Response response; - boolean credentialsIncluded = false; - RawHttpListener rawHttpListener = null; - String id = null; - try { - /* prepare connection */ - conn.setRequestMethod(method); - conn.setConnectTimeout(options.httpOpenTimeout); - conn.setReadTimeout(options.httpRequestTimeout); - conn.setDoInput(true); - - String authHeader = Param.getFirst(headers, HttpConstants.Headers.AUTHORIZATION); - if (authHeader == null && auth != null) { - authHeader = auth.getAuthorizationHeader(); - } - if(withCredentials && authHeader != null) { - conn.setRequestProperty(HttpConstants.Headers.AUTHORIZATION, authHeader); - credentialsIncluded = true; - } - if(withProxyCredentials && proxyAuth.hasChallenge()) { - byte[] encodedRequestBody = (requestBody != null) ? requestBody.getEncoded() : null; - String proxyAuthorizationHeader = proxyAuth.getAuthorizationHeader(method, conn.getURL().getPath(), encodedRequestBody); - conn.setRequestProperty(HttpConstants.Headers.PROXY_AUTHORIZATION, proxyAuthorizationHeader); + T httpExecute(URL url, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, boolean withProxyCredentials, ResponseHandler responseHandler) throws AblyException { + HttpRequest.HttpRequestBuilder requestBuilder = HttpRequest.builder(); + /* prepare connection */ + requestBuilder + .url(url) + .method(method) + .httpOpenTimeout(options.httpOpenTimeout) + .httpReadTimeout(options.httpRequestTimeout) + .body(requestBody != null ? new HttpBody(requestBody.getContentType(), requestBody.getEncoded()) : null); + + Map requestHeaders = collectRequestHeaders(url, method, headers, requestBody, withCredentials, withProxyCredentials); + boolean credentialsIncluded = requestHeaders.containsKey(HttpConstants.Headers.AUTHORIZATION); + String authHeader = requestHeaders.get(HttpConstants.Headers.AUTHORIZATION); + + requestBuilder.headers(requestHeaders); + HttpRequest request = requestBuilder.build(); + + // Check the logging level to avoid performance hit associated with building the message + if (Log.level <= Log.VERBOSE && request.getBody() != null && request.getBody().getContent() != null) + Log.v(TAG, System.lineSeparator() + new String(request.getBody().getContent())); + + /* log raw request details */ + Map> requestProperties = request.getHeaders(); + // Check the logging level to avoid performance hit associated with building the message + if (Log.level <= Log.VERBOSE) { + Log.v(TAG, "HTTP request: " + url + " " + method); + if (credentialsIncluded) + Log.v(TAG, " " + HttpConstants.Headers.AUTHORIZATION + ": " + authHeader); + + for (Map.Entry> entry : requestProperties.entrySet()) + for (String val : entry.getValue()) + Log.v(TAG, " " + entry.getKey() + ": " + val); + + if (requestBody != null) { + Log.v(TAG, " " + HttpConstants.Headers.CONTENT_TYPE + ": " + requestBody.getContentType()); + Log.v(TAG, " " + HttpConstants.Headers.CONTENT_LENGTH + ": " + (requestBody.getEncoded() != null ? requestBody.getEncoded().length : 0)); } - boolean acceptSet = false; - if(headers != null) { - for(Param header: headers) { - conn.setRequestProperty(header.key, header.value); - if(header.key.equals(HttpConstants.Headers.ACCEPT)) { acceptSet = true; } + } + + DebugOptions.RawHttpListener rawHttpListener = null; + String id = null; + + if (options instanceof DebugOptions) { + rawHttpListener = ((DebugOptions) options).httpListener; + if (rawHttpListener != null) { + id = String.valueOf(Math.random()).substring(2); + Response response = rawHttpListener.onRawHttpRequest(id, request, (credentialsIncluded ? authHeader : null), requestProperties, requestBody); + if (response != null) { + return handleResponse(credentialsIncluded, response, responseHandler); } } - if(!acceptSet) { conn.setRequestProperty(HttpConstants.Headers.ACCEPT, HttpConstants.ContentTypes.JSON); } + } - /* pass required headers */ - conn.setRequestProperty(Defaults.ABLY_VERSION_HEADER, Defaults.ABLY_VERSION); - conn.setRequestProperty(Defaults.ABLY_LIB_HEADER, Defaults.ABLY_LIB_VERSION); - /* prepare request body */ - byte[] body = null; - if(requestBody != null) { - body = prepareRequestBody(requestBody, conn); - if (Log.level <= Log.VERBOSE) - Log.v(TAG, System.lineSeparator() + new String(body)); - } + Response response; - /* log raw request details */ - Map> requestProperties = conn.getRequestProperties(); - if (Log.level <= Log.VERBOSE) { - Log.v(TAG, "HTTP request: " + conn.getURL() + " " + method); - if (credentialsIncluded) - Log.v(TAG, " " + HttpConstants.Headers.AUTHORIZATION + ": " + authHeader); - for (Map.Entry> entry : requestProperties.entrySet()) - for (String val : entry.getValue()) - Log.v(TAG, " " + entry.getKey() + ": " + val); + try { + response = executeRequest(request); + } catch (FailedConnectionException exception) { + throw AblyException.fromThrowable(exception); + } catch (Exception e) { + if (e.getCause() instanceof IOException) { + throw AblyException.fromThrowable(e.getCause()); + } else { + throw AblyException.fromThrowable(e); } + } - if(options instanceof DebugOptions) { - rawHttpListener = ((DebugOptions)options).httpListener; - if(rawHttpListener != null) { - id = String.valueOf(Math.random()).substring(2); - response = rawHttpListener.onRawHttpRequest(id, conn, method, (credentialsIncluded ? authHeader : null), requestProperties, requestBody); - if (response != null) { - return handleResponse(conn, credentialsIncluded, response, responseHandler); - } + if (rawHttpListener != null) { + rawHttpListener.onRawHttpResponse(id, method, response); + } + + return handleResponse(credentialsIncluded, response, responseHandler); + } + + private Map collectRequestHeaders(URL url, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, boolean withProxyCredentials) throws AblyException { + Map requestHeaders = new HashMap<>(); + + String authHeader = Param.getFirst(headers, HttpConstants.Headers.AUTHORIZATION); + if (authHeader == null && auth != null) { + authHeader = auth.getAuthorizationHeader(); + } + + if (withCredentials && authHeader != null) { + requestHeaders.put(HttpConstants.Headers.AUTHORIZATION, authHeader); + } + + if (withProxyCredentials && proxyAuth.hasChallenge()) { + byte[] encodedRequestBody = (requestBody != null) ? requestBody.getEncoded() : null; + String proxyAuthorizationHeader = proxyAuth.getAuthorizationHeader(method, url.getPath(), encodedRequestBody); + requestHeaders.put(HttpConstants.Headers.PROXY_AUTHORIZATION, proxyAuthorizationHeader); + } + + boolean acceptSet = false; + + if (headers != null) { + for (Param header : headers) { + requestHeaders.put(header.key, header.value); + if (header.key.equals(HttpConstants.Headers.ACCEPT)) { + acceptSet = true; } } + } - /* send request body */ - if(requestBody != null) { - writeRequestBody(body, conn); - } - response = readResponse(conn); - if(rawHttpListener != null) { - rawHttpListener.onRawHttpResponse(id, method, response); - } - } catch(IOException ioe) { - ioe.printStackTrace(); - if(rawHttpListener != null) { - rawHttpListener.onRawHttpException(id, method, ioe); - } - throw AblyException.fromThrowable(ioe); + if (!acceptSet) { + requestHeaders.put(HttpConstants.Headers.ACCEPT, HttpConstants.ContentTypes.JSON); } - return handleResponse(conn, credentialsIncluded, response, responseHandler); + /* pass required headers */ + requestHeaders.put(Defaults.ABLY_PROTOCOL_VERSION_HEADER, Defaults.ABLY_PROTOCOL_VERSION); // RSC7a + Map additionalAgents = new HashMap<>(); + if (options.agents != null) additionalAgents.putAll(options.agents); + if (dynamicAgents != null) additionalAgents.putAll(dynamicAgents); + requestHeaders.put(Defaults.ABLY_AGENT_HEADER, AgentHeaderCreator.create(additionalAgents, platformAgentProvider)); + if (options.clientId != null) + requestHeaders.put(Defaults.ABLY_CLIENT_ID_HEADER, Base64Coder.encodeString(options.clientId)); + + return requestHeaders; } /** * Handle HTTP response - * @param conn + * * @param credentialsIncluded * @param response * @param responseHandler * @return * @throws AblyException */ - private T handleResponse(HttpURLConnection conn, boolean credentialsIncluded, Response response, ResponseHandler responseHandler) throws AblyException { + private T handleResponse(boolean credentialsIncluded, Response response, ResponseHandler responseHandler) throws AblyException { if (response.statusCode == 0) { return null; } - if (response.statusCode >=500 && response.statusCode <= 504) { + if (response.statusCode >= 500 && response.statusCode <= 504) { ErrorInfo error = ErrorInfo.fromResponseStatus(response.statusLine, response.statusCode); throw AblyException.fromErrorInfo(error); } - if(response.statusCode >= 200 && response.statusCode < 300) { + if (response.statusCode >= 200 && response.statusCode < 300) { return (responseHandler != null) ? responseHandler.handleResponse(response, null) : null; } /* get any in-body error details */ ErrorInfo error = null; - if(response.body != null && response.body.length > 0) { - if(response.contentType != null && response.contentType.contains("msgpack")) { + if (response.body != null && response.body.length > 0) { + if (response.contentType != null && response.contentType.contains("msgpack")) { try { error = ErrorInfo.fromMsgpackBody(response.body); } catch (IOException e) { @@ -305,10 +383,10 @@ private T handleResponse(HttpURLConnection conn, boolean credentialsIncluded String bodyText = new String(response.body); try { ErrorResponse errorResponse = ErrorResponse.fromJSON(bodyText); - if(errorResponse != null) { + if (errorResponse != null) { error = errorResponse.error; } - } catch(JsonParseException jse) { + } catch (JsonParseException jse) { /* error pages aren't necessarily going to satisfy our Accept criteria ... */ System.err.println("Error message in unexpected format: " + bodyText); } @@ -316,220 +394,119 @@ private T handleResponse(HttpURLConnection conn, boolean credentialsIncluded } /* handle error details in header */ - if(error == null) { - String errorCodeHeader = conn.getHeaderField("X-Ably-ErrorCode"); - String errorMessageHeader = conn.getHeaderField("X-Ably-ErrorMessage"); - if(errorCodeHeader != null) { + if (error == null) { + String errorCodeHeader = response.getHeaderField("X-Ably-ErrorCode"); + String errorMessageHeader = response.getHeaderField("X-Ably-ErrorMessage"); + if (errorCodeHeader != null) { try { error = new ErrorInfo(errorMessageHeader, response.statusCode, Integer.parseInt(errorCodeHeader)); - } catch(NumberFormatException e) {} + } catch (NumberFormatException e) { + } } } /* handle www-authenticate */ - if(response.statusCode == 401) { + if (response.statusCode == 401) { boolean stale = (error != null && error.code == 40140); List wwwAuthHeaders = response.getHeaderFields(HttpConstants.Headers.WWW_AUTHENTICATE); - if(wwwAuthHeaders != null && wwwAuthHeaders.size() > 0) { + if (wwwAuthHeaders != null && wwwAuthHeaders.size() > 0) { Map headersByType = HttpAuth.sortAuthenticateHeaders(wwwAuthHeaders); String tokenHeader = headersByType.get(HttpAuth.Type.X_ABLY_TOKEN); - if(tokenHeader != null) { stale |= (tokenHeader.indexOf("stale") > -1); } + if (tokenHeader != null) { + stale |= (tokenHeader.indexOf("stale") > -1); + } AuthRequiredException exception = new AuthRequiredException(null, error); exception.authChallenge = headersByType; - if(stale) { + if (stale) { exception.expired = true; throw exception; } - if(!credentialsIncluded) { + if (!credentialsIncluded) { throw exception; } } } + /* handle proxy-authenticate */ - if(response.statusCode == 407) { + if (response.statusCode == 407) { List proxyAuthHeaders = response.getHeaderFields(HttpConstants.Headers.PROXY_AUTHENTICATE); - if(proxyAuthHeaders != null && proxyAuthHeaders.size() > 0) { + if (proxyAuthHeaders != null && !proxyAuthHeaders.isEmpty()) { AuthRequiredException exception = new AuthRequiredException(null, error); exception.proxyAuthChallenge = HttpAuth.sortAuthenticateHeaders(proxyAuthHeaders); throw exception; } } - if(error == null) { + + if (error == null) { error = ErrorInfo.fromResponseStatus(response.statusLine, response.statusCode); - } else { } - Log.e(TAG, "Error response from server: err = " + error.toString()); - if(responseHandler != null) { + Log.e(TAG, "Error response from server: err = " + error); + if (responseHandler != null) { return responseHandler.handleResponse(response, error); } throw AblyException.fromErrorInfo(error); } - /** - * Emit the request body for an HTTP request - * @param requestBody - * @param conn - * @return body - * @throws IOException - */ - private byte[] prepareRequestBody(RequestBody requestBody, HttpURLConnection conn) throws IOException { - conn.setDoOutput(true); - byte[] body = requestBody.getEncoded(); - int length = body.length; - conn.setFixedLengthStreamingMode(length); - conn.setRequestProperty(HttpConstants.Headers.CONTENT_TYPE, requestBody.getContentType()); - conn.setRequestProperty(HttpConstants.Headers.CONTENT_LENGTH, Integer.toString(length)); - return body; - } - - private void writeRequestBody(byte[] body, HttpURLConnection conn) throws IOException { - OutputStream os = conn.getOutputStream(); - os.write(body); - } - /** * Read the response for an HTTP request - * @param connection - * @return - * @throws IOException */ - private Response readResponse(HttpURLConnection connection) throws IOException { + private Response executeRequest(HttpRequest request) { + HttpResponse rawResponse = engine.call(request).execute(); + Response response = new Response(); - response.statusCode = connection.getResponseCode(); - response.statusLine = connection.getResponseMessage(); + response.statusCode = rawResponse.getCode(); + response.statusLine = rawResponse.getMessage(); /* Store all header field names in lower-case to eliminate case insensitivity */ Log.v(TAG, "HTTP response:"); - Map> caseSensitiveHeaders = connection.getHeaderFields(); + Map> caseSensitiveHeaders = rawResponse.getHeaders(); response.headers = new HashMap<>(caseSensitiveHeaders.size(), 1f); for (Map.Entry> entry : caseSensitiveHeaders.entrySet()) { if (entry.getKey() != null) { - response.headers.put(entry.getKey().toLowerCase(), entry.getValue()); + response.headers.put(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue()); + // Check the logging level to avoid performance hit associated with building the message if (Log.level <= Log.VERBOSE) for (String val : entry.getValue()) Log.v(TAG, entry.getKey() + ": " + val); } } - if(response.statusCode == HttpURLConnection.HTTP_NO_CONTENT) { + if (response.statusCode == HttpURLConnection.HTTP_NO_CONTENT || rawResponse.getBody() == null) { return response; } - response.contentType = connection.getContentType(); - response.contentLength = connection.getContentLength(); + response.contentType = rawResponse.getBody().getContentType(); + response.body = rawResponse.getBody().getContent(); + response.contentLength = response.body == null ? 0 : response.body.length; - InputStream is = null; - try { - is = connection.getInputStream(); - } catch (Throwable e) {} - if (is == null) - is = connection.getErrorStream(); - - try { - response.body = readInputStream(is, response.contentLength); + if (Log.level <= Log.VERBOSE && response.body != null) Log.v(TAG, System.lineSeparator() + new String(response.body)); - } catch (NullPointerException e) { - /* nothing to read */ - } finally { - if (is != null) { - try { - is.close(); - } catch (IOException e) {} - } - } return response; } - private byte[] readInputStream(InputStream inputStream, int bytes) throws IOException { - /* If there is nothing to read */ - if (inputStream == null) { - throw new NullPointerException("inputStream == null"); - } - - int bytesRead = 0; - - if (bytes == -1) { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - byte[] buffer = new byte[4 * 1024]; - while((bytesRead = inputStream.read(buffer)) > -1) { - outputStream.write(buffer, 0, bytesRead); - } - - return outputStream.toByteArray(); - } - else { - int idx = 0; - byte[] output = new byte[bytes]; - while((bytesRead = inputStream.read(output, idx, bytes - idx)) > -1) { - idx += bytesRead; - } - - return output; - } - } - - Proxy getProxy(URL url) { - String host = url.getHost(); - return getProxy(host); - } - - private Proxy getProxy(String host) { - if(proxyOptions != null) { - String[] nonProxyHosts = proxyOptions.nonProxyHosts; - if(nonProxyHosts != null) { - for(String nonProxyHostPattern : nonProxyHosts) { - if(host.matches(nonProxyHostPattern)) { - return null; - } - } - } - } - return proxy; - } - - /************************* - * Private state - *************************/ - - static { - /* if on Android, check version */ - Field androidVersionField = null; - int androidVersion = 0; - try { - androidVersionField = Class.forName("android.os.Build$VERSION").getField("SDK_INT"); - androidVersion = androidVersionField.getInt(androidVersionField); - } catch (Exception e) {} - if(androidVersionField != null && androidVersion < 8) { - /* HTTP connection reuse which was buggy pre-froyo */ - System.setProperty("httpCore.keepAlive", "false"); - } + /** + * [Internal Method] + *

+ * We use this method to implement proxy Realtime / Rest clients that add additional agents to the underlying client. + */ + public HttpCore injectDynamicAgents(Map wrapperSDKAgents) { + return new HttpCore(this, wrapperSDKAgents); } - public final String scheme; - public final int port; - final ClientOptions options; - final Hosts hosts; - - private final Auth auth; - private final ProxyOptions proxyOptions; - private HttpAuth proxyAuth; - private Proxy proxy = Proxy.NO_PROXY; - private boolean isDisposed; - - private static final String TAG = HttpCore.class.getName(); - /** * Interface for an entity that supplies an httpCore request body */ public interface RequestBody { byte[] getEncoded(); + String getContentType(); } /** * Interface for an entity that performs type-specific processing on an httpCore response body + * * @param */ public interface BodyHandler { @@ -538,6 +515,7 @@ public interface BodyHandler { /** * Interface for an entity that performs type-specific processing on an httpCore response + * * @param */ public interface ResponseHandler { @@ -550,7 +528,7 @@ public interface ResponseHandler { public static class Response { public int statusCode; public String statusLine; - public Map> headers; + public Map> headers; public String contentType; public int contentLength; public byte[] body; @@ -561,17 +539,29 @@ public static class Response { * If called on a connection that sets the same header multiple times * with possibly different values, only the last value is returned. * - * - * @param name the name of a header field. - * @return the value of the named header field, or {@code null} - * if there is no such field in the header. + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. */ public List getHeaderFields(String name) { - if(headers == null) { + if (headers == null) { + return null; + } + + return headers.get(name.toLowerCase(Locale.ROOT)); + } + + public String getHeaderField(String name) { + if (headers == null) { + return null; + } + + List values = headers.get(name.toLowerCase(Locale.ROOT)); + if (values == null || values.isEmpty()) { return null; } - return headers.get(name.toLowerCase()); + return values.get(0); } } @@ -580,11 +570,12 @@ public List getHeaderFields(String name) { */ public static class AuthRequiredException extends AblyException { private static final long serialVersionUID = 1L; - public AuthRequiredException(Throwable throwable, ErrorInfo reason) { - super(throwable, reason); - } public boolean expired; public Map authChallenge; public Map proxyAuthChallenge; + + public AuthRequiredException(Throwable throwable, ErrorInfo reason) { + super(throwable, reason); + } } } diff --git a/lib/src/main/java/io/ably/lib/http/HttpHelpers.java b/lib/src/main/java/io/ably/lib/http/HttpHelpers.java index 264d5fb79..56862ffd4 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpHelpers.java +++ b/lib/src/main/java/io/ably/lib/http/HttpHelpers.java @@ -1,6 +1,5 @@ package io.ably.lib.http; -import java.io.IOException; import java.net.URL; import io.ably.lib.types.AblyException; @@ -42,7 +41,11 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExcepti * @throws AblyException */ public static String getUrlString(HttpCore httpCore, String url) throws AblyException { - return new String(getUrl(httpCore, url)); + byte[] bytes = getUrl(httpCore, url); + if (bytes == null) { + throw AblyException.fromErrorInfo(new ErrorInfo("Empty response body", 500, 50000)); + } + return new String(bytes); } /** @@ -62,8 +65,8 @@ public byte[] handleResponse(HttpCore.Response response, ErrorInfo error) throws } return response.body; }}); - } catch(IOException ioe) { - throw AblyException.fromThrowable(ioe); + } catch (Exception e) { + throw AblyException.fromThrowable(e); } } diff --git a/lib/src/main/java/io/ably/lib/http/HttpScheduler.java b/lib/src/main/java/io/ably/lib/http/HttpScheduler.java index 54046b995..1da80c526 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpScheduler.java +++ b/lib/src/main/java/io/ably/lib/http/HttpScheduler.java @@ -1,12 +1,14 @@ package io.ably.lib.http; -import java.net.HttpURLConnection; import java.net.URL; +import java.util.Locale; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import io.ably.lib.network.HttpCall; import io.ably.lib.types.AblyException; import io.ably.lib.types.Callback; import io.ably.lib.types.ErrorInfo; @@ -17,11 +19,8 @@ * HttpScheduler schedules HttpCore operations to an Executor, exposing a generic async API. * * Internal; use Http instead. - * - * @param The Executor that will run blocking operations. */ -public class HttpScheduler { - +public class HttpScheduler implements AutoCloseable { /** * Async HTTP GET for Ably host, with fallbacks * @param path @@ -189,6 +188,12 @@ private AblyRequestWithFallback( this.path = path; this.requireAblyAuth = requireAblyAuth; } + + private String extendMessage(String msg) { + return Param.getFirst(params, "request_id") == null ? + msg : String.format(Locale.ROOT, "%s request_id=%s", msg, Param.getFirst(params, "request_id")); + } + @Override public void run() { String candidateHost = httpCore.hosts.getPreferredHost(); @@ -202,17 +207,20 @@ public void run() { break; } catch (AblyException.HostFailedException e) { if(--retryCountRemaining < 0) { + e.errorInfo.message = extendMessage(e.errorInfo.message); setError(e.errorInfo); break; } - Log.d(TAG, "Connection failed to host `" + candidateHost + "`. Searching for new host..."); + Log.d(TAG, extendMessage("Connection failed to host `" + candidateHost + "`. Searching for new host...")); candidateHost = httpCore.hosts.getFallback(candidateHost); if (candidateHost == null) { + e.errorInfo.message = extendMessage(e.errorInfo.message); setError(e.errorInfo); break; } - Log.d(TAG, "Switched to `" + candidateHost + "`."); + Log.d(TAG, extendMessage("Switched to `" + candidateHost + "`.")); } catch(AblyException e) { + e.errorInfo.message = extendMessage(e.errorInfo.message); setError(e.errorInfo); break; } finally { @@ -323,15 +331,15 @@ protected void setError(ErrorInfo err) { } } protected synchronized boolean disposeConnection() { - boolean hasConnection = conn != null; + boolean hasConnection = httpCall != null; if(hasConnection) { - conn.disconnect(); - conn = null; + httpCall.cancel(); + httpCall = null; } return hasConnection; } - protected HttpURLConnection conn; + protected HttpCall httpCall; protected T result; protected ErrorInfo err; @@ -345,11 +353,16 @@ protected synchronized boolean disposeConnection() { protected boolean isDone = false; } - protected HttpScheduler(HttpCore httpCore, Executor executor) { + protected HttpScheduler(HttpCore httpCore, CloseableExecutor executor) { this.httpCore = httpCore; this.executor = executor; } + @Override + public void close() throws Exception { + this.executor.close(); + } + /** * Make an asynchronous HTTP request to a given URL * @param url @@ -427,9 +440,18 @@ public Future ablyHttpExecuteWithRetry( return request; } - protected final Executor executor; + protected final CloseableExecutor executor; private final HttpCore httpCore; protected static final String TAG = HttpScheduler.class.getName(); + /** + * Adds a {@link Runnable} to the {@link Executor} used by this scheduler instance. + * @apiNote This is pretty hacky and is here to support the current Push Notifications implementation. + * + * @param runnable The code to be executed. + */ + public void execute(Runnable runnable) { + executor.execute(runnable); + } } diff --git a/lib/src/main/java/io/ably/lib/http/HttpUtils.java b/lib/src/main/java/io/ably/lib/http/HttpUtils.java index 02889449e..fc5fb7894 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpUtils.java +++ b/lib/src/main/java/io/ably/lib/http/HttpUtils.java @@ -4,6 +4,8 @@ import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; import java.net.URLEncoder; @@ -78,6 +80,28 @@ public static URL parseUrl(String url) throws AblyException { } } + /** + * Removes querystring from given url string and returns the url string without query string(s) + * @param url Url string that needs querystring part removed + * + * @return Url string with query string part removed, if existed in the first place + * + * @throws AblyException built from URISyntaxException if java.net.URI fails to build + * the URI given url + * */ + public static String urlWithQueryStringRemoved(String url) throws AblyException { + try { + final URI uri = new URI(url); + return new URI(uri.getScheme(), + uri.getAuthority(), + uri.getPath(), + null, // Ignore the query part of the input url + uri.getFragment()).toString(); + } catch (URISyntaxException e) { + throw AblyException.fromThrowable(e); + } + } + public static Map decodeParams(String query) { Map params = new HashMap(); String[] pairs = query.split("&"); @@ -163,18 +187,12 @@ public static String encodeURIComponent(String str) { return builder.toString(); } - private static void appendParams(StringBuilder uri, Param[] params) { - if(params != null && params.length > 0) { - uri.append('?').append(params[0].key).append('=').append(params[0].value); - for(int i = 1; i < params.length; i++) { - uri.append('&').append(params[i].key).append('=').append(params[i].value); - } - } - } - static URL buildURL(String scheme, String host, int port, String path, Param[] params) { - StringBuilder builder = new StringBuilder(scheme).append(host).append(':').append(port).append(path); - appendParams(builder, params); + StringBuilder builder = new StringBuilder(scheme) + .append(host) + .append(':') + .append(port) + .append(HttpUtils.encodeParams(path, params)); URL result = null; try { @@ -184,12 +202,9 @@ static URL buildURL(String scheme, String host, int port, String path, Param[] p } static URL buildURL(String uri, Param[] params) { - StringBuilder builder = new StringBuilder(uri); - appendParams(builder, params); - URL result = null; try { - result = new URL(builder.toString()); + result = new URL(HttpUtils.encodeParams(uri, params)); } catch (MalformedURLException e) {} return result; } diff --git a/lib/src/main/java/io/ably/lib/http/SyncHttpScheduler.java b/lib/src/main/java/io/ably/lib/http/SyncHttpScheduler.java index 8634be78d..052119586 100644 --- a/lib/src/main/java/io/ably/lib/http/SyncHttpScheduler.java +++ b/lib/src/main/java/io/ably/lib/http/SyncHttpScheduler.java @@ -5,8 +5,7 @@ /** * A HttpScheduler that runs everything in the current thread. */ -public class SyncHttpScheduler extends HttpScheduler { - +public class SyncHttpScheduler extends HttpScheduler { public SyncHttpScheduler(HttpCore httpCore) { super(httpCore, CurrentThreadExecutor.INSTANCE); } diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java new file mode 100644 index 000000000..e9a084ae7 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -0,0 +1,45 @@ +package io.ably.lib.objects; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.ChannelBase; +import io.ably.lib.transport.ConnectionManager; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.NotNull; + +public class Adapter implements ObjectsAdapter { + private final AblyRealtime ably; + private static final String TAG = ObjectsAdapter.class.getName(); + + public Adapter(@NotNull AblyRealtime ably) { + this.ably = ably; + } + + @Override + public @NotNull ClientOptions getClientOptions() { + return ably.options; + } + + @Override + public @NotNull ConnectionManager getConnectionManager() { + return ably.connection.connectionManager; + } + + @Override + public long getTime() throws AblyException { + return ably.time(); + } + + @Override + public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { + if (ably.channels.containsKey(channelName)) { + return ably.channels.get(channelName); + } else { + Log.e(TAG, "attachChannel(): channel not found: " + channelName); + ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); + throw AblyException.fromErrorInfo(errorInfo); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java b/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java new file mode 100644 index 000000000..21262942a --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java @@ -0,0 +1,46 @@ +package io.ably.lib.objects; + +import io.ably.lib.realtime.ChannelBase; +import io.ably.lib.transport.ConnectionManager; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; + +public interface ObjectsAdapter { + /** + * Retrieves the client options configured for the Ably client. + * Used to access client configuration parameters such as echoMessages setting + * that affect the behavior of Objects operations. + * + * @return the client options containing configuration parameters + */ + @NotNull ClientOptions getClientOptions(); + + /** + * Retrieves the connection manager for handling connection state and operations. + * Used to check connection status, obtain error information, and manage + * message transmission across the Ably connection. + * + * @return the connection manager instance + */ + @NotNull ConnectionManager getConnectionManager(); + + /** + * Retrieves the current time in milliseconds from the Ably server. + * Spec: RTO16 + */ + @Blocking + long getTime() throws AblyException; + + /** + * Retrieves the channel instance for the specified channel name. + * If the channel does not exist, an AblyException is thrown. + * + * @param channelName the name of the channel to retrieve + * @return the ChannelBase instance for the specified channel + * @throws AblyException if the channel is not found or cannot be retrieved + */ + @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException; +} + diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java new file mode 100644 index 000000000..0afd5ef2f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java @@ -0,0 +1,31 @@ +package io.ably.lib.objects; + +import io.ably.lib.types.AblyException; + +/** + * Callback interface for handling results of asynchronous Objects operations. + * Used for operations like creating LiveMaps/LiveCounters, modifying entries, and retrieving objects. + * Callbacks are executed on background threads managed by the Objects system. + * + * @param the type of the result returned by the asynchronous operation + */ +public interface ObjectsCallback { + + /** + * Called when the asynchronous operation completes successfully. + * For modification operations (set, remove, increment), result is typically Void. + * For creation/retrieval operations, result contains the created/retrieved object. + * + * @param result the result of the operation, may be null for modification operations + */ + void onSuccess(T result); + + /** + * Called when the asynchronous operation fails. + * The exception contains detailed error information including error codes and messages. + * Common errors include network issues, authentication failures, and validation errors. + * + * @param exception the exception that occurred during the operation + */ + void onError(AblyException exception); +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java new file mode 100644 index 000000000..38b000a11 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java @@ -0,0 +1,48 @@ +package io.ably.lib.objects; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.util.Log; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.InvocationTargetException; + +public class ObjectsHelper { + + private static final String TAG = ObjectsHelper.class.getName(); + private static volatile ObjectsSerializer objectsSerializer; + + @Nullable + public static ObjectsPlugin tryInitializeObjectsPlugin(AblyRealtime ablyRealtime) { + try { + Class objectsImplementation = Class.forName("io.ably.lib.objects.DefaultObjectsPlugin"); + ObjectsAdapter adapter = new Adapter(ablyRealtime); + return (ObjectsPlugin) objectsImplementation + .getDeclaredConstructor(ObjectsAdapter.class) + .newInstance(adapter); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); + return null; + } + } + + @Nullable + public static ObjectsSerializer getSerializer() { + if (objectsSerializer == null) { + synchronized (ObjectsHelper.class) { + if (objectsSerializer == null) { // Double-Checked Locking (DCL) + try { + Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultObjectsSerializer"); + objectsSerializer = (ObjectsSerializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | + NoSuchMethodException | + InvocationTargetException e) { + Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e); + return null; + } + } + } + } + return objectsSerializer; + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java new file mode 100644 index 000000000..b96954ca8 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java @@ -0,0 +1,39 @@ +package io.ably.lib.objects; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import io.ably.lib.util.Log; + +import java.lang.reflect.Type; + +public class ObjectsJsonSerializer implements JsonSerializer, JsonDeserializer { + private static final String TAG = ObjectsJsonSerializer.class.getName(); + + @Override + public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + ObjectsSerializer serializer = ObjectsHelper.getSerializer(); + if (serializer == null) { + Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found."); + return null; + } + if (!json.isJsonArray()) { + throw new JsonParseException("Expected a JSON array for 'state' field, but got: " + json); + } + return serializer.readFromJsonArray(json.getAsJsonArray()); + } + + @Override + public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { + ObjectsSerializer serializer = ObjectsHelper.getSerializer(); + if (serializer == null) { + Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found."); + return JsonNull.INSTANCE; + } + return serializer.asJsonArray(src); + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsPlugin.java b/lib/src/main/java/io/ably/lib/objects/ObjectsPlugin.java new file mode 100644 index 000000000..ef30ab7f8 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsPlugin.java @@ -0,0 +1,60 @@ +package io.ably.lib.objects; + +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.types.ProtocolMessage; +import org.jetbrains.annotations.NotNull; + +/** + * The ObjectsPlugin interface provides a mechanism for managing and interacting with + * live data objects in a real-time environment. It allows for the retrieval, disposal, and + * management of Objects instances associated with specific channel names. + */ +public interface ObjectsPlugin { + + /** + * Retrieves an instance of RealtimeObjects associated with the specified channel name. + * This method ensures that a RealtimeObjects instance is available for the given channel, + * creating one if it does not already exist. + * + * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved. + * @return the RealtimeObjects instance associated with the specified channel name. + */ + @NotNull + RealtimeObjects getInstance(@NotNull String channelName); + + /** + * Handles a protocol message. + * This method is invoked whenever a protocol message is received, allowing the implementation + * to process the message and take appropriate actions. + * + * @param message the protocol message to handle. + */ + void handle(@NotNull ProtocolMessage message); + + /** + * Handles state changes for a specific channel. + * This method is invoked whenever a channel's state changes, allowing the implementation + * to update the RealtimeObjects instances accordingly based on the new state and presence of objects. + * + * @param channelName the name of the channel whose state has changed. + * @param state the new state of the channel. + * @param hasObjects flag indicates whether the channel has any associated live objects. + */ + void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); + + /** + * Disposes of the RealtimeObjects instance associated with the specified channel name. + * This method removes the RealtimeObjects instance for the given channel, releasing any + * resources associated with it. + * This is invoked when ablyRealtimeClient.channels.release(channelName) is called + * + * @param channelName the name of the channel whose RealtimeObjects instance is to be removed. + */ + void dispose(@NotNull String channelName); + + /** + * Disposes of the plugin instance and all underlying resources. + * This is invoked when ablyRealtimeClient.close() is called + */ + void dispose(); +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java new file mode 100644 index 000000000..9bee9a8fd --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java @@ -0,0 +1,50 @@ +package io.ably.lib.objects; + +import com.google.gson.JsonArray; +import org.jetbrains.annotations.NotNull; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.IOException; + +/** + * Serializer interface for converting between objects and their MessagePack or JSON representations. + */ +public interface ObjectsSerializer { + /** + * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. + * + * @param unpacker the MessageUnpacker to read from + * @return the deserialized Object array + * @throws IOException if an I/O error occurs during unpacking + */ + @NotNull + Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; + + /** + * Serializes the given Object array as a MessagePack array using the provided packer. + * + * @param objects the Object array to serialize + * @param packer the MessagePacker to write to + * @throws IOException if an I/O error occurs during packing + */ + void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; + + /** + * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. + * + * @param json the {@link JsonArray} representing the array to deserialize + * @return the deserialized Object array + */ + @NotNull + Object[] readFromJsonArray(@NotNull JsonArray json); + + /** + * Serializes the given Object array as a JSON array. + * + * @param objects the Object array to serialize + * @return the resulting JsonArray + */ + @NotNull + JsonArray asJsonArray(@NotNull Object[] objects); +} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java new file mode 100644 index 000000000..2b22d71d4 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java @@ -0,0 +1,24 @@ +package io.ably.lib.objects; + +/** + * Represents a objects subscription that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they are no longer needed. + * Example usage: + *

+ * {@code
+ * ObjectsSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {});
+ * // Later when done with the subscription
+ * s.unsubscribe();
+ * }
+ * 
+ * Spec: RTLO4b5 + */ +public interface ObjectsSubscription { + /** + * This method should be called when the subscription is no longer needed, + * it will make sure no further events will be sent to the subscriber and + * that references to the subscriber are cleaned up. + * Spec: RTLO4b5a + */ + void unsubscribe(); +} diff --git a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java b/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java new file mode 100644 index 000000000..950b41bf6 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java @@ -0,0 +1,166 @@ +package io.ably.lib.objects; + +import io.ably.lib.objects.state.ObjectsStateChange; +import io.ably.lib.objects.type.counter.LiveCounter; +import io.ably.lib.objects.type.map.LiveMap; +import io.ably.lib.objects.type.map.LiveMapValue; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * The RealtimeObjects interface provides methods to interact with live data objects, + * such as maps and counters, in a real-time environment. It supports both synchronous + * and asynchronous operations for retrieving and creating live objects. + * + *

Implementations of this interface must be thread-safe as they may be accessed + * from multiple threads concurrently. + */ +public interface RealtimeObjects extends ObjectsStateChange { + + /** + * Retrieves the root LiveMap object. + * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. + * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. + * This is useful when working with multiple channels with different underlying data structure. + * + * @return the root LiveMap instance. + */ + @Blocking + @NotNull + LiveMap getRoot(); + + /** + * Creates a new empty LiveMap with no entries. + * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * and returns it. + * + * @return the newly created empty LiveMap instance. + */ + @Blocking + @NotNull + LiveMap createMap(); + + /** + * Creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. + * Implements spec RTO11 : createMap(Dict entries?) + * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * using the provided data and returns it. + * + *

Example:

+ *
{@code
+     * Map entries = Map.of(
+     *     "string", LiveMapValue.of("Hello"),
+     *     "number", LiveMapValue.of(42),
+     *     "boolean", LiveMapValue.of(true),
+     *     "binary", LiveMapValue.of(new byte[]{1, 2, 3}),
+     *     "array", LiveMapValue.of(new JsonArray()),
+     *     "object", LiveMapValue.of(new JsonObject()),
+     *     "counter", LiveMapValue.of(realtimeObjects.createCounter()),
+     *     "nested", LiveMapValue.of(realtimeObjects.createMap())
+     * );
+     * LiveMap map = realtimeObjects.createMap(entries);
+     * }
+ * + * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. + * @return the newly created LiveMap instance. + */ + @Blocking + @NotNull + LiveMap createMap(@NotNull Map entries); + + /** + * Creates a new LiveCounter with an initial value of 0. + * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * using the provided data and returns it. + * + * @return the newly created LiveCounter instance with initial value of 0. + */ + @Blocking + @NotNull + LiveCounter createCounter(); + + /** + * Creates a new LiveCounter with an initial value. + * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * using the provided data and returns it. + * + * @param initialValue the initial value of the LiveCounter. + * @return the newly created LiveCounter instance. + */ + @Blocking + @NotNull + LiveCounter createCounter(@NotNull Number initialValue); + + /** + * Asynchronously retrieves the root LiveMap object. + * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. + * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. + * This is useful when working with multiple channels with different underlying data structure. + * + * @param callback the callback to handle the result or error. + */ + @NonBlocking + void getRootAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); + + /** + * Asynchronously creates a new empty LiveMap with no entries. + * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * and returns it. + * + * @param callback the callback to handle the result or error. + */ + @NonBlocking + void createMapAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); + + /** + * Asynchronously creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. + * This method implements the spec RTO11 signature: createMap(Dict entries?) + * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * using the provided data and returns it. + * + * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. + * @param callback the callback to handle the result or error. + */ + @NonBlocking + void createMapAsync(@NotNull Map entries, @NotNull ObjectsCallback<@NotNull LiveMap> callback); + + /** + * Asynchronously creates a new LiveCounter with an initial value of 0. + * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * using the provided data and returns it. + * + * @param callback the callback to handle the result or error. + */ + @NonBlocking + void createCounterAsync(@NotNull ObjectsCallback<@NotNull LiveCounter> callback); + + /** + * Asynchronously creates a new LiveCounter with an initial value. + * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. + * Once the ACK message is received, the method returns the object from the local pool if it got created due to + * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * using the provided data and returns it. + * + * @param initialValue the initial value of the LiveCounter. + * @param callback the callback to handle the result or error. + */ + @NonBlocking + void createCounterAsync(@NotNull Number initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback); +} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java new file mode 100644 index 000000000..7b3a7e1e3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java @@ -0,0 +1,56 @@ +package io.ably.lib.objects.state; + +import io.ably.lib.objects.ObjectsSubscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +public interface ObjectsStateChange { + /** + * Subscribes to a specific Live Objects synchronization state event. + * + *

This method registers the provided listener to be notified when the specified + * synchronization state event occurs. The returned subscription can be used to + * unsubscribe later when the notifications are no longer needed. + * + * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) + * @param listener the listener that will be called when the event occurs + * @return a subscription object that can be used to unsubscribe from the event + */ + @NonBlocking + ObjectsSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); + + /** + * Unsubscribes the specified listener from all synchronization state events. + * + *

After calling this method, the provided listener will no longer receive + * any synchronization state event notifications. + * + * @param listener the listener to unregister from all events + */ + @NonBlocking + void off(@NotNull ObjectsStateChange.Listener listener); + + /** + * Unsubscribes all listeners from all synchronization state events. + * + *

After calling this method, no listeners will receive any synchronization + * state event notifications until new listeners are registered. + */ + @NonBlocking + void offAll(); + + /** + * Interface for receiving notifications about Live Objects synchronization state changes. + *

+ * Implement this interface and register it with an ObjectsStateEmitter to be notified + * when synchronization state transitions occur. + */ + interface Listener { + /** + * Called when the synchronization state changes. + * + * @param objectsStateEvent The new state event (SYNCING or SYNCED) + */ + void onStateChanged(ObjectsStateEvent objectsStateEvent); + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java new file mode 100644 index 000000000..4fa01a173 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java @@ -0,0 +1,19 @@ +package io.ably.lib.objects.state; + +/** + * Represents the synchronization state of Ably Live Objects. + *

+ * This enum is used to notify listeners about state changes in the synchronization process. + * Clients can register an {@link ObjectsStateChange.Listener} to receive these events. + */ +public enum ObjectsStateEvent { + /** + * Indicates that synchronization between local and remote objects is in progress. + */ + SYNCING, + + /** + * Indicates that synchronization has completed successfully and objects are in sync. + */ + SYNCED +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java new file mode 100644 index 000000000..6df47cf99 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java @@ -0,0 +1,27 @@ +package io.ably.lib.objects.type; + +import org.jetbrains.annotations.Nullable; + +/** + * Abstract base class for all LiveMap/LiveCounter update notifications. + * Provides common structure for updates that occur on LiveMap and LiveCounter objects. + * Contains the update data that describes what changed in the live object. + * Spec: RTLO4b4 + */ +public abstract class ObjectUpdate { + /** + * The update data containing details about the change that occurred + * Spec: RTLO4b4a + */ + @Nullable + protected final Object update; + + /** + * Creates a ObjectUpdate with the specified update data. + * + * @param update the data describing the change, or null for no-op updates + */ + protected ObjectUpdate(@Nullable Object update) { + this.update = update; + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java new file mode 100644 index 000000000..c23ccc91b --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java @@ -0,0 +1,72 @@ +package io.ably.lib.objects.type.counter; + +import io.ably.lib.objects.ObjectsCallback; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Contract; + +/** + * The LiveCounter interface provides methods to interact with a live counter. + * It allows incrementing, decrementing, and retrieving the current value of the counter, + * both synchronously and asynchronously. + */ +public interface LiveCounter extends LiveCounterChange { + + /** + * Increments the value of the counter by the specified amount. + * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. + * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when + * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * Spec: RTLC12 + * + * @param amount the amount by which to increment the counter + */ + @Blocking + void increment(@NotNull Number amount); + + /** + * Decrements the value of the counter by the specified amount. + * An alias for calling {@link LiveCounter#increment(Number)} with a negative amount. + * Spec: RTLC13 + * + * @param amount the amount by which to decrement the counter + */ + @Blocking + void decrement(@NotNull Number amount); + + /** + * Increments the value of the counter by the specified amount asynchronously. + * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. + * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when + * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * Spec: RTLC12 + * + * @param amount the amount by which to increment the counter + * @param callback the callback to be invoked upon completion of the operation. + */ + @NonBlocking + void incrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); + + /** + * Decrements the value of the counter by the specified amount asynchronously. + * An alias for calling {@link LiveCounter#incrementAsync(Number, ObjectsCallback)} with a negative amount. + * Spec: RTLC13 + * + * @param amount the amount by which to decrement the counter + * @param callback the callback to be invoked upon completion of the operation. + */ + @NonBlocking + void decrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); + + /** + * Retrieves the current value of the counter. + * + * @return the current value of the counter as a Double. + */ + @NotNull + @Contract(pure = true) // Indicates this method does not modify the state of the object. + Double value(); +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java new file mode 100644 index 000000000..79f842e74 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java @@ -0,0 +1,56 @@ +package io.ably.lib.objects.type.counter; + +import io.ably.lib.objects.ObjectsSubscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +/** + * Provides methods to subscribe to real-time updates on LiveCounter objects. + * Enables clients to receive notifications when counter values change due to + * operations performed by any client connected to the same channel. + */ +public interface LiveCounterChange { + + /** + * Subscribes to real-time updates on this LiveCounter object. + * Multiple listeners can be subscribed to the same object independently. + * Spec: RTLO4b + * + * @param listener the listener to be notified of counter updates + * @return an ObjectsSubscription for managing this specific listener + */ + @NonBlocking + @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); + + /** + * Unsubscribes a specific listener from receiving updates. + * Has no effect if the listener is not currently subscribed. + * Spec: RTLO4c + * + * @param listener the listener to be unsubscribed + */ + @NonBlocking + void unsubscribe(@NotNull Listener listener); + + /** + * Unsubscribes all listeners from receiving updates. + * No notifications will be delivered until new listeners are subscribed. + * Spec: RTLO4d + */ + @NonBlocking + void unsubscribeAll(); + + /** + * Listener interface for receiving LiveCounter updates. + * Spec: RTLO4b3 + */ + interface Listener { + /** + * Called when the LiveCounter has been updated. + * Should execute quickly as it's called from the real-time processing thread. + * + * @param update details about the counter change + */ + void onUpdated(@NotNull LiveCounterUpdate update); + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java new file mode 100644 index 000000000..d7921a0b5 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java @@ -0,0 +1,80 @@ +package io.ably.lib.objects.type.counter; + +import io.ably.lib.objects.type.ObjectUpdate; +import org.jetbrains.annotations.NotNull; + +/** + * Represents an update that occurred on a LiveCounter object. + * Contains information about counter value changes from increment/decrement operations. + * Updates can represent positive changes (increments) or negative changes (decrements). + * + * @spec RTLC11, RTLC11a - LiveCounter update structure and behavior + */ +public class LiveCounterUpdate extends ObjectUpdate { + + /** + * Creates a no-op LiveCounterUpdate representing no actual change. + */ + public LiveCounterUpdate() { + super(null); + } + + /** + * Creates a LiveCounterUpdate with the specified amount change. + * + * @param amount the amount by which the counter changed (positive = increment, negative = decrement) + */ + public LiveCounterUpdate(@NotNull Double amount) { + super(new Update(amount)); + } + + /** + * Gets the update information containing the amount of change. + * + * @return the Update object with the counter modification amount + */ + @NotNull + public LiveCounterUpdate.Update getUpdate() { + return (Update) update; + } + + /** + * Returns a string representation of this LiveCounterUpdate. + * + * @return a string showing the amount of change to the counter + */ + @Override + public String toString() { + if (update == null) { + return "LiveCounterUpdate{no change}"; + } + return "LiveCounterUpdate{amount=" + getUpdate().getAmount() + "}"; + } + + /** + * Contains the specific details of a counter update operation. + * + * @spec RTLC11b, RTLC11b1 - Counter update data structure + */ + public static class Update { + private final @NotNull Double amount; + + /** + * Creates an Update with the specified amount. + * + * @param amount the counter change amount (positive = increment, negative = decrement) + */ + public Update(@NotNull Double amount) { + this.amount = amount; + } + + /** + * Gets the amount by which the counter value was modified. + * + * @return the change amount (positive for increments, negative for decrements) + */ + public @NotNull Double getAmount() { + return amount; + } + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java new file mode 100644 index 000000000..46c336360 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java @@ -0,0 +1,130 @@ +package io.ably.lib.objects.type.map; + +import io.ably.lib.objects.ObjectsCallback; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Map; + +/** + * The LiveMap interface provides methods to interact with a live, real-time map structure. + * It supports both synchronous and asynchronous operations for managing key-value pairs. + */ +public interface LiveMap extends LiveMapChange { + + /** + * Retrieves the value associated with the specified key. + * If this map object is tombstoned (deleted), null is returned. + * If no entry is associated with the specified key, null is returned. + * If map entry is tombstoned (deleted), null is returned. + * If the value associated with the provided key is an objectId string of another RealtimeObject, a reference to + * that RealtimeObject is returned, provided it exists in the local pool and is not tombstoned. Otherwise, null is returned. + * If the value is not an objectId, then that value is returned. + * Spec: RTLM5, RTLM5a + * + * @param keyName the key whose associated value is to be returned. + * @return the value associated with the specified key, or null if the key does not exist. + */ + @Nullable + LiveMapValue get(@NotNull String keyName); + + /** + * Retrieves all entries (key-value pairs) in the map. + * Spec: RTLM11, RTLM11a + * + * @return an iterable collection of all entries in the map. + */ + @NotNull + @Unmodifiable + Iterable> entries(); + + /** + * Retrieves all keys in the map. + * Spec: RTLM12, RTLM12a + * + * @return an iterable collection of all keys in the map. + */ + @NotNull + @Unmodifiable + Iterable keys(); + + /** + * Retrieves all values in the map. + * Spec: RTLM13, RTLM13a + * + * @return an iterable collection of all values in the map. + */ + @NotNull + @Unmodifiable + Iterable values(); + + /** + * Sets the specified key to the given value in the map. + * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. + * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when + * the published MAP_SET operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * Spec: RTLM20 + * + * @param keyName the key to be set. + * @param value the value to be associated with the key. + */ + @Blocking + void set(@NotNull String keyName, @NotNull LiveMapValue value); + + /** + * Removes the specified key and its associated value from the map. + * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. + * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when + * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * Spec: RTLM21 + * + * @param keyName the key to be removed. + */ + @Blocking + void remove(@NotNull String keyName); + + /** + * Retrieves the number of entries in the map. + * Spec: RTLM10, RTLM10a + * + * @return the size of the map. + */ + @Contract(pure = true) // Indicates this method does not modify the state of the object. + @NotNull + Long size(); + + /** + * Asynchronously sets the specified key to the given value in the map. + * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. + * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when + * the published MAP_SET operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * Spec: RTLM20 + * + * @param keyName the key to be set. + * @param value the value to be associated with the key. + * @param callback the callback to handle the result or any errors. + */ + @NonBlocking + void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull ObjectsCallback callback); + + /** + * Asynchronously removes the specified key and its associated value from the map. + * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. + * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when + * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * Spec: RTLM21 + * + * @param keyName the key to be removed. + * @param callback the callback to handle the result or any errors. + */ + @NonBlocking + void removeAsync(@NotNull String keyName, @NotNull ObjectsCallback callback); +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java new file mode 100644 index 000000000..c30ae7850 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java @@ -0,0 +1,56 @@ +package io.ably.lib.objects.type.map; + +import io.ably.lib.objects.ObjectsSubscription; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +/** + * Provides methods to subscribe to real-time updates on LiveMap objects. + * Enables clients to receive notifications when map entries are added, updated, or removed. + * Uses last-write-wins conflict resolution when multiple clients modify the same key. + */ +public interface LiveMapChange { + + /** + * Subscribes to real-time updates on this LiveMap object. + * Multiple listeners can be subscribed to the same object independently. + * Spec: RTLO4b + * + * @param listener the listener to be notified of map updates + * @return an ObjectsSubscription for managing this specific listener + */ + @NonBlocking + @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); + + /** + * Unsubscribes a specific listener from receiving updates. + * Has no effect if the listener is not currently subscribed. + * Spec: RTLO4c + * + * @param listener the listener to be unsubscribed + */ + @NonBlocking + void unsubscribe(@NotNull Listener listener); + + /** + * Unsubscribes all listeners from receiving updates. + * No notifications will be delivered until new listeners are subscribed. + * Spec: RTLO4d + */ + @NonBlocking + void unsubscribeAll(); + + /** + * Listener interface for receiving LiveMap updates. + * Spec: RTLO4b3 + */ + interface Listener { + /** + * Called when the LiveMap has been updated. + * Should execute quickly as it's called from the real-time processing thread. + * + * @param update details about which keys were modified and how + */ + void onUpdated(@NotNull LiveMapUpdate update); + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java new file mode 100644 index 000000000..08fe2fc39 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java @@ -0,0 +1,66 @@ +package io.ably.lib.objects.type.map; + +import io.ably.lib.objects.type.ObjectUpdate; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * Represents an update that occurred on a LiveMap object. + * Contains information about which keys were modified and whether they were updated or removed. + * + * @spec RTLM18, RTLM18a - LiveMap update structure and behavior + */ +public class LiveMapUpdate extends ObjectUpdate { + + /** + * Creates a no-op LiveMapUpdate representing no actual change. + */ + public LiveMapUpdate() { + super(null); + } + + /** + * Creates a LiveMapUpdate with the specified key changes. + * + * @param update map of key names to their change types (UPDATED or REMOVED) + */ + public LiveMapUpdate(@NotNull Map update) { + super(update); + } + + /** + * Gets the map of key changes that occurred in this update. + * + * @return map of key names to their change types + */ + @NotNull + public Map getUpdate() { + return (Map) update; + } + + /** + * Returns a string representation of this LiveMapUpdate. + * + * @return a string showing the map key changes in this update + */ + @Override + public String toString() { + if (update == null) { + return "LiveMapUpdate{no change}"; + } + return "LiveMapUpdate{changes=" + getUpdate() + "}"; + } + + /** + * Indicates the type of change that occurred to a map key. + * + * @spec RTLM18b - Map change types + */ + public enum Change { + /** The key was added or its value was modified */ + UPDATED, + /** The key was removed from the map */ + REMOVED + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java new file mode 100644 index 000000000..ccba80330 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java @@ -0,0 +1,443 @@ +package io.ably.lib.objects.type.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.ably.lib.objects.type.counter.LiveCounter; +import org.jetbrains.annotations.NotNull; + +/** + * Abstract class representing the union type for LiveMap values. + * Provides strict compile-time type safety, implementation is similar to Gson's JsonElement pattern. + * Spec: RTO11a1 - Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap + */ +public abstract class LiveMapValue { + + /** + * Gets the underlying value. + * + * @return the value as an Object + */ + @NotNull + public abstract Object getValue(); + + /** + * Type checking methods with default implementations + */ + + /** + * Returns true if this LiveMapValue represents a Boolean value. + * + * @return true if this is a Boolean value + */ + public boolean isBoolean() { return false; } + + /** + * Returns true if this LiveMapValue represents a Binary value. + * + * @return true if this is a Binary value + */ + public boolean isBinary() { return false; } + + /** + * Returns true if this LiveMapValue represents a Number value. + * + * @return true if this is a Number value + */ + public boolean isNumber() { return false; } + + /** + * Returns true if this LiveMapValue represents a String value. + * + * @return true if this is a String value + */ + public boolean isString() { return false; } + + /** + * Returns true if this LiveMapValue represents a JsonArray value. + * + * @return true if this is a JsonArray value + */ + public boolean isJsonArray() { return false; } + + /** + * Returns true if this LiveMapValue represents a JsonObject value. + * + * @return true if this is a JsonObject value + */ + public boolean isJsonObject() { return false; } + + /** + * Returns true if this LiveMapValue represents a LiveCounter value. + * + * @return true if this is a LiveCounter value + */ + public boolean isLiveCounter() { return false; } + + /** + * Returns true if this LiveMapValue represents a LiveMap value. + * + * @return true if this is a LiveMap value + */ + public boolean isLiveMap() { return false; } + + /** + * Getter methods with default implementations that throw exceptions + */ + + /** + * Gets the Boolean value if this LiveMapValue represents a Boolean. + * + * @return the Boolean value + * @throws IllegalStateException if this is not a Boolean value + */ + @NotNull + public Boolean getAsBoolean() { + throw new IllegalStateException("Not a Boolean value"); + } + + /** + * Gets the Binary value if this LiveMapValue represents a Binary. + * + * @return the Binary value + * @throws IllegalStateException if this is not a Binary value + */ + public byte @NotNull [] getAsBinary() { + throw new IllegalStateException("Not a Binary value"); + } + + /** + * Gets the Number value if this LiveMapValue represents a Number. + * + * @return the Number value + * @throws IllegalStateException if this is not a Number value + */ + @NotNull + public Number getAsNumber() { + throw new IllegalStateException("Not a Number value"); + } + + /** + * Gets the String value if this LiveMapValue represents a String. + * + * @return the String value + * @throws IllegalStateException if this is not a String value + */ + @NotNull + public String getAsString() { + throw new IllegalStateException("Not a String value"); + } + + /** + * Gets the JsonArray value if this LiveMapValue represents a JsonArray. + * + * @return the JsonArray value + * @throws IllegalStateException if this is not a JsonArray value + */ + @NotNull + public JsonArray getAsJsonArray() { + throw new IllegalStateException("Not a JsonArray value"); + } + + /** + * Gets the JsonObject value if this LiveMapValue represents a JsonObject. + * + * @return the JsonObject value + * @throws IllegalStateException if this is not a JsonObject value + */ + @NotNull + public JsonObject getAsJsonObject() { + throw new IllegalStateException("Not a JsonObject value"); + } + + /** + * Gets the LiveCounter value if this LiveMapValue represents a LiveCounter. + * + * @return the LiveCounter value + * @throws IllegalStateException if this is not a LiveCounter value + */ + @NotNull + public LiveCounter getAsLiveCounter() { + throw new IllegalStateException("Not a LiveCounter value"); + } + + /** + * Gets the LiveMap value if this LiveMapValue represents a LiveMap. + * + * @return the LiveMap value + * @throws IllegalStateException if this is not a LiveMap value + */ + @NotNull + public LiveMap getAsLiveMap() { + throw new IllegalStateException("Not a LiveMap value"); + } + + /** + * Static factory methods similar to JsonElement constructors + */ + + /** + * Creates a LiveMapValue from a Boolean. + * + * @param value the boolean value + * @return a LiveMapValue containing the boolean + */ + @NotNull + public static LiveMapValue of(@NotNull Boolean value) { + return new BooleanValue(value); + } + + /** + * Creates a LiveMapValue from a Binary. + * + * @param value the binary value + * @return a LiveMapValue containing the binary + */ + @NotNull + public static LiveMapValue of(byte @NotNull [] value) { + return new BinaryValue(value); + } + + /** + * Creates a LiveMapValue from a Number. + * + * @param value the number value + * @return a LiveMapValue containing the number + */ + @NotNull + public static LiveMapValue of(@NotNull Number value) { + return new NumberValue(value); + } + + /** + * Creates a LiveMapValue from a String. + * + * @param value the string value + * @return a LiveMapValue containing the string + */ + @NotNull + public static LiveMapValue of(@NotNull String value) { + return new StringValue(value); + } + + /** + * Creates a LiveMapValue from a JsonArray. + * + * @param value the JsonArray value + * @return a LiveMapValue containing the JsonArray + */ + @NotNull + public static LiveMapValue of(@NotNull JsonArray value) { + return new JsonArrayValue(value); + } + + /** + * Creates a LiveMapValue from a JsonObject. + * + * @param value the JsonObject value + * @return a LiveMapValue containing the JsonObject + */ + @NotNull + public static LiveMapValue of(@NotNull JsonObject value) { + return new JsonObjectValue(value); + } + + /** + * Creates a LiveMapValue from a LiveCounter. + * + * @param value the LiveCounter value + * @return a LiveMapValue containing the LiveCounter + */ + @NotNull + public static LiveMapValue of(@NotNull LiveCounter value) { + return new LiveCounterValue(value); + } + + /** + * Creates a LiveMapValue from a LiveMap. + * + * @param value the LiveMap value + * @return a LiveMapValue containing the LiveMap + */ + @NotNull + public static LiveMapValue of(@NotNull LiveMap value) { + return new LiveMapValueWrapper(value); + } + + // Concrete implementations for each allowed type + + /** + * Boolean value implementation. + */ + private static final class BooleanValue extends LiveMapValue { + private final Boolean value; + + BooleanValue(@NotNull Boolean value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isBoolean() { return true; } + + @Override + public @NotNull Boolean getAsBoolean() { return value; } + } + + /** + * Binary value implementation. + */ + private static final class BinaryValue extends LiveMapValue { + private final byte[] value; + + BinaryValue(byte @NotNull [] value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isBinary() { return true; } + + @Override + public byte @NotNull [] getAsBinary() { return value; } + } + + /** + * Number value implementation. + */ + private static final class NumberValue extends LiveMapValue { + private final Number value; + + NumberValue(@NotNull Number value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isNumber() { return true; } + + @Override + public @NotNull Number getAsNumber() { return value; } + } + + /** + * String value implementation. + */ + private static final class StringValue extends LiveMapValue { + private final String value; + + StringValue(@NotNull String value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isString() { return true; } + + @Override + public @NotNull String getAsString() { return value; } + } + + /** + * JsonArray value implementation. + */ + private static final class JsonArrayValue extends LiveMapValue { + private final JsonArray value; + + JsonArrayValue(@NotNull JsonArray value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isJsonArray() { return true; } + + @Override + public @NotNull JsonArray getAsJsonArray() { return value; } + } + + /** + * JsonObject value implementation. + */ + private static final class JsonObjectValue extends LiveMapValue { + private final JsonObject value; + + JsonObjectValue(@NotNull JsonObject value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isJsonObject() { return true; } + + @Override + public @NotNull JsonObject getAsJsonObject() { return value; } + } + + /** + * LiveCounter value implementation. + */ + private static final class LiveCounterValue extends LiveMapValue { + private final LiveCounter value; + + LiveCounterValue(@NotNull LiveCounter value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isLiveCounter() { return true; } + + @Override + public @NotNull LiveCounter getAsLiveCounter() { return value; } + } + + /** + * LiveMap value implementation. + */ + private static final class LiveMapValueWrapper extends LiveMapValue { + private final LiveMap value; + + LiveMapValueWrapper(@NotNull LiveMap value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isLiveMap() { return true; } + + @Override + public @NotNull LiveMap getAsLiveMap() { return value; } + } +} diff --git a/lib/src/main/java/io/ably/lib/push/PushBase.java b/lib/src/main/java/io/ably/lib/push/PushBase.java index c735df2c5..dbfe31e10 100644 --- a/lib/src/main/java/io/ably/lib/push/PushBase.java +++ b/lib/src/main/java/io/ably/lib/push/PushBase.java @@ -16,13 +16,16 @@ import io.ably.lib.types.PaginatedResult; import io.ably.lib.types.Param; import io.ably.lib.util.Log; +import io.ably.lib.util.ParamsUtils; import io.ably.lib.util.Serialisation; import io.ably.lib.util.StringUtils; import java.util.Arrays; import java.util.Map; - +/** + * Enables a device to be registered and deregistered from receiving push notifications. + */ public class PushBase { public PushBase(AblyBase rest) { this.rest = rest; @@ -32,7 +35,17 @@ public PushBase(AblyBase rest) { public static class Admin { private static final String TAG = Admin.class.getName(); + /** + * A {@link DeviceRegistrations} object. + *

+ * Spec: RSH1b + */ public final DeviceRegistrations deviceRegistrations; + /** + * A {@link ChannelSubscriptions} object. + *

+ * Spec: RSH1c + */ public final ChannelSubscriptions channelSubscriptions; Admin(AblyBase rest) { @@ -41,10 +54,31 @@ public static class Admin { this.channelSubscriptions = new ChannelSubscriptions(rest); } + /** + * Sends a push notification directly to a device, or a group of devices sharing the same clientId. + *

+ * Spec: RSH1a + * + * @param recipient A JSON object containing the recipient details using clientId, deviceId or the underlying notifications service. + * @param payload A JSON object containing the push notification payload. + * @throws AblyException + */ public void publish(Param[] recipient, JsonObject payload) throws AblyException { publishImpl(recipient, payload).sync(); } + /** + * Asynchronously sends a push notification directly to a device, or a group of devices sharing the same clientId. + *

+ * Spec: RSH1a + * + * @param recipient A JSON object containing the recipient details using clientId, deviceId or the underlying notifications service. + * @param payload A JSON object containing the push notification payload. + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ public void publishAsync(Param[] recipient, JsonObject payload, final CompletionListener listener) { publishImpl(recipient, payload).async(new CompletionListener.ToCallback(listener)); } @@ -71,11 +105,7 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExce bodyJson.add(entry.getKey(), entry.getValue()); } HttpCore.RequestBody body = HttpUtils.requestBodyFromGson(bodyJson, rest.options.useBinaryProtocol); - - Param[] params = null; - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + Param[] params = ParamsUtils.enrichParams(null, rest.options); http.post("/push/publish", HttpUtils.defaultAcceptHeaders(rest.options.useBinaryProtocol), params, body, null, true, callback); } @@ -85,13 +115,35 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExce private final AblyBase rest; } + /** + * Enables the management of push notification registrations with Ably. + */ public static class DeviceRegistrations { private static final String TAG = DeviceRegistrations.class.getName(); + /** + * Registers or updates a {@link DeviceDetails} object with Ably. + * Returns the new, or updated {@link DeviceDetails} object. + *

+ * Spec: RSH1b3 + * + * @param device The {@link DeviceDetails} object to create or update. + * @return A {@link DeviceDetails} object. + * @throws AblyException + */ public DeviceDetails save(DeviceDetails device) throws AblyException { return saveImpl(device).sync(); } + /** + * Asynchronously registers or updates a {@link DeviceDetails} object with Ably. + * Returns the new, or updated {@link DeviceDetails} object. + *

+ * Spec: RSH1b3 + * + * @param device The {@link DeviceDetails} object to create or update. + * @param callback A callback returning a {@link DeviceDetails} object. + */ public void saveAsync(DeviceDetails device, final Callback callback) { saveImpl(device).async(callback); } @@ -101,20 +153,34 @@ protected Http.Request saveImpl(final DeviceDetails device) { final HttpCore.RequestBody body = HttpUtils.requestBodyFromGson(device.toJsonObject(), rest.options.useBinaryProtocol); return rest.http.request(new Http.Execute() { @Override - public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + public void execute(HttpScheduler http, Callback callback) { + Param[] params = ParamsUtils.enrichParams(null, rest.options); http.put("/push/deviceRegistrations/" + device.id, rest.push.pushRequestHeaders(device.id), params, body, DeviceDetails.httpResponseHandler, true, callback); } }); } + /** + * Retrieves the {@link DeviceDetails} of a device registered to receive push notifications using its deviceId. + *

+ * Spec: RSH1b1 + * + * @param deviceId The unique ID of the device. + * @return A {@link DeviceDetails} object. + * @throws AblyException + */ public DeviceDetails get(String deviceId) throws AblyException { return getImpl(deviceId).sync(); } + /** + * Asynchronously retrieves the {@link DeviceDetails} of a device registered to receive push notifications using its deviceId. + *

+ * Spec: RSH1b1 + * + * @param deviceId The unique ID of the device. + * @param callback A callback returning a {@link DeviceDetails} object. + */ public void getAsync(String deviceId, final Callback callback) { getImpl(deviceId).async(callback); } @@ -124,19 +190,37 @@ protected Http.Request getImpl(final String deviceId) { return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + Param[] params = ParamsUtils.enrichParams(null, rest.options); http.get("/push/deviceRegistrations/" + deviceId, rest.push.pushRequestHeaders(deviceId), params, DeviceDetails.httpResponseHandler, true, callback); } }); } + /** + * Retrieves all devices matching the filter params provided. + * Returns a {@link PaginatedResult} object, containing an array of {@link DeviceDetails} objects. + *

+ * Spec: RSH1b2 + * + * @param params An object containing key-value pairs to filter devices by. + * Can contain clientId, deviceId and a limit on the number of devices returned, up to 1,000. + * @return A {@link PaginatedResult} object containing an array of {@link DeviceDetails} objects. + * @throws AblyException + */ public PaginatedResult list(Param[] params) throws AblyException { return listImpl(params).sync(); } + /** + * Asynchronously retrieves all devices matching the filter params provided. + * Returns a {@link AsyncPaginatedResult} object, containing an array of {@link DeviceDetails} objects. + *

+ * Spec: RSH1b2 + * + * @param params An object containing key-value pairs to filter devices by. + * Can contain clientId, deviceId and a limit on the number of devices returned, up to 1,000. + * @param callback A callback returning a {@link AsyncPaginatedResult} object containing an array of {@link DeviceDetails} objects. + */ public void listAsync(Param[] params, Callback> callback) { listImpl(params).async(callback); } @@ -146,18 +230,50 @@ protected BasePaginatedQuery.ResultRequest listImpl(Param[] param return new BasePaginatedQuery(rest.http, "/push/deviceRegistrations", HttpUtils.defaultAcceptHeaders(rest.options.useBinaryProtocol), params, DeviceDetails.httpBodyHandler).get(); } + /** + * Removes a device registered to receive push notifications from Ably using the id property of a {@link DeviceDetails} object. + *

+ * Spec: RSH1b4 + * + * @param device The {@link DeviceDetails} object containing the id property of the device. + * @throws AblyException + */ public void remove(DeviceDetails device) throws AblyException { remove(device.id); } + /** + * Asynchronously removes a device registered to receive push notifications from Ably using the id property of a {@link DeviceDetails} object. + *

+ * Spec: RSH1b4 + * + * @param device The {@link DeviceDetails} object containing the id property of the device. + * @param listener A listener to be notified of success or failure. + */ public void removeAsync(DeviceDetails device, CompletionListener listener) { removeAsync(device.id, listener); } + /** + * Removes a device registered to receive push notifications from Ably using its deviceId. + *

+ * Spec: RSH1b4 + * + * @param deviceId The unique ID of the device. + * @throws AblyException + */ public void remove(String deviceId) throws AblyException { removeImpl(deviceId).sync(); } + /** + * Asynchronously removes a device registered to receive push notifications from Ably using its deviceId. + *

+ * Spec: RSH1b4 + * + * @param deviceId The unique ID of the device. + * @param listener A listener to be notified of success or failure. + */ public void removeAsync(String deviceId, CompletionListener listener) { removeImpl(deviceId).async(new CompletionListener.ToCallback(listener)); } @@ -167,29 +283,39 @@ protected Http.Request removeImpl(final String deviceId) { return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + Param[] params = ParamsUtils.enrichParams(null, rest.options); http.del("/push/deviceRegistrations/" + deviceId, rest.push.pushRequestHeaders(deviceId), params, null, true, callback); } }); } + /** + * Removes all devices registered to receive push notifications from Ably matching the filter params provided. + *

+ * Spec: RSH1b5 + * + * @param params An object containing key-value pairs to filter devices by. Can contain clientId and deviceId. + * @throws AblyException + */ public void removeWhere(Param[] params) throws AblyException { removeWhereImpl(params).sync(); } + /** + * Removes all devices registered to receive push notifications from Ably matching the filter params provided. + *

+ * Spec: RSH1b5 + * + * @param params An object containing key-value pairs to filter devices by. Can contain clientId and deviceId. + * @param listener A listener to be notified of success or failure. + */ public void removeWhereAsync(Param[] params, CompletionListener listener) { removeWhereImpl(params).async(new CompletionListener.ToCallback(listener)); } protected Http.Request removeWhereImpl(Param[] params) { Log.v(TAG, "removeWhereImpl(): params=" + Arrays.toString(params)); - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } - final Param[] finalParams = params; + final Param[] finalParams = ParamsUtils.enrichParams(params, rest.options); return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { @@ -205,13 +331,35 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExce private final AblyBase rest; } + /** + * Enables device push channel subscriptions. + */ public static class ChannelSubscriptions { private static final String TAG = ChannelSubscriptions.class.getName(); + /** + * Subscribes a device, or a group of devices sharing the same clientId to push notifications on a channel. + * Returns a {@link ChannelSubscription} object. + *

+ * Spec: RSH1c3 + * + * @param subscription A {@link ChannelSubscription} object. + * @return A {@link ChannelSubscription} object describing the new or updated subscriptions. + * @throws AblyException + */ public ChannelSubscription save(ChannelSubscription subscription) throws AblyException { return saveImpl(subscription).sync(); } + /** + * Asynchronously subscribes a device, or a group of devices sharing the same clientId to push notifications on a channel. + * Returns a {@link ChannelSubscription} object. + *

+ * Spec: RSH1c3 + * + * @param subscription A {@link ChannelSubscription} object. + * @param callback A callback returning {@link ChannelSubscription} object describing the new or updated subscriptions. + */ public void saveAsync(ChannelSubscription subscription, final Callback callback) { saveImpl(subscription).async(callback); } @@ -222,19 +370,38 @@ protected Http.Request saveImpl(final ChannelSubscription s return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - Param[] params = null; - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + Param[] params = ParamsUtils.enrichParams(null, rest.options); http.post("/push/channelSubscriptions", rest.push.pushRequestHeaders(subscription.deviceId), params, body, ChannelSubscription.httpResponseHandler, true, callback); } }); } + /** + * Retrieves all push channel subscriptions matching the filter params provided. + * Returns a {@link PaginatedResult} object, containing an array of {@link ChannelSubscription} objects. + *

+ * Spec: RSH1c1 + * + * @param params An object containing key-value pairs to filter subscriptions by. + * Can contain channel, clientId, deviceId and a limit on the number of devices returned, up to 1,000. + * @return A {@link PaginatedResult} object containing an array of {@link ChannelSubscription} objects. + * @throws AblyException + */ public PaginatedResult list(Param[] params) throws AblyException { return listImpl(params).sync(); } + /** + * Asynchronously retrieves all push channel subscriptions matching the filter params provided. + * Returns a {@link PaginatedResult} object, containing an array of {@link ChannelSubscription} objects. + *

+ * Spec: RSH1c1 + * + * @param params An object containing key-value pairs to filter subscriptions by. + * Can contain channel, clientId, deviceId and a limit on the number of devices returned, up to 1,000. + * @param callback A callback returning {@link AsyncPaginatedResult} object containing an array of {@link ChannelSubscription} objects. + * @throws AblyException + */ public void listAsync(Param[] params, Callback> callback) { listImpl(params).async(callback); } @@ -245,10 +412,28 @@ protected BasePaginatedQuery.ResultRequest listImpl(Param[] return new BasePaginatedQuery(rest.http, "/push/channelSubscriptions", rest.push.pushRequestHeaders(deviceId), params, ChannelSubscription.httpBodyHandler).get(); } + /** + * Unsubscribes a device, or a group of devices sharing the same clientId from receiving push notifications on a channel. + *

+ * Spec: RSH1c4 + * + * @param subscription A {@link ChannelSubscription} object. + * @throws AblyException + */ public void remove(ChannelSubscription subscription) throws AblyException { removeImpl(subscription).sync(); } + /** + * Asynchronously unsubscribes a device, + * or a group of devices sharing the same clientId from receiving push notifications on a channel. + *

+ * Spec: RSH1c4 + * + * @param subscription A {@link ChannelSubscription} object. + * @param listener A listener to be notified of success or failure. + * @throws AblyException + */ public void removeAsync(ChannelSubscription subscription, CompletionListener listener) { removeImpl(subscription).async(new CompletionListener.ToCallback(listener)); } @@ -267,11 +452,29 @@ protected Http.Request removeImpl(ChannelSubscription subscription) { return removeWhereImpl(params); } - + /** + * Unsubscribes all devices from receiving push notifications on a channel that match the filter params provided. + *

+ * Spec: RSH1c5 + * + * @param params An object containing key-value pairs to filter subscriptions by. + * Can contain channel, and optionally either clientId or deviceId. + * @throws AblyException + */ public void removeWhere(Param[] params) throws AblyException { removeWhereImpl(params).sync(); } + /** + * Asynchronously unsubscribes all devices from receiving push notifications on a channel that match the filter params provided. + *

+ * Spec: RSH1c5 + * + * @param params An object containing key-value pairs to filter subscriptions by. + * Can contain channel, and optionally either clientId or deviceId. + * @param listener A listener to be notified of success or failure. + * @throws AblyException + */ public void removeWhereAsync(Param[] params, CompletionListener listener) { removeWhereImpl(params).async(new CompletionListener.ToCallback(listener)); } @@ -279,11 +482,8 @@ public void removeWhereAsync(Param[] params, CompletionListener listener) { protected Http.Request removeWhereImpl(Param[] params) { Log.v(TAG, "removeWhereImpl(): params=" + Arrays.toString(params)); String deviceId = HttpUtils.getParam(params, "deviceId"); - if (rest.options.pushFullWait) { - params = Param.push(params, "fullWait", "true"); - } + final Param[] finalParams = ParamsUtils.enrichParams(params, rest.options); final Param[] finalHeaders = rest.push.pushRequestHeaders(deviceId); - final Param[] finalParams = params; return rest.http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { @@ -292,10 +492,32 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExce }); } + /** + * Retrieves all channels with at least one device subscribed to push notifications. + * Returns a {@link PaginatedResult} object, containing an array of channel names. + *

+ * Spec: RSH1c2 + * + * @param params An object containing key-value pairs to filter channels by. + * Can contain a limit on the number of channels returned, up to 1,000. + * @return A {@link PaginatedResult} object containing an array of channel names. + * @throws AblyException + */ public PaginatedResult listChannels(Param[] params) throws AblyException { return listChannelsImpl(params).sync(); } + /** + * Asynchronously retrieves all channels with at least one device subscribed to push notifications. + * Returns a {@link PaginatedResult} object, containing an array of channel names. + *

+ * Spec: RSH1c2 + * + * @param params An object containing key-value pairs to filter channels by. + * Can contain a limit on the number of channels returned, up to 1,000. + * @param callback A {@link AsyncPaginatedResult} callback returning object containing an array of channel names. + * @throws AblyException + */ public void listChannelsAsync(Param[] params, Callback> callback) { listChannelsImpl(params).async(callback); } @@ -313,15 +535,46 @@ protected BasePaginatedQuery.ResultRequest listChannelsImpl(Param[] para private final AblyBase rest; } + /** + * Contains the subscriptions of a device, or a group of devices sharing the same clientId, + * has to a channel in order to receive push notifications. + */ public static class ChannelSubscription { + /** + * The channel the push notification subscription is for. + *

+ * Spec: PCS4 + */ public final String channel; + /** + * The unique ID of the device. + *

+ * Spec: PCS2, PCS5, PCS6 + */ public final String deviceId; + /** + * The ID of the client the device, or devices are associated to. + *

+ * Spec: PCS3, PCS6 + */ public final String clientId; + /** + * A static factory method to create a PushChannelSubscription object for a channel and single device. + * @param channel The channel name. + * @param deviceId The unique ID of the device. + * @return A {@link ChannelSubscription} object. + */ public static ChannelSubscription forDevice(String channel, String deviceId) { return new ChannelSubscription(channel, deviceId, null); } + /** + * A static factory method to create a PushChannelSubscription object for a channel and group of devices sharing the same clientId. + * @param channel The channel name. + * @param clientId The ID of the client. + * @return A {@link ChannelSubscription} object. + */ public static ChannelSubscription forClientId(String channel, String clientId) { return new ChannelSubscription(channel, null, clientId); } @@ -388,5 +641,10 @@ Param[] pushRequestHeaders(String deviceId) { } protected final AblyBase rest; + /** + * A {@link PushBase.Admin} object. + *

+ * Spec: RSH1 + */ public final Admin admin; } diff --git a/lib/src/main/java/io/ably/lib/push/Storage.java b/lib/src/main/java/io/ably/lib/push/Storage.java new file mode 100644 index 000000000..cb30dbd55 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/push/Storage.java @@ -0,0 +1,43 @@ +package io.ably.lib.push; + +/** + * Interface for an entity that supplies key value store + */ +public interface Storage { + + /** + * Put string value in to storage + * @param key name under which value is stored + * @param value stored string value + */ + void put(String key, String value); + + /** + * Put integer value in to storage + * @param key name after which value is stored + * @param value stored integer value + */ + void put(String key, int value); + + /** + * Returns string value based on key from storage + * @param key name under value is stored + * @param defaultValue value which is returned if key is not found + * @return value stored under key or default value if key is not found + */ + String get(String key, String defaultValue); + + /** + * Returns integer value based on key from storage + * @param key name under value is stored + * @param defaultValue value which is returned if key is not found + * @return value stored under key or default value if key is not found + */ + int get(String key, int defaultValue); + + /** + * Removes keys from storage + * @param keys array of keys which values should be removed from storage + */ + void clear(String[] keys); +} 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 a97205cb6..7cc4480f2 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -1,10 +1,14 @@ package io.ably.lib.realtime; -import java.util.Iterator; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import io.ably.lib.objects.ObjectsHelper; +import io.ably.lib.objects.ObjectsPlugin; import io.ably.lib.rest.AblyRest; +import io.ably.lib.rest.Auth; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; @@ -12,31 +16,46 @@ import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.types.ReadOnlyMap; +import io.ably.lib.types.RecoveryKeyContext; import io.ably.lib.util.InternalMap; import io.ably.lib.util.Log; +import io.ably.lib.util.StringUtils; +import org.jetbrains.annotations.Nullable; /** - * AblyRealtime - * The top-level class to be instanced for the Ably Realtime library. + * A client that extends the functionality of the {@link AblyRest} and provides additional realtime-specific features. * * This class implements {@link AutoCloseable} so you can use it in * try-with-resources constructs and have the JDK close it for you. */ -public class AblyRealtime extends AblyRest implements AutoCloseable { - +public class AblyRealtime extends AblyRest { /** * The {@link Connection} object for this instance. + *

+ * Spec: RTC2 */ public final Connection connection; + /** + * A {@link Channels} object. + *

+ * Spec: RTC3, RTS1 + */ public final Channels channels; /** - * Instance the Ably library using a key only. - * This is simply a convenience constructor for the - * simplest case of instancing the library with a key - * for basic authentication and no other options. - * @param key String key (obtained from application dashboard) + * A nullable reference to the LiveObjects plugin. + *

+ * This field is initialized only if the LiveObjects plugin is present in the classpath. + */ + @Nullable + private final ObjectsPlugin objectsPlugin; + + /** + * Constructs a Realtime client object using an Ably API key or token string. + *

+ * Spec: RSC1 + * @param key The Ably API key or token string used to validate the client. * @throws AblyException */ public AblyRealtime(String key) throws AblyException { @@ -44,43 +63,68 @@ public AblyRealtime(String key) throws AblyException { } /** - * Instance the Ably library with the given options. - * @param options see {@link io.ably.lib.types.ClientOptions} for options + * Constructs a RealtimeClient object using an Ably {@link ClientOptions} object. + *

+ * Spec: RSC1 + * @param options A {@link ClientOptions} object. * @throws AblyException */ public AblyRealtime(ClientOptions options) throws AblyException { super(options); final InternalChannels channels = new InternalChannels(); this.channels = channels; - connection = new Connection(this, channels); - /* remove all channels when the connection is closed, to avoid stalled state */ - connection.on(ConnectionEvent.closed, new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateListener.ConnectionStateChange state) { - channels.clear(); + objectsPlugin = ObjectsHelper.tryInitializeObjectsPlugin(this); + + connection = new Connection(this, channels, platformAgentProvider, objectsPlugin); + + if (!StringUtils.isNullOrEmpty(options.recover)) { + RecoveryKeyContext recoveryKeyContext = RecoveryKeyContext.decode(options.recover); + if (recoveryKeyContext != null) { + setChannelSerialsFromRecoverOption(recoveryKeyContext.getChannelSerials()); // RTN16j + connection.connectionManager.msgSerial = recoveryKeyContext.getMsgSerial(); //RTN16f } - }); + } if(options.autoConnect) connection.connect(); } /** - * Initiate a connection. - * {@link Connection#connect}. + * Calls {@link Connection#connect} and causes the connection to open, + * entering the connecting state. Explicitly calling connect() is unnecessary + * unless the {@link ClientOptions#autoConnect} property is disabled. + *

+ * Spec: RTN11 */ public void connect() { connection.connect(); } /** - * Close this instance. This closes the connection. - * The connection can be re-opened by calling - * {@link Connection#connect}. + * Calls {@link Connection#close} and causes the connection to close, entering the closing state. + * Once closed, the library will not attempt to re-establish the connection + * without an explicit call to {@link Connection#connect}. + *

+ * Spec: RTN12 */ @Override public void close() { + try { + super.close(); // throws checked exception + } catch (final Exception exception) { + // Soften to Log, rather than throw. + // This is because our close() method has never declared that it throws a checked exception. + // Which is confusing, given AutoCloseable declares that it does. + // TODO captured in https://github.com/ably/ably-java/issues/806 + // It's also because this particular piece of resource cleanup, focussed on thread pool resources used by + // our REST code in the base class, is being introduced in an SDK patch release for version 1.2. + Log.e(TAG, "There was an exception releasing client instance base resources.", exception); + } + connection.close(); + if (objectsPlugin != null) { + objectsPlugin.dispose(); + } } /** @@ -91,6 +135,14 @@ protected void onAuthUpdated(String token, boolean waitForResponse) throws AblyE connection.connectionManager.onAuthUpdated(token, waitForResponse); } + /** + * Authentication token has changed. Async version + */ + @Override + protected void onAuthUpdatedAsync(String token, Auth.AuthUpdateResult authUpdateResult) { + connection.connectionManager.onAuthUpdatedAsync(token,authUpdateResult); + } + /** * Authentication error occurred */ @@ -103,37 +155,37 @@ protected void onAuthError(ErrorInfo errorInfo) { */ public interface Channels extends ReadOnlyMap { /** - * Get the named channel; if it does not already exist, - * create it with default options. - * @param channelName the name of the channel - * @return the channel + * Creates a new {@link Channel} object, or returns the existing channel object. + *

+ * Spec: RSN3a, RTS3a + * @param channelName The channel name. + * @return A {@link Channel} object. */ Channel get(String channelName); /** - * Get the named channel and set the given options, creating it - * if it does not already exist. - * @param channelName the name of the channel - * @param channelOptions the options to set (null to clear options on an existing channel) - * @return the channel + * Creates a new {@link Channel} object, with the specified {@link ChannelOptions}, or returns the existing channel object. + *

+ * Spec: RSN3c, RTS3c + * @param channelName The channel name. + * @param channelOptions A {@link ChannelOptions} object. + * @return A {@link Channel} object. * @throws AblyException */ Channel get(String channelName, ChannelOptions channelOptions) throws AblyException; /** - * Remove this channel from this AblyRealtime instance. This detaches from the channel - * and releases all other resources associated with the channel in this client. - * This silently does nothing if the channel does not already exist. - * @param channelName the name of the channel + * Releases a {@link Channel} object, deleting it, and enabling it to be garbage collected. + * It also removes any listeners associated with the channel. + * To release a channel, the {@link ChannelState} must be INITIALIZED, DETACHED, or FAILED. + *

+ * Spec: RSN4, RTS4 + * @param channelName The channel name. */ void release(String channelName); } private class InternalChannels extends InternalMap implements Channels, ConnectionManager.Channels { - private InternalChannels() { - super(new ConcurrentHashMap()); - } - /** * Get the named channel; if it does not already exist, * create it with default options. @@ -148,40 +200,51 @@ public Channel get(String channelName) { } @Override - public Channel get(String channelName, ChannelOptions channelOptions) throws AblyException { - Channel channel = map.get(channelName); - if (channel != null) { + public Channel get(final String channelName, final ChannelOptions channelOptions) throws AblyException { + // We're not using computeIfAbsent because that requires Java 1.8. + // Hence there's the slight inefficiency of creating newChannel when it may not be + // needed because there is an existingChannel. + final Channel newChannel = new Channel(AblyRealtime.this, channelName, channelOptions, objectsPlugin); + final Channel existingChannel = map.putIfAbsent(channelName, newChannel); + + if (existingChannel != null) { if (channelOptions != null) { - if (channel.shouldReattachToSetOptions(channelOptions)) { + if (existingChannel.shouldReattachToSetOptions(channelOptions)) { throw AblyException.fromErrorInfo(new ErrorInfo("Channels.get() cannot be used to set channel options that would cause the channel to reattach. Please, use Channel.setOptions() instead.", 40000, 400)); } - channel.setOptions(channelOptions); + existingChannel.setOptions(channelOptions); } - return channel; + return existingChannel; } - channel = new Channel(AblyRealtime.this, channelName, channelOptions); - map.put(channelName, channel); - return channel; + return newChannel; } @Override public void release(String channelName) { Channel channel = map.remove(channelName); if(channel != null) { + channel.markAsReleased(); try { channel.detach(); } catch (AblyException e) { Log.e(TAG, "Unexpected exception detaching channel; channelName = " + channelName, e); } } + if (objectsPlugin != null) { + objectsPlugin.dispose(channelName); + } } @Override public void onMessage(ProtocolMessage msg) { String channelName = msg.channel; - Channel channel; - synchronized(this) { channel = channels.get(channelName); } + Channel channel = null; + synchronized(this) { + if (channels.containsKey(channelName)) { + channel = channels.get(channelName); + } + } if(channel == null) { Log.e(TAG, "Received channel message for non-existent channel"); return; @@ -191,9 +254,32 @@ public void onMessage(ProtocolMessage msg) { @Override public void suspendAll(ErrorInfo error, boolean notifyStateChange) { - for(Iterator> it = map.entrySet().iterator(); it.hasNext(); ) { - Map.Entry entry = it.next(); - entry.getValue().setSuspended(error, notifyStateChange); + for (Channel channel : map.values()) { + channel.setSuspended(error, notifyStateChange); + } + } + + /** + * By spec RTN15c3 + * Move queued messages from connection manager to their respective channel for them to be sent after reattach + * @param queuedMessages Queued messages transferred from ConnectionManager + */ + @Override + public void transferToChannelQueue(List queuedMessages) { + final Map> channelQueueMap = new HashMap<>(); + for (ConnectionManager.QueuedMessage queuedMessage : queuedMessages) { + final String channelName = queuedMessage.msg.channel; + if (!channelQueueMap.containsKey(channelName)){ + channelQueueMap.put(channelName, new ArrayList<>()); + } + channelQueueMap.get(channelName).add(queuedMessage); + } + + for (Channel channel : map.values()) { + if (channel.state.isReattachable()) { + Log.d(TAG, "reAttach(); channel = " + channel.name); + channel.transferQueuedPresenceMessages(channelQueueMap.get(channel.name)); + } } } @@ -202,6 +288,27 @@ private void clear() { } } + protected void setChannelSerialsFromRecoverOption(Map serials) { + for (Map.Entry entry : serials.entrySet()) { + String channelName = entry.getKey(); + String channelSerial = entry.getValue(); + Channel channel = this.channels.get(channelName); + if (channel != null) { + channel.properties.channelSerial = channelSerial; + } + } + } + + protected Map getChannelSerials() { + Map channelSerials = new HashMap<>(); + for (Channel channel : this.channels.values()) { + if (channel.state == ChannelState.attached) { + channelSerials.put(channel.name, channel.properties.channelSerial); + } + } + return channelSerials; + } + /******************** * internal ********************/ diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index 434b85b63..587a13fab 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -1,17 +1,21 @@ package io.ably.lib.realtime; -import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.Timer; import java.util.TimerTask; import io.ably.lib.http.BasePaginatedQuery; +import io.ably.lib.http.Http; import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; +import io.ably.lib.objects.RealtimeObjects; +import io.ably.lib.objects.ObjectsPlugin; +import io.ably.lib.rest.RestAnnotations; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.transport.ConnectionManager.QueuedMessage; import io.ably.lib.transport.Defaults; @@ -25,6 +29,7 @@ import io.ably.lib.types.DeltaExtras; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.Message; +import io.ably.lib.types.MessageAction; import io.ably.lib.types.MessageDecodeException; import io.ably.lib.types.MessageSerializer; import io.ably.lib.types.PaginatedResult; @@ -36,13 +41,13 @@ import io.ably.lib.util.CollectionUtils; import io.ably.lib.util.EventEmitter; import io.ably.lib.util.Log; +import io.ably.lib.util.ReconnectionStrategy; +import io.ably.lib.util.StringUtils; +import org.jetbrains.annotations.Nullable; /** - * A class representing a Channel belonging to this application. - * The Channel instance allows messages to be published and - * received, and controls the lifecycle of this instance's - * attachment to the channel. - * + * Enables messages to be published and subscribed to. + * Also enables historic messages to be retrieved and provides access to the {@link Presence} object of a channel. */ public abstract class ChannelBase extends EventEmitter { @@ -51,36 +56,81 @@ public abstract class ChannelBase extends EventEmitter + * Spec: RTL9 */ public final Presence presence; /** - * The current channel state. + * The current {@link ChannelState} of the channel. + *

+ * Spec: RTL2b */ public ChannelState state; /** - * Error information associated with a failed channel state. + * An {@link ErrorInfo} object describing the last error which occurred on the channel, if any. + *

+ * Spec: RTL4e */ public ErrorInfo reason; /** - * Properties of Channel + * A {@link ChannelProperties} object. + *

+ * Spec: CP1, RTL15 */ public ChannelProperties properties = new ChannelProperties(); + private int retryAttempt = 0; + + /** + * @see #markAsReleased() + */ + private boolean released = false; + + @Nullable private final ObjectsPlugin objectsPlugin; + + public RealtimeObjects getObjects() throws AblyException { + if (objectsPlugin == null) { + throw AblyException.fromErrorInfo( + new ErrorInfo("LiveObjects plugin hasn't been installed, " + + "add runtimeOnly('io.ably:live-objects:') to your dependency tree", 400, 40019) + ); + } + return objectsPlugin.getInstance(name); + } + + public final RealtimeAnnotations annotations; + /*** * internal * */ + private static class AttachRequest{ + final boolean forceReattach; + final CompletionListener completionListener; + + private AttachRequest(boolean forceReattach, CompletionListener completionListener) { + this.forceReattach = forceReattach; + this.completionListener = completionListener; + } + } + private static class DetachRequest{ + final CompletionListener completionListener; + private DetachRequest(CompletionListener completionListener) { + this.completionListener = completionListener; + } + } + private AttachRequest pendingAttachRequest; + private DetachRequest pendingDetachRequest; + private void setState(ChannelState newState, ErrorInfo reason) { setState(newState, reason, false, true); } @@ -96,10 +146,41 @@ private void setState(ChannelState newState, ErrorInfo reason, boolean resumed, this.reason = stateChange.reason; } + // cover states other than attached, ChannelState.attached already covered in setAttached + if (objectsPlugin != null && newState!= ChannelState.attached) { + try { + objectsPlugin.handleStateChange(name, newState, false); + } catch (Throwable t) { + Log.e(TAG, "Unexpected exception in objectsPlugin.handle", t); + } + } + + if (newState != ChannelState.attaching && newState != ChannelState.suspended) { + this.retryAttempt = 0; + } + + // RTP5a1 + if (newState == ChannelState.detached || newState == ChannelState.suspended || newState == ChannelState.failed) { + properties.channelSerial = null; + } + if(notifyStateChange) { /* broadcast state change */ emit(newState, stateChange); } + if (newState == ChannelState.detached && pendingAttachRequest != null){ + Log.v(TAG, "Pending attach request after detach- now reattaching channel:"+name); + attach(pendingAttachRequest.forceReattach, pendingAttachRequest.completionListener); + pendingAttachRequest = null; + }else if (newState == ChannelState.attached && pendingDetachRequest != null){ + Log.v(TAG, "Pending detach request after attach. Now detaching channel:"+name); + try { + detach(pendingDetachRequest.completionListener); + pendingDetachRequest = null; + } catch (AblyException e) { + Log.e(TAG,"Channel failed to detach after attach:"+name,e); + } + } } /************************************ @@ -107,12 +188,14 @@ private void setState(ChannelState newState, ErrorInfo reason, boolean resumed, ************************************/ /** - * Attach to this channel. - * This call initiates the attach request, and the response - * is indicated asynchronously in the resulting state change. - * attach() is called implicitly when publishing or subscribing - * on this channel, so it is not usually necessary for a client - * to call attach() explicitly. + * Attach to this channel ensuring the channel is created in the Ably system and all messages published + * on the channel are received by any channel listeners registered using {@link Channel#subscribe}. + * Any resulting channel state change will be emitted to any listeners registered using the + * {@link EventEmitter#on} or {@link EventEmitter#once} methods. + * As a convenience, attach() is called implicitly if {@link Channel#subscribe} for the channel is called, + * or {@link Presence#enter} or {@link Presence#subscribe} are called on the {@link Presence} object for this channel. + *

+ * Spec: RTL4d * @throws AblyException */ public void attach() throws AblyException { @@ -120,41 +203,66 @@ public void attach() throws AblyException { } /** - * Attach to this channel. - * This call initiates the attach request, and the response - * is indicated asynchronously in the resulting state change. - * attach() is called implicitly when publishing or subscribing - * on this channel, so it is not usually necessary for a client - * to call attach() explicitly. - * - * @param listener When the channel is attached successfully or the attach fails and - * the ErrorInfo error is passed as an argument to the callback + * Attach to this channel ensuring the channel is created in the Ably system and all messages published + * on the channel are received by any channel listeners registered using {@link Channel#subscribe}. + * Any resulting channel state change will be emitted to any listeners registered using the + * {@link EventEmitter#on} or {@link EventEmitter#once} methods. + * As a convenience, attach() is called implicitly if {@link Channel#subscribe} for the channel is called, + * or {@link Presence#enter} or {@link Presence#subscribe} are called on the {@link Presence} object for this channel. + *

+ * Spec: RTL4d + * @param listener A callback may optionally be passed in to this call to be notified of success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void attach(CompletionListener listener) throws AblyException { this.attach(false, listener); } - private void attach(boolean forceReattach, CompletionListener listener) { + void attach(boolean forceReattach, CompletionListener listener) { clearAttachTimers(); - attachWithTimeout(forceReattach, listener); + attachWithTimeout(forceReattach, listener, null); + } + + /** + * This method carries queued messages accumulated on connection manager while the channel + * isn't attached yet. It's added in the queue here + * */ + synchronized void transferQueuedPresenceMessages(List messagesToTransfer) { + state = ChannelState.attaching; + if (messagesToTransfer != null) { + for (QueuedMessage queuedMessage : messagesToTransfer) { + PresenceMessage[] presenceMessages = queuedMessage.msg.presence; + if (presenceMessages != null && presenceMessages.length > 0) { + for (PresenceMessage presenceMessage : presenceMessages) { + this.presence.addPendingPresence(presenceMessage, queuedMessage.listener); + } + } + } + } } private boolean attachResume; - private void attachImpl(final boolean forceReattach, final CompletionListener listener) throws AblyException { + private void attachImpl(final boolean forceReattach, final CompletionListener listener, ErrorInfo reattachmentReason) throws AblyException { Log.v(TAG, "attach(); channel = " + name); if(!forceReattach) { /* check preconditions */ switch(state) { - case attaching: + case attaching: //RTL4h if(listener != null) { on(new ChannelStateCompletionListener(listener, ChannelState.attached, ChannelState.failed)); } return; - case attached: + case detaching: //RTL4h + pendingAttachRequest = new AttachRequest(forceReattach,listener); + return; + case attached: //RTL4a callCompletionListenerSuccess(listener); return; + case failed: //RTL4g + this.reason = null; default: } } @@ -163,6 +271,16 @@ private void attachImpl(final boolean forceReattach, final CompletionListener li throw AblyException.fromErrorInfo(connectionManager.getStateErrorInfo()); } + // (RTL4i) + ConnectionState connState = connectionManager.getConnectionState().state; + if (connState == ConnectionState.connecting || connState == ConnectionState.disconnected) { + if (listener != null) { + on(new ChannelStateCompletionListener(listener, ChannelState.attached, ChannelState.failed)); + } + setState(ChannelState.attaching, reattachmentReason); + return; + } + /* send attach request and pending state */ Log.v(TAG, "attach(); channel = " + name + "; sending ATTACH request"); ProtocolMessage attachMessage = new ProtocolMessage(Action.attach, this.name); @@ -174,7 +292,9 @@ private void attachImpl(final boolean forceReattach, final CompletionListener li attachMessage.setFlags(options.getModeFlags()); } } - if(this.decodeFailureRecoveryInProgress) { + attachMessage.channelSerial = properties.channelSerial; // RTL4c1 + if(this.decodeFailureRecoveryInProgress) { // RTL18c + Log.v(TAG, "attach(); message decode recovery in progress, setting last message channelserial"); attachMessage.channelSerial = this.lastPayloadProtocolMessageChannelSerial; } try { @@ -185,7 +305,7 @@ private void attachImpl(final boolean forceReattach, final CompletionListener li attachMessage.setFlag(Flag.attach_resume); } - setState(ChannelState.attaching, null); + setState(ChannelState.attaching, reattachmentReason); connectionManager.send(attachMessage, true, null); } catch(AblyException e) { throw e; @@ -194,18 +314,34 @@ private void attachImpl(final boolean forceReattach, final CompletionListener li /** * Detach from this channel. - * This call initiates the detach request, and the response - * is indicated asynchronously in the resulting state change. + * Any resulting channel state change is emitted to any listeners registered using the + * {@link EventEmitter#on} or {@link EventEmitter#once} methods. + * Once all clients globally have detached from the channel, the channel will be released in the Ably service within two minutes. + *

+ * Spec: RTL5e * @throws AblyException */ public void detach() throws AblyException { detach(null); } + /** + * Mark channel as released that means we can't perform any operation on this channel anymore + */ + public synchronized void markAsReleased() { + released = true; + } + /** * Detach from this channel. - * This call initiates the detach request, and the response - * is indicated asynchronously in the resulting state change. + * Any resulting channel state change is emitted to any listeners registered using the + * {@link EventEmitter#on} or {@link EventEmitter#once} methods. + * Once all clients globally have detached from the channel, the channel will be released in the Ably service within two minutes. + *

+ * Spec: RTL5e + * @param listener A callback may optionally be passed in to this call to be notified of success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void detach(CompletionListener listener) throws AblyException { @@ -217,23 +353,39 @@ private void detachImpl(CompletionListener listener) throws AblyException { Log.v(TAG, "detach(); channel = " + name); /* check preconditions */ switch(state) { - case initialized: + case initialized: // RTL5a case detached: { callCompletionListenerSuccess(listener); return; } - case detaching: + case detaching: //RTL5i if (listener != null) { on(new ChannelStateCompletionListener(listener, ChannelState.detached, ChannelState.failed)); } return; + case attaching: //RTL5i + pendingDetachRequest = new DetachRequest(listener); + return; + case failed: //RTL5b + ErrorInfo error = this.reason != null ? + this.reason : new ErrorInfo("Channel state is failed", 90000); + callCompletionListenerError(listener, error); + return; + case suspended: //RTL5j + setState(ChannelState.detached, null); + callCompletionListenerSuccess(listener); + return; default: } ConnectionManager connectionManager = ably.connection.connectionManager; - if(!connectionManager.isActive()) + if(!connectionManager.isActive()) { // RTL5g throw AblyException.fromErrorInfo(connectionManager.getStateErrorInfo()); + } - /* send detach request */ + sendDetachMessage(listener); + } + + private void sendDetachMessage(CompletionListener listener) throws AblyException { ProtocolMessage detachMessage = new ProtocolMessage(Action.detach, this.name); try { if (listener != null) { @@ -241,33 +393,17 @@ private void detachImpl(CompletionListener listener) throws AblyException { } this.attachResume = false; - setState(ChannelState.detaching, null); - connectionManager.send(detachMessage, true, null); + if (released) { + setDetached(null); + } else { + setState(ChannelState.detaching, null); + } + ably.connection.connectionManager.send(detachMessage, true, null); } catch(AblyException e) { throw e; } } - public void sync() throws AblyException { - Log.v(TAG, "sync(); channel = " + name); - /* check preconditions */ - switch(state) { - case initialized: - case detaching: - case detached: - throw AblyException.fromErrorInfo(new ErrorInfo("Unable to sync to channel; not attached", 40000)); - default: - } - ConnectionManager connectionManager = ably.connection.connectionManager; - if(!connectionManager.isActive()) - throw AblyException.fromErrorInfo(connectionManager.getStateErrorInfo()); - - /* send sync request */ - ProtocolMessage syncMessage = new ProtocolMessage(Action.sync, this.name); - syncMessage.channelSerial = syncChannelSerial; - connectionManager.send(syncMessage, true, null); - } - /*** * internal * @@ -282,6 +418,11 @@ private static void callCompletionListenerSuccess(CompletionListener listener) { } } + @Deprecated + public void sync() throws AblyException { + Log.w(TAG, "sync() method is intended only for internal testing purpose as per RTP19"); + } + private static void callCompletionListenerError(CompletionListener listener, ErrorInfo err) { if(listener != null) { try { @@ -294,38 +435,53 @@ private static void callCompletionListenerError(CompletionListener listener, Err private void setAttached(ProtocolMessage message) { clearAttachTimers(); - boolean resumed = message.hasFlag(Flag.resumed); - Log.v(TAG, "setAttached(); channel = " + name + ", resumed = " + resumed); properties.attachSerial = message.channelSerial; params = message.params; modes = ChannelMode.toSet(message.flags); + this.attachResume = true; + + if (state == ChannelState.detaching || state == ChannelState.detached) { //RTL5k + Log.v(TAG, "setAttached(): channel is in detaching state, as per RTL5k sending detach message!"); + try { + sendDetachMessage(null); + } catch (AblyException e) { + Log.e(TAG, e.getMessage(), e); + } + return; + } + if (objectsPlugin != null) { + try { + objectsPlugin.handleStateChange(name, ChannelState.attached, message.hasFlag(Flag.has_objects)); + } catch (Throwable t) { + Log.e(TAG, "Unexpected exception in objectsPlugin.handle", t); + } + } if(state == ChannelState.attached) { - Log.v(TAG, String.format("Server initiated attach for channel %s", name)); - /* emit UPDATE event according to RTL12 */ - emitUpdate(null, resumed); - } else { - this.attachResume = true; - setState(ChannelState.attached, message.error, resumed); - sendQueuedMessages(); - presence.setAttached(message.hasFlag(Flag.has_presence)); + Log.v(TAG, String.format(Locale.ROOT, "Server initiated attach for channel %s", name)); + if (!message.hasFlag(Flag.resumed)) { // RTL12 + presence.onAttached(message.hasFlag(Flag.has_presence)); + emitUpdate(message.error, false); + } + } + else { + presence.onAttached(message.hasFlag(Flag.has_presence)); + setState(ChannelState.attached, message.error, message.hasFlag(Flag.resumed)); } } private void setDetached(ErrorInfo reason) { clearAttachTimers(); Log.v(TAG, "setDetached(); channel = " + name); - presence.setDetached(reason); + presence.onChannelDetachedOrFailed(reason); setState(ChannelState.detached, reason); - failQueuedMessages(reason); } private void setFailed(ErrorInfo reason) { clearAttachTimers(); Log.v(TAG, "setFailed(); channel = " + name); - presence.setDetached(reason); + presence.onChannelDetachedOrFailed(reason); this.attachResume = false; setState(ChannelState.failed, reason); - failQueuedMessages(reason); } /* Timer for attach operation */ @@ -349,14 +505,15 @@ synchronized private void clearAttachTimers() { } private void attachWithTimeout(final CompletionListener listener) throws AblyException { - this.attachWithTimeout(false, listener); + this.attachWithTimeout(false, listener, null); } /** * Attach channel, if not attached within timeout set state to suspended and * set up timer to reattach it later */ - synchronized private void attachWithTimeout(final boolean forceReattach, final CompletionListener listener) { + synchronized private void attachWithTimeout(final boolean forceReattach, final CompletionListener listener, ErrorInfo reattachmentReason) { + checkChannelIsNotReleased(); Timer currentAttachTimer; try { currentAttachTimer = new Timer(); @@ -380,7 +537,7 @@ public void onError(ErrorInfo reason) { clearAttachTimers(); callCompletionListenerError(listener, reason); } - }); + }, reattachmentReason); } catch(AblyException e) { attachTimer = null; callCompletionListenerError(listener, e.errorInfo); @@ -396,7 +553,7 @@ public void onError(ErrorInfo reason) { new TimerTask() { @Override public void run() { - String errorMessage = String.format("Attach timed out for channel %s", name); + String errorMessage = String.format(Locale.ROOT, "Attach timed out for channel %s", name); Log.v(TAG, errorMessage); synchronized (ChannelBase.this) { if(attachTimer != inProgressTimer) { @@ -404,7 +561,7 @@ public void run() { } attachTimer = null; if(state == ChannelState.attaching) { - setSuspended(new ErrorInfo(errorMessage, 91200), true); + setSuspended(new ErrorInfo(errorMessage, 90007), true); reattachAfterTimeout(); } } @@ -412,6 +569,10 @@ public void run() { }, Defaults.realtimeRequestTimeout); } + private void checkChannelIsNotReleased() { + if (released) throw new IllegalStateException("Unable to perform any operation on released channel"); + } + /** * Must be called in suspended state. Wait for timeout specified in clientOptions, and then * try to attach the channel @@ -426,6 +587,9 @@ synchronized private void reattachAfterTimeout() { } reattachTimer = currentReattachTimer; + this.retryAttempt++; + int retryDelay = ReconnectionStrategy.getRetryTime(ably.options.channelRetryTimeout, retryAttempt); + final Timer inProgressTimer = currentReattachTimer; reattachTimer.schedule(new TimerTask() { @Override @@ -444,7 +608,7 @@ public void run() { } } } - }, ably.options.channelRetryTimeout); + }, retryDelay); } /** @@ -461,10 +625,11 @@ synchronized private void detachWithTimeout(final CompletionListener listener) { callCompletionListenerError(listener, ErrorInfo.fromThrowable(t)); return; } - attachTimer = currentDetachTimer; + attachTimer = released ? null : currentDetachTimer; try { - detachImpl(new CompletionListener() { + // If channel has been released, completionListener won't be invoked anyway + CompletionListener completionListener = released ? null : new CompletionListener() { @Override public void onSuccess() { clearAttachTimers(); @@ -476,9 +641,11 @@ public void onError(ErrorInfo reason) { clearAttachTimers(); callCompletionListenerError(listener, reason); } - }); + }; + detachImpl(completionListener); } catch (AblyException e) { attachTimer = null; + callCompletionListenerError(listener, e.errorInfo); // RTL5g } if(attachTimer == null) { @@ -506,25 +673,10 @@ public void run() { } /* State changes provoked by ConnectionManager state changes. */ - public void setConnected() { - if(state == ChannelState.attached) { - try { - sync(); - } catch (AblyException e) { - Log.e(TAG, "setConnected(): Unable to sync; channel = " + name, e); - } - } else if (state == ChannelState.suspended) { - /* (RTL3d) If the connection state enters the CONNECTED state, then - * a SUSPENDED channel will initiate an attach operation. If the - * attach operation for the channel times out and the channel - * returns to the SUSPENDED state (see #RTL4f) - */ - try { - attachWithTimeout(null); - } catch (AblyException e) { - Log.e(TAG, "setConnected(): Unable to initiate attach; channel = " + name, e); - } + // TODO - seems test is failing because of explicit attach after connect + if (state.isReattachable()){ + attach(true,null); // RTN15c6, RTN15c7 } } @@ -558,12 +710,21 @@ public synchronized void setSuspended(ErrorInfo reason, boolean notifyStateChang clearAttachTimers(); if (state == ChannelState.attached || state == ChannelState.attaching) { Log.v(TAG, "setSuspended(); channel = " + name); - presence.setSuspended(reason); + presence.onChannelSuspended(reason); setState(ChannelState.suspended, reason, false, notifyStateChange); - failQueuedMessages(reason); } } + /** + * Internal + *

+ * (RTN11d) Resets channels back to initialized and clears error reason + */ + public synchronized void setReinitialized() { + clearAttachTimers(); + setState(ChannelState.initialized, null); + } + @Override protected void apply(ChannelStateListener listener, ChannelEvent event, Object... args) { try { @@ -587,12 +748,10 @@ public interface MessageListener { } /** + * Deregisters all listeners to messages on this channel. + * This removes all earlier subscriptions. *

- * Unsubscribe all subscribed listeners from this channel. - *

- *

- * Spec: RTL8a - *

+ * Spec: RTL8a, RTE5 */ public synchronized void unsubscribe() { Log.v(TAG, "unsubscribe(); channel = " + this.name); @@ -601,20 +760,43 @@ public synchronized void unsubscribe() { } /** - * Subscribe for messages on this channel. This implicitly attaches the channel if - * not already attached. - * @param listener the MessageListener + *

+ * Checks if {@link io.ably.lib.types.ChannelOptions#attachOnSubscribe} is true. + *

+ * Defaults to {@code true} when {@link io.ably.lib.realtime.ChannelBase#options} is null. + *

Spec: TB4, RTL7g, RTL7h, RTP6d, RTP6e

+ */ + protected boolean attachOnSubscribeEnabled() { + return options == null || options.attachOnSubscribe; + } + + /** + * Registers a listener for messages on this channel. + * The caller supplies a listener function, which is called each time one or more messages arrives on the channel. + *

+ * Spec: RTL7a + * @param listener A listener may optionally be passed in to this call to be notified of success or failure + * of the channel {@link Channel#attach} operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public synchronized void subscribe(MessageListener listener) throws AblyException { Log.v(TAG, "subscribe(); channel = " + this.name); listeners.add(listener); - attach(); + if (attachOnSubscribeEnabled()) { + attach(); + } } /** - * Unsubscribe a previously subscribed listener from this channel. - * @param listener the previously subscribed listener. + * Deregisters the given listener (for any/all event names). + * This removes an earlier subscription. + *

+ * Spec: RTL8a + * @param listener An event listener function. + *

+ * This listener is invoked on a background thread. */ public synchronized void unsubscribe(MessageListener listener) { Log.v(TAG, "unsubscribe(); channel = " + this.name); @@ -625,22 +807,34 @@ public synchronized void unsubscribe(MessageListener listener) { } /** - * Subscribe for messages with a specific event name on this channel. - * This implicitly attaches the channel if not already attached. - * @param name the event name - * @param listener the MessageListener + * Registers a listener for messages with a given event name on this channel. + * The caller supplies a listener function, which is called each time one or more matching messages arrives on the channel. + *

+ * Spec: RTL7b + * @param name The event name. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure + * of the channel {@link Channel#attach} operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public synchronized void subscribe(String name, MessageListener listener) throws AblyException { Log.v(TAG, "subscribe(); channel = " + this.name + "; event = " + name); subscribeImpl(name, listener); - attach(); + if (attachOnSubscribeEnabled()) { + attach(); + } } /** - * Unsubscribe a previously subscribed event listener from this channel. - * @param name the event name - * @param listener the previously subscribed listener. + * Deregisters the given listener for the specified event name. + * This removes an earlier event-specific subscription + *

+ * Spec: RTL8a + * @param name The event name. + * @param listener An event listener function. + *

+ * This listener is invoked on a background thread. */ public synchronized void unsubscribe(String name, MessageListener listener) { Log.v(TAG, "unsubscribe(); channel = " + this.name + "; event = " + name); @@ -648,23 +842,34 @@ public synchronized void unsubscribe(String name, MessageListener listener) { } /** - * Subscribe for messages with an array of event names on this channel. - * This implicitly attaches the channel if not already attached. - * @param names the event names - * @param listener the MessageListener + * Registers a listener for messages on this channel for multiple event name values. + * The caller supplies a listener function, which is called each time one or more matching messages arrives on the channel. + *

+ * Spec: RTL7a + * @param names An array of event names. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure + * of the channel {@link Channel#attach} operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public synchronized void subscribe(String[] names, MessageListener listener) throws AblyException { Log.v(TAG, "subscribe(); channel = " + this.name + "; (multiple events)"); for(String name : names) subscribeImpl(name, listener); - attach(); + if (attachOnSubscribeEnabled()) { + attach(); + } } /** - * Unsubscribe a previously subscribed event listener from this channel. - * @param names the event names - * @param listener the previously subscribed listener. + * Deregisters the given listener from all event names in the array. + *

+ * Spec: RTL8a + * @param names An array of event names. + * @param listener An event listener function. + *

+ * This listener is invoked on a background thread. */ public synchronized void unsubscribe(String[] names, MessageListener listener) { Log.v(TAG, "unsubscribe(); channel = " + this.name + "; (multiple events)"); @@ -684,7 +889,7 @@ private void onMessage(final ProtocolMessage protocolMessage) { final DeltaExtras deltaExtras = (null == firstMessage.extras) ? null : firstMessage.extras.getDelta(); if (null != deltaExtras && !deltaExtras.getFrom().equals(this.lastPayloadMessageId)) { - Log.e(TAG, String.format("Delta message decode failure - previous message not available. Message id = %s, channel = %s", firstMessage.id, name)); + Log.e(TAG, String.format(Locale.ROOT, "Delta message decode failure - previous message not available. Message id = %s, channel = %s", firstMessage.id, name)); startDecodeFailureRecovery(); return; } @@ -696,25 +901,29 @@ private void onMessage(final ProtocolMessage protocolMessage) { if(msg.connectionId == null) msg.connectionId = protocolMessage.connectionId; if(msg.timestamp == 0) msg.timestamp = protocolMessage.timestamp; if(msg.id == null) msg.id = protocolMessage.id + ':' + i; + // (TM2k) + if(msg.serial == null && msg.version != null && msg.action == MessageAction.MESSAGE_CREATE) msg.serial = msg.version; + // (TM2o) + if(msg.createdAt == null && msg.action == MessageAction.MESSAGE_CREATE) msg.createdAt = msg.timestamp; try { - msg.decode(options, decodingContext); + if (msg.data != null) msg.decode(options, decodingContext); } catch (MessageDecodeException e) { if (e.errorInfo.code == 40018) { - Log.e(TAG, String.format("Delta message decode failure - %s. Message id = %s, channel = %s", e.errorInfo.message, msg.id, name)); + Log.e(TAG, String.format(Locale.ROOT, "Delta message decode failure - %s. Message id = %s, channel = %s", e.errorInfo.message, msg.id, name)); startDecodeFailureRecovery(); // log messages skipped per RTL16 for (int j = i + 1; j < messages.length; j++) { final String jId = messages[j].id; // might be null final String jIdToLog = (null == jId) ? protocolMessage.id + ':' + j : jId; - Log.v(TAG, String.format("Delta recovery in progress - message skipped. Message id = %s, channel = %s", jIdToLog, name)); + Log.v(TAG, String.format(Locale.ROOT, "Delta recovery in progress - message skipped. Message id = %s, channel = %s", jIdToLog, name)); } return; } else { - Log.e(TAG, String.format("Message decode failure - %s. Message id = %s, channel = %s", e.errorInfo.message, msg.id, name)); + Log.e(TAG, String.format(Locale.ROOT, "Message decode failure - %s. Message id = %s, channel = %s", e.errorInfo.message, msg.id, name)); } } @@ -751,37 +960,13 @@ public void onError(ErrorInfo reason) { }); } - private void onPresence(ProtocolMessage message, String syncChannelSerial) { - Log.v(TAG, "onPresence(); channel = " + name + "; syncChannelSerial = " + syncChannelSerial); - PresenceMessage[] messages = message.presence; - for(int i = 0; i < messages.length; i++) { - PresenceMessage msg = messages[i]; - try { - msg.decode(options); - } catch (MessageDecodeException e) { - Log.e(TAG, String.format("%s on channel %s", e.errorInfo.message, name)); - } - /* populate fields derived from protocol message */ - if(msg.connectionId == null) msg.connectionId = message.connectionId; - if(msg.timestamp == 0) msg.timestamp = message.timestamp; - if(msg.id == null) msg.id = message.id + ':' + i; - } - presence.setPresence(messages, true, syncChannelSerial); - } - - private void onSync(ProtocolMessage message) { - Log.v(TAG, "onSync(); channel = " + name); - if(message.presence != null) - onPresence(message, (syncChannelSerial = message.channelSerial)); - } - private MessageMulticaster listeners = new MessageMulticaster(); private HashMap eventListeners = new HashMap(); private static class MessageMulticaster extends io.ably.lib.util.Multicaster implements MessageListener { @Override public void onMessage(Message message) { - for(MessageListener member : members) + for (final MessageListener member : getMembers()) try { member.onMessage(message); } catch (Throwable t) { @@ -813,8 +998,12 @@ private void unsubscribeImpl(String name, MessageListener listener) { ************************************/ /** - * Publish a message on this channel. This implicitly attaches the channel if - * not already attached. + * Publishes a single message to the channel with the given event name and payload. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel, + * so long as transient publishing is available in the library. + * Otherwise, the client will implicitly attach. + *

+ * Spec: RTL6i * @param name the event name * @param data the message payload * @throws AblyException @@ -824,9 +1013,11 @@ public void publish(String name, Object data) throws AblyException { } /** - * Publish a message on this channel. This implicitly attaches the channel if - * not already attached. - * @param message the message + * Publishes a message to the channel. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel. + *

+ * Spec: RTL6i + * @param message A {@link Message} object. * @throws AblyException */ public void publish(Message message) throws AblyException { @@ -834,9 +1025,11 @@ public void publish(Message message) throws AblyException { } /** - * Publish an array of messages on this channel. This implicitly attaches the channel if - * not already attached. - * @param messages the message + * Publishes an array of messages to the channel. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel. + *

+ * Spec: RTL6i + * @param messages An array of {@link Message} objects. * @throws AblyException */ public void publish(Message[] messages) throws AblyException { @@ -844,11 +1037,17 @@ public void publish(Message[] messages) throws AblyException { } /** - * Publish a message on this channel. This implicitly attaches the channel if - * not already attached. + * Publishes a single message to the channel with the given event name and payload. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel, + * so long as transient publishing is available in the library. + * Otherwise, the client will implicitly attach. + *

+ * Spec: RTL6i * @param name the event name - * @param data the message payload. See {@link io.ably.types.Data} for supported datatypes - * @param listener a listener to be notified of the outcome of this message. + * @param data the message payload + * @param listener A listener may optionally be passed in to this call to be notified of success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void publish(String name, Object data, CompletionListener listener) throws AblyException { @@ -857,10 +1056,14 @@ public void publish(String name, Object data, CompletionListener listener) throw } /** - * Publish a message on this channel. This implicitly attaches the channel if - * not already attached. - * @param message the message - * @param listener a listener to be notified of the outcome of this message. + * Publishes a message to the channel. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel. + *

+ * Spec: RTL6i + * @param message A {@link Message} object. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void publish(Message message, CompletionListener listener) throws AblyException { @@ -869,10 +1072,14 @@ public void publish(Message message, CompletionListener listener) throws AblyExc } /** - * Publish an array of messages on this channel. This implicitly attaches the channel if - * not already attached. - * @param messages the message - * @param listener a listener to be notified of the outcome of this message. + * Publishes an array of messages to the channel. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel. + *

+ * Spec: RTL6i + * @param messages An array of {@link Message} objects. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public synchronized void publish(Message[] messages, CompletionListener listener) throws AblyException { @@ -920,46 +1127,6 @@ private static class FailedMessage { } } - private void sendQueuedMessages() { - Log.v(TAG, "sendQueuedMessages()"); - ArrayList failedMessages = new ArrayList<>(); - synchronized (this) { - boolean queueMessages = ably.options.queueMessages; - ConnectionManager connectionManager = ably.connection.connectionManager; - for (QueuedMessage msg : queuedMessages) - try { - connectionManager.send(msg.msg, queueMessages, msg.listener); - } catch (AblyException e) { - Log.e(TAG, "sendQueuedMessages(): Unexpected exception sending message", e); - if (msg.listener != null) - failedMessages.add(new FailedMessage(msg, e.errorInfo)); - } - queuedMessages.clear(); - } - - /* Call completion callbacks for failed messages without holding the lock */ - for (FailedMessage failed: failedMessages) { - callCompletionListenerError(failed.msg.listener, failed.reason); - } - } - - private void failQueuedMessages(ErrorInfo reason) { - Log.v(TAG, "failQueuedMessages()"); - - ArrayList failedMessages = new ArrayList<>(); - synchronized (this) { - for (QueuedMessage msg: queuedMessages) { - if (msg.listener != null) - failedMessages.add(new FailedMessage(msg, reason)); - } - queuedMessages.clear(); - } - - for(FailedMessage failed : failedMessages) { - callCompletionListenerError(failed.msg.listener, failed.reason); - } - } - static Param[] replacePlaceholderParams(Channel channel, Param[] placeholderParams) throws AblyException { if (placeholderParams == null) { return null; @@ -995,30 +1162,78 @@ else if(!"false".equalsIgnoreCase(param.value)) { private static final String KEY_UNTIL_ATTACH = "untilAttach"; private static final String KEY_FROM_SERIAL = "fromSerial"; - private List queuedMessages; /************************************ * Channel history ************************************/ /** - * Obtain recent history for this channel using the REST API. - * The history provided relqtes to all clients of this application, - * not just this instance. - * @param params the request params. See the Ably REST API - * documentation for more details. - * @return an array of Messgaes for this Channel. + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link Message} objects for the channel. + * If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. + * If not, messages can only be retrieved from history for up to two minutes in the past. + *

+ * Spec: RSL2a + * @param params the request params: + *

+ * start (RTL10a) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RTL10a) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RTL10a) - The order for which messages are returned in. + * Valid values are backwards which orders messages from most recent to oldest, + * or forwards which orders messages from oldest to most recent. The default is backwards. + *

+ * limit (RTL10a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + *

+ * untilAttach (RTL10b) - When true, ensures message history is up until the point of the channel being attached. + * See continuous history for more info. + * Requires the direction to be backwards. + * If the channel is not attached, or if direction is set to forwards, this option results in an error. + * @return A {@link PaginatedResult} object containing an array of {@link Message} objects. * @throws AblyException */ public PaginatedResult history(Param[] params) throws AblyException { - return historyImpl(params).sync(); + return historyImpl(ably.http, params).sync(); + } + + PaginatedResult history(Http http, Param[] params) throws AblyException { + return historyImpl(http, params).sync(); } + /** + * Asynchronously retrieves a {@link PaginatedResult} object, containing an array of historical {@link Message} objects for the channel. + * If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. + * If not, messages can only be retrieved from history for up to two minutes in the past. + *

+ * Spec: RSL2a + * @param params the request params: + *

+ * start (RTL10a) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RTL10a) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RTL10a) - The order for which messages are returned in. + * Valid values are backwards which orders messages from most recent to oldest, + * or forwards which orders messages from oldest to most recent. The default is backwards. + *

+ * limit (RTL10a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + *

+ * untilAttach (RTL10b) - When true, ensures message history is up until the point of the channel being attached. + * See continuous history for more info. + * Requires the direction to be backwards. + * If the channel is not attached, or if direction is set to forwards, this option results in an error. + * @param callback Callback with {@link AsyncPaginatedResult} object containing an array of {@link Message} objects. + * @throws AblyException + */ public void historyAsync(Param[] params, Callback> callback) { - historyImpl(params).async(callback); + historyAsync(ably.http, params, callback); + } + + void historyAsync(Http http, Param[] params, Callback> callback) { + historyImpl(http, params).async(callback); } - private BasePaginatedQuery.ResultRequest historyImpl(Param[] params) { + private BasePaginatedQuery.ResultRequest historyImpl(Http http, Param[] params) { try { params = replacePlaceholderParams((Channel) this, params); } catch (AblyException e) { @@ -1026,17 +1241,32 @@ private BasePaginatedQuery.ResultRequest historyImpl(Param[] params) { } HttpCore.BodyHandler bodyHandler = MessageSerializer.getMessageResponseHandler(options); - return new BasePaginatedQuery(ably.http, basePath + "/history", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler).get(); + return new BasePaginatedQuery(http, basePath + "/history", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler).get(); } /************************************ * Channel options ************************************/ + /** + * Sets the {@link ChannelOptions} for the channel. + *

+ * Spec: RTL16 + * @param options A {@link ChannelOptions} object. + * @throws AblyException + */ public void setOptions(ChannelOptions options) throws AblyException { this.setOptions(options, null); } + /** + * Sets the {@link ChannelOptions} for the channel. + *

+ * Spec: RTL16 + * @param options A {@link ChannelOptions} object. + * @param listener An optional listener may be provided to notify of the success or failure of the operation. + * @throws AblyException + */ public void setOptions(ChannelOptions options, CompletionListener listener) throws AblyException { this.options = options; if(this.shouldReattachToSetOptions(options)) { @@ -1057,9 +1287,16 @@ public Map getParams() { } public ChannelMode[] getModes() { + if (modes == null) { + return new ChannelMode[0]; + } return modes.toArray(new ChannelMode[modes.size()]); } + public ChannelOptions getOptions() { + return options; + } + /************************************ * internal general * @throws AblyException @@ -1089,7 +1326,7 @@ else if(stateChange.current.equals(failureState)) { } } - ChannelBase(AblyRealtime ably, String name, ChannelOptions options) throws AblyException { + ChannelBase(AblyRealtime ably, String name, ChannelOptions options, @Nullable ObjectsPlugin objectsPlugin) throws AblyException { Log.v(TAG, "RealtimeChannel(); channel = " + name); this.ably = ably; this.name = name; @@ -1098,11 +1335,27 @@ else if(stateChange.current.equals(failureState)) { this.presence = new Presence((Channel) this); this.attachResume = false; state = ChannelState.initialized; - queuedMessages = new ArrayList(); this.decodingContext = new DecodingContext(); + this.objectsPlugin = objectsPlugin; + if (objectsPlugin != null) { + objectsPlugin.getInstance(name); // Make objects instance ready to process sync messages + } + this.annotations = new RealtimeAnnotations( + this, + new RestAnnotations(name, ably.http, ably.options, options) + ); } void onChannelMessage(ProtocolMessage msg) { + // RTL15b + if (!StringUtils.isNullOrEmpty(msg.channelSerial) && (msg.action == Action.message || + msg.action == Action.presence || msg.action == Action.attached)) { + Log.v(TAG, String.format( + Locale.ROOT, "Setting channel serial for channelName - %s, previous - %s, current - %s", + name, properties.channelSerial, msg.channelSerial)); + properties.channelSerial = msg.channelSerial; + } + switch(msg.action) { case attached: setAttached(msg); @@ -1111,21 +1364,16 @@ void onChannelMessage(ProtocolMessage msg) { case detached: ChannelState oldState = state; switch(oldState) { + // RTL13a case attached: - /* Unexpected detach, reattach when possible */ - setDetached((msg.error != null) ? msg.error : REASON_NOT_ATTACHED); - Log.v(TAG, String.format("Server initiated detach for channel %s; attempting reattach", name)); - try { - attachWithTimeout(null); - } catch (AblyException e) { - /* Send message error */ - Log.e(TAG, "Attempting reattach threw exception", e); - setDetached(e.errorInfo); - } + case suspended: + /* Unexpected detach, reattach immediately as per RTL13a */ + Log.v(TAG, String.format(Locale.ROOT, "Server initiated detach for channel %s; attempting reattach", name)); + attachWithTimeout(true, null, msg.error); break; case attaching: /* RTL13b says we need to be suspended, but continue to retry */ - Log.v(TAG, String.format("Server initiated detach for channel %s whilst attaching; moving to suspended", name)); + Log.v(TAG, String.format(Locale.ROOT, "Server initiated detach for channel %s whilst attaching; moving to suspended", name)); setSuspended(msg.error, true); reattachAfterTimeout(); break; @@ -1133,7 +1381,6 @@ void onChannelMessage(ProtocolMessage msg) { setDetached((msg.error != null) ? msg.error : REASON_NOT_ATTACHED); break; case detached: - case suspended: case failed: default: /* do nothing */ @@ -1154,15 +1401,18 @@ void onChannelMessage(ProtocolMessage msg) { } } break; - case presence: - onPresence(msg, null); - break; case sync: - onSync(msg); + presence.onSync(msg); + break; + case presence: + presence.onPresence(msg); break; case error: setFailed(msg.error); break; + case annotation: + annotations.onAnnotation(msg); + break; default: Log.e(TAG, "onChannelMessage(): Unexpected message action (" + msg.action + ")"); } @@ -1189,12 +1439,33 @@ public void once(ChannelState state, ChannelStateListener listener) { super.once(state.getChannelEvent(), listener); } + /** + * (Internal) Sends a protocol message and provides a callback for completion. + * + * @param protocolMessage the protocol message to be sent + * @param listener the listener to be notified upon completion of the message delivery + */ + public void sendProtocolMessage(ProtocolMessage protocolMessage, CompletionListener listener) throws AblyException { + ConnectionManager connectionManager = ably.connection.connectionManager; + connectionManager.send(protocolMessage, ably.options.queueMessages, listener); + } + private static final String TAG = Channel.class.getName(); final AblyRealtime ably; final String basePath; ChannelOptions options; - String syncChannelSerial; + /** + * Optional channel parameters + * that configure the behavior of the channel. + *

+ * Spec: RTL4k1 + */ private Map params; + /** + * An array of {@link ChannelMode} objects. + *

+ * Spec: RTL4m + */ private Set modes; private String lastPayloadMessageId; private String lastPayloadProtocolMessageChannelSerial; diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelEvent.java b/lib/src/main/java/io/ably/lib/realtime/ChannelEvent.java index 2da8c0745..4cce47e1d 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelEvent.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelEvent.java @@ -1,7 +1,8 @@ package io.ably.lib.realtime; /** - * Channel event + * Describes the events emitted by a {@link Channel} object. + * An event is either an UPDATE or a {@link ChannelState}. */ public enum ChannelEvent { initialized, @@ -11,5 +12,10 @@ public enum ChannelEvent { detached, failed, suspended, + /** + * An event for changes to channel conditions that do not result in a change in {@link ChannelState}. + *

+ * Spec: RTL2g + */ update } diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelState.java b/lib/src/main/java/io/ably/lib/realtime/ChannelState.java index 7cda2f5a5..202659e12 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelState.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelState.java @@ -1,15 +1,43 @@ package io.ably.lib.realtime; /** - * Channel states. See Ably Realtime API documentation for more details. + * Describes the possible states of a {@link Channel} object. */ public enum ChannelState { + /** + * The channel has been initialized but no attach has yet been attempted. + */ initialized(ChannelEvent.initialized), + /** + * An attach has been initiated by sending a request to Ably. + * This is a transient state, followed either by a transition to ATTACHED, SUSPENDED, or FAILED. + */ attaching(ChannelEvent.attaching), + /** + * The attach has succeeded. + * In the ATTACHED state a client may publish and subscribe to messages, or be present on the channel. + */ attached(ChannelEvent.attached), + /** + * A detach has been initiated on an ATTACHED channel by sending a request to Ably. + * This is a transient state, followed either by a transition to DETACHED or FAILED. + */ detaching(ChannelEvent.detaching), + /** + * The channel, having previously been ATTACHED, has been detached by the user. + */ detached(ChannelEvent.detached), + /** + * An indefinite failure condition. + * This state is entered if a channel error has been received from the Ably service, + * such as an attempt to attach without the necessary access rights. + */ failed(ChannelEvent.failed), + /** + * The channel, having previously been ATTACHED, has lost continuity, + * usually due to the client being disconnected from Ably for longer than two minutes. + * It will automatically attempt to reattach as soon as connectivity is restored. + */ suspended(ChannelEvent.suspended); final private ChannelEvent event; @@ -19,4 +47,9 @@ public enum ChannelState { public ChannelEvent getChannelEvent() { return event; } + + // RTN15c6, RTN15c7, RTL3d, RTN15g3 + public boolean isReattachable() { + return this == ChannelState.attaching || this == ChannelState.attached || this == ChannelState.suspended; + } } diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelStateListener.java b/lib/src/main/java/io/ably/lib/realtime/ChannelStateListener.java index 963703ebf..5dbb8ec48 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelStateListener.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelStateListener.java @@ -7,29 +7,47 @@ */ public interface ChannelStateListener { + /** + * Called when channel state changes. + * @param stateChange information about the new state. Check {@link ChannelState ChannelState} - for all states available. + */ void onChannelStateChanged(ChannelStateChange stateChange); /** - * Channel state change. See Ably Realtime API documentation for more details. + * Contains state change information emitted by {@link Channel} objects. */ class ChannelStateChange { + /** + * The event that triggered this{@link ChannelState} change. + *

+ * Spec: TH5 + */ final public ChannelEvent event; - /* (TH2) The ChannelStateChange object contains the current state in - * attribute current, the previous state in attribute previous. */ + /** + * The new current {@link ChannelState}. + *

+ * Spec: RTL2a, RTL2b + */ final public ChannelState current; + /** + * The previous state. + * For the {@link ChannelEvent#update} event, this is equal to the current {@link ChannelState}. + *

+ * Spec: RTL2a, RTL2b + */ final public ChannelState previous; - /* (TH3) If the channel state change includes error information, then - * the reason attribute will contain an ErrorInfo object describing the - * reason for the error. */ + /** + * An {@link ErrorInfo} object containing any information relating to the transition. + *

+ * Spec: RTL2e, TH3 + */ final public ErrorInfo reason; - /* (TH4) The ChannelStateChange object contains an attribute resumed which - * in combination with an ATTACHED state, indicates whether the channel - * attach successfully resumed its state following the connection being - * resumed or recovered. If resumed is true, then the attribute indicates - * that the attach within Ably successfully recovered the state for the - * channel, and as such there is no loss of message continuity. In all - * other cases, resumed is false, and may be accompanied with a "channel - * state change error reason". */ + /** + * Indicates whether message continuity on this channel is preserved, + * see Nonfatal channel errors for more info. + *

+ * Spec: RTL2f, TH4 + */ final public boolean resumed; ChannelStateChange(ChannelState current, ChannelState previous, ErrorInfo reason, boolean resumed) { @@ -56,10 +74,10 @@ static ChannelStateChange createUpdateEvent(ErrorInfo reason, boolean resumed) { class Multicaster extends io.ably.lib.util.Multicaster implements ChannelStateListener { @Override public void onChannelStateChanged(ChannelStateChange stateChange) { - for(ChannelStateListener member : members) + for (final ChannelStateListener member : getMembers()) try { member.onChannelStateChanged(stateChange); - } catch(Throwable t) {} + } catch(Throwable ignored) {} } } diff --git a/lib/src/main/java/io/ably/lib/realtime/CompletionListener.java b/lib/src/main/java/io/ably/lib/realtime/CompletionListener.java index 431cb39f8..7a7205e2f 100644 --- a/lib/src/main/java/io/ably/lib/realtime/CompletionListener.java +++ b/lib/src/main/java/io/ably/lib/realtime/CompletionListener.java @@ -2,7 +2,6 @@ import io.ably.lib.types.Callback; import io.ably.lib.types.ErrorInfo; -import io.ably.lib.types.Callback; /** * An interface allowing a client to be notified of the outcome @@ -10,7 +9,7 @@ */ public interface CompletionListener { /** - * Called when the associated operation completes successfully, + * Called when the associated operation completes successfully. */ void onSuccess(); @@ -29,7 +28,7 @@ class Multicaster extends io.ably.lib.util.Multicaster imple @Override public void onSuccess() { - for(CompletionListener member : members) + for (final CompletionListener member : getMembers()) try { member.onSuccess(); } catch(Throwable t) {} @@ -37,7 +36,7 @@ public void onSuccess() { @Override public void onError(ErrorInfo reason) { - for(CompletionListener member : members) + for (final CompletionListener member : getMembers()) try { member.onError(reason); } catch(Throwable t) {} diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java index d15e6c6c8..00aa83624 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Connection.java +++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java @@ -1,70 +1,117 @@ package io.ably.lib.realtime; +import io.ably.lib.objects.ObjectsPlugin; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.RecoveryKeyContext; import io.ably.lib.util.EventEmitter; import io.ably.lib.util.Log; +import io.ably.lib.util.PlatformAgentProvider; /** - * A class representing the connection associated with an AblyRealtime instance. - * The Connection object exposes the lifecycle and parameters of the realtime connection. + * Enables the management of a connection to Ably. + * Extends an {@link EventEmitter} object. + *

+ * Spec: RTN4a, RTN4e, RTN4g */ public class Connection extends EventEmitter { /** - * The current state of this Connection. + * The current {@link ConnectionState} of the connection. + *

+ * Spec: RTN4d */ public ConnectionState state; /** - * Error information associated with a connection failure. + * An {@link ErrorInfo} object describing the last error received if a connection failure occurs. + *

+ * Spec: RTN14a */ public ErrorInfo reason; /** - * The assigned connection key. + * A unique private connection key used to recover or resume a connection, assigned by Ably. + * When recovering a connection explicitly, the recoveryKey is used in the recover client options + * as it contains both the key and the last message serial. + * This private connection key can also be used by other REST clients to publish on behalf of this client. + * See the + * publishing over REST on behalf of a realtime client docs + * for more info. + *

+ * Spec: RTN9 */ public String key; /** - * RTN16b) Connection#recoveryKey is an attribute composed of the connection key and latest - * serial received on the connection + * The recovery key string can be used by another client to recover this connection's state in the recover client options property. + * See connection state recover options + * for more information. + *

+ * Spec: RTN16m + * @deprecated use createRecoveryKey method instead. */ + @Deprecated public String recoveryKey; /** - * A public identifier for this connection, used to identify - * this member in presence events and message ids. + * createRecoveryKey is a method that returns a json string which incorporates the @connectionKey@, the + * current @msgSerial@, and a collection of pairs of channel @name@ and current @channelSerial@ for every + * currently attached channel. + *

+ * Spec: RTN16g, RTN16c + *

*/ - public String id; + public String createRecoveryKey() { + if (key == null || key.isEmpty() || this.state == ConnectionState.closing || + this.state == ConnectionState.closed || + this.state == ConnectionState.failed || + this.state == ConnectionState.suspended + ) { + return null; // RTN16g2 + } + + return new RecoveryKeyContext(key, connectionManager.msgSerial, ably.getChannelSerials()).encode(); + } /** - * The serial number of the last message to be received on this connection. + * A unique public identifier for this connection, used to identify this member. + *

+ * Spec: RTN8 */ - public long serial; + public String id; /** - * Causes the library to re-attempt connection, if it was previously explicitly - * closed by the user, or was closed as a result of an unrecoverable error. + * Explicitly calling connect() is unnecessary unless the autoConnect attribute of the {@link io.ably.lib.types.ClientOptions} + * object is false. + * Unless already connected or connecting, this method causes the connection to open, + * entering the {@link ConnectionState#connecting} state. + *

+ * Spec: RTC1b, RTN3, RTN11 */ public void connect() { connectionManager.connect(); } /** - * Send a heartbeat message to the Ably service and await a response. - * @param listener a listener to be notified of the outcome of this message. + * When connected, sends a heartbeat ping to the Ably server and executes the callback with any error and the response + * time in milliseconds when a heartbeat ping request is echoed from the server. + * This can be useful for measuring true round-trip latency to the connected Ably server. + * @param listener A listener to be notified of success or failure. + *

+ * Spec: RTN13 */ public void ping(CompletionListener listener) { connectionManager.ping(listener); } /** - * Causes the connection to close, entering the closed state, from any state except - * the failed state. Once closed, the library will not attempt to re-establish the - * connection without a call to {@link #connect}. + * Causes the connection to close, entering the {@link ConnectionState#closing} state. + * Once closed, the library does not attempt to re-establish the connection without an explicit call to {@link Connection#connect}. + *

+ * Spec: RTN12 */ public void close() { key = null; @@ -76,10 +123,10 @@ public void close() { * internal *****************/ - Connection(AblyRealtime ably, ConnectionManager.Channels channels) throws AblyException { + Connection(AblyRealtime ably, ConnectionManager.Channels channels, PlatformAgentProvider platformAgentProvider, ObjectsPlugin objectsPlugin) throws AblyException { this.ably = ably; this.state = ConnectionState.initialized; - this.connectionManager = new ConnectionManager(ably, this, channels); + this.connectionManager = new ConnectionManager(ably, this, channels, platformAgentProvider, objectsPlugin); } public void onConnectionStateChange(ConnectionStateChange stateChange) { diff --git a/lib/src/main/java/io/ably/lib/realtime/ConnectionEvent.java b/lib/src/main/java/io/ably/lib/realtime/ConnectionEvent.java index c27fcd19a..dc223150c 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ConnectionEvent.java +++ b/lib/src/main/java/io/ably/lib/realtime/ConnectionEvent.java @@ -1,7 +1,7 @@ package io.ably.lib.realtime; /** - * Connection event + * Describes the events emitted by a {@link Connection} object. An event is either an UPDATE or a {@link ConnectionState}. */ public enum ConnectionEvent { initialized, @@ -12,5 +12,10 @@ public enum ConnectionEvent { closing, closed, failed, + /** + * An event for changes to connection conditions for which the {@link ConnectionState} does not change. + *

+ * Spec: RTN4h + */ update } diff --git a/lib/src/main/java/io/ably/lib/realtime/ConnectionState.java b/lib/src/main/java/io/ably/lib/realtime/ConnectionState.java index 633f4bea5..0f997f8b0 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ConnectionState.java +++ b/lib/src/main/java/io/ably/lib/realtime/ConnectionState.java @@ -1,16 +1,70 @@ package io.ably.lib.realtime; /** - * Connection states. See Ably Realtime API documentation for more details. + * Describes the realtime {@link Connection} object states. */ public enum ConnectionState { + /** + * A connection with this state has been initialized but no connection has yet been attempted. + */ initialized(ConnectionEvent.initialized), + /** + * A connection attempt has been initiated. + * The connecting state is entered as soon as the library has completed initialization, + * and is reentered each time connection is re-attempted following disconnection. + */ connecting(ConnectionEvent.connecting), + /** + * A connection exists and is active. + */ connected(ConnectionEvent.connected), + /** + * A temporary failure condition. + * No current connection exists because there is no network connectivity or no host is available. + * The disconnected state is entered if an established connection is dropped, or if a connection attempt was unsuccessful. + * In the disconnected state the library will periodically attempt to open a new connection (approximately every 15 seconds), + * anticipating that the connection will be re-established soon and thus connection and channel continuity will be possible. + * In this state, developers can continue to publish messages as they are automatically placed in a local queue, + * to be sent as soon as a connection is reestablished. + * Messages published by other clients while this client is disconnected will be delivered to it upon reconnection, + * so long as the connection was resumed within 2 minutes. After 2 minutes have elapsed, + * recovery is no longer possible and the connection will move to the SUSPENDED state. + */ disconnected(ConnectionEvent.disconnected), + /** + * A long term failure condition. + * No current connection exists because there is no network connectivity or no host is available. + * The suspended state is entered after a failed connection attempt if there has then been no connection for a period of two minutes. + * In the suspended state, the library will periodically attempt to open a new connection every 30 seconds. + * Developers are unable to publish messages in this state. + * A new connection attempt can also be triggered by an explicit call to {@link Connection#connect}. + * Once the connection has been re-established, channels will be automatically re-attached. + * The client has been disconnected for too long for them to resume from where they left off, + * so if it wants to catch up on messages published by other clients while it was disconnected, + * it needs to use the History API. + */ suspended(ConnectionEvent.suspended), + /** + * An explicit request by the developer to close the connection has been sent to the Ably service. + * If a reply is not received from Ably within a short period of time, + * the connection is forcibly terminated and the connection state becomes CLOSED. + */ closing(ConnectionEvent.closing), + /** + * The connection has been explicitly closed by the client. + * In the closed state, no reconnection attempts are made automatically by the library, and clients may not publish messages. + * No connection state is preserved by the service or by the library. + * A new connection attempt can be triggered by an explicit call to {@link Connection#connect}, which results in a new connection. + */ closed(ConnectionEvent.closed), + /** + * This state is entered if the client library encounters a failure condition that it cannot recover from. + * This may be a fatal connection error received from the Ably service, + * for example an attempt to connect with an incorrect API key, or a local terminal error, + * for example the token in use has expired and the library does not have any way to renew it. + * In the failed state, no reconnection attempts are made automatically by the library, and clients may not publish messages. + * A new connection attempt can be triggered by an explicit call to {@link Connection#connect}. + */ failed(ConnectionEvent.failed); final private ConnectionEvent event; diff --git a/lib/src/main/java/io/ably/lib/realtime/ConnectionStateListener.java b/lib/src/main/java/io/ably/lib/realtime/ConnectionStateListener.java index bcc444b9b..5b57f5871 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ConnectionStateListener.java +++ b/lib/src/main/java/io/ably/lib/realtime/ConnectionStateListener.java @@ -2,15 +2,51 @@ import io.ably.lib.types.ErrorInfo; +/** + * An interface whereby a client may be notified of state changes for a connection. + */ public interface ConnectionStateListener { + /** + * Called when connection state changes. + * @param state information about the new state. Check {@link ConnectionState ConnectionState} - for all states available. + */ void onConnectionStateChanged(ConnectionStateListener.ConnectionStateChange state); + /** + * Contains {@link ConnectionState} change information emitted by the {@link Connection} object. + */ class ConnectionStateChange { + /** + * The event that triggered this {@link ConnectionState} change. + *

+ * Spec: TA5 + */ public final ConnectionEvent event; + /** + * The previous {@link ConnectionState}. + * For the {@link ConnectionEvent#update} event, this is equal to the current {@link ConnectionState}. + *

+ * Spec: TA2 + */ public final ConnectionState previous; + /** + * The new {@link ConnectionState}. + *

+ * Spec: TA2 + */ public final ConnectionState current; + /** + * Duration in milliseconds, after which the client retries a connection where applicable. + *

+ * Spec: RTN14d, TA2 + */ public final long retryIn; + /** + * An {@link ErrorInfo} object containing any information relating to the transition. + *

+ * Spec: RTN4f, TA3 + */ public final ErrorInfo reason; public ConnectionStateChange(ConnectionState previous, ConnectionState current, long retryIn, ErrorInfo reason) { @@ -38,7 +74,7 @@ public static ConnectionStateChange createUpdateEvent(ErrorInfo reason) { class Multicaster extends io.ably.lib.util.Multicaster implements ConnectionStateListener { @Override public void onConnectionStateChanged(ConnectionStateChange state) { - for(ConnectionStateListener member : members) + for (final ConnectionStateListener member : getMembers()) try { member.onConnectionStateChanged(state); } catch(Throwable t) {} diff --git a/lib/src/main/java/io/ably/lib/realtime/Presence.java b/lib/src/main/java/io/ably/lib/realtime/Presence.java index 0147a6154..a74d3bf50 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Presence.java +++ b/lib/src/main/java/io/ably/lib/realtime/Presence.java @@ -1,6 +1,7 @@ package io.ably.lib.realtime; import io.ably.lib.http.BasePaginatedQuery; +import io.ably.lib.http.Http; import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; import io.ably.lib.transport.ConnectionManager; @@ -8,12 +9,15 @@ import io.ably.lib.types.AsyncPaginatedResult; import io.ably.lib.types.Callback; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.MessageDecodeException; import io.ably.lib.types.PaginatedResult; import io.ably.lib.types.Param; import io.ably.lib.types.PresenceMessage; import io.ably.lib.types.PresenceSerializer; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.util.Log; +import io.ably.lib.util.StringUtils; + import java.util.ArrayList; import java.util.Collection; import java.util.EnumMap; @@ -22,12 +26,11 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; -import java.util.Set; /** - * A class that provides access to presence operations and state for the - * associated Channel. + * Enables the presence set to be entered and subscribed to, and the historic presence set to be retrieved for a channel. */ public class Presence { @@ -43,13 +46,25 @@ public class Presence { public final static String GET_CONNECTIONID = "connectionId"; /** - * Get the presence state for this channel. Take Param[] array as an argument. - * Implicitly attaches the channel. However, if the channel is in or moves to the FAILED - * state before the operation succeeds, it will result in an error - * @param params - * @return + * Retrieves the current members present on the channel and the metadata for each member, + * such as their {@link io.ably.lib.types.PresenceMessage.Action} and ID. + * Returns an array of {@link PresenceMessage} objects. + *

+ * Spec: RTP11 + * @param params the request params: + *

+ * waitForSync (RTP11c1) - Sets whether to wait for a full presence set synchronization between Ably and the clients on + * the channel to complete before returning the results. + * Synchronization begins as soon as the channel is {@link ChannelState#attached}. + * When set to true the results will be returned as soon as the sync is complete. + * When set to false the current list of members will be returned without the sync completing. + * The default is true. + *

+ * clientId (RTP11c2) - Filters the array of returned presence members by a specific client using its ID. + *

+ * connectionId (RTP11c3) - Filters the array of returned presence members by a specific connection using its ID. + * @return An array of {@link PresenceMessage} objects. * @throws AblyException - * @throws InterruptedException */ public synchronized PresenceMessage[] get(Param... params) throws AblyException { if (channel.state == ChannelState.failed) { @@ -61,16 +76,24 @@ public synchronized PresenceMessage[] get(Param... params) throws AblyException Collection values = presence.get(params); return values.toArray(new PresenceMessage[values.size()]); } catch (InterruptedException e) { - Log.v(TAG, String.format("Channel %s: get() operation interrupted", channel.name)); + Log.v(TAG, String.format(Locale.ROOT, "Channel %s: get() operation interrupted", channel.name)); throw AblyException.fromThrowable(e); } } /** - * Get the presence state for this Channel, optionally waiting for sync to complete. - * Implicitly attaches the Channel. However, if the channel is in or moves to the FAILED - * state before the operation succeeds, it will result in an error - * @return: the current present members. + * Retrieves the current members present on the channel and the metadata for each member, + * such as their {@link io.ably.lib.types.PresenceMessage.Action} and ID. + * Returns an array of {@link PresenceMessage} objects. + *

+ * Spec: RTP11 + * @param wait (RTP11c1) - Sets whether to wait for a full presence set synchronization between Ably and the clients on + * the channel to complete before returning the results. + * Synchronization begins as soon as the channel is {@link ChannelState#attached}. + * When set to true the results will be returned as soon as the sync is complete. + * When set to false the current list of members will be returned without the sync completing. + * The default is true. + * @return An array of {@link PresenceMessage} objects. * @throws AblyException */ public synchronized PresenceMessage[] get(boolean wait) throws AblyException { @@ -78,18 +101,32 @@ public synchronized PresenceMessage[] get(boolean wait) throws AblyException { } /** - * Get the presence state for a given clientId. Implicitly attaches the - * Channel. However, if the channel is in or moves to the FAILED - * state before the operation succeeds, it will result in an error - * @param wait - * @return - * @throws InterruptedException + * Retrieves the current members present on the channel and the metadata for each member, + * such as their {@link io.ably.lib.types.PresenceMessage.Action} and ID. + * Returns an array of {@link PresenceMessage} objects. + *

+ * Spec: RTP11 + * @param clientId (RTP11c2) - Filters the array of returned presence members by a specific client using its ID. + * @param wait (RTP11c1) - Sets whether to wait for a full presence set synchronization between Ably and the clients on + * the channel to complete before returning the results. + * Synchronization begins as soon as the channel is {@link ChannelState#attached}. + * When set to true the results will be returned as soon as the sync is complete. + * When set to false the current list of members will be returned without the sync completing. + * The default is true. + * @return An array of {@link PresenceMessage} objects. * @throws AblyException */ public synchronized PresenceMessage[] get(String clientId, boolean wait) throws AblyException { return get(new Param(GET_WAITFORSYNC, String.valueOf(wait)), new Param(GET_CLIENTID, clientId)); } + void addPendingPresence(PresenceMessage presenceMessage, CompletionListener listener) { + synchronized(channel) { + final QueuedPresence queuedPresence = new QueuedPresence(presenceMessage,listener); + pendingPresence.add(queuedPresence); + } + } + /** * An interface allowing a listener to be notified of arrival of a presence message. */ @@ -98,10 +135,17 @@ public interface PresenceListener { } /** - * Subscribe to presence events on the associated Channel. This implicitly - * attaches the Channel if it is not already attached. - * @param listener the listener to me notified on arrival of presence messages. - * @param completionListener listener to be called on success/failure + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceMessage.Action}, + * or an action within an array of {@link PresenceMessage.Action}, is received on the channel, + * such as a new member entering the presence set. + * + *

+ * Spec: RTP6a + * + * @param listener An event listener function. + * @param completionListener A callback to be notified of success or failure of the channel {@link Channel#attach()} operation. + *

+ * These listeners are invoked on a background thread. * @throws AblyException */ public void subscribe(PresenceListener listener, CompletionListener completionListener) throws AblyException { @@ -110,15 +154,27 @@ public void subscribe(PresenceListener listener, CompletionListener completionLi } /** - * Same as above without completion listener + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceMessage.Action}, + * or an action within an array of {@link PresenceMessage.Action}, is received on the channel, + * such as a new member entering the presence set. + * + *

+ * Spec: RTP6a + * + * @param listener An event listener function. + *

+ * This listener is invoked on a background thread. + * @throws AblyException */ public void subscribe(PresenceListener listener) throws AblyException { subscribe(listener, null); } /** - * Unsubscribe a previously subscribed presence listener for this channel. - * @param listener the previously subscribed listener. + * Deregisters a specific listener that is registered to receive {@link PresenceMessage} on the channel. + *

+ * Spec: RTP7a + * @param listener An event listener function. */ public void unsubscribe(PresenceListener listener) { listeners.remove(listener); @@ -128,12 +184,18 @@ public void unsubscribe(PresenceListener listener) { } /** - * Subscribe to presence events with a specific action on the associated Channel. - * This implicitly attaches the Channel if it is not already attached. + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceMessage.Action}, + * or an action within an array of {@link PresenceMessage.Action}, is received on the channel, + * such as a new member entering the presence set. + * + *

+ * Spec: RTP6b * - * @param action to be observed - * @param listener - * @param completionListener listener to be called on success/failure + * @param action A {@link PresenceMessage.Action} to register the listener for. + * @param listener An event listener function. + * @param completionListener A callback to be notified of success or failure of the channel {@link Channel#attach()} operation. + *

+ * These listeners are invoked on a background thread. * @throws AblyException */ public void subscribe(PresenceMessage.Action action, PresenceListener listener, CompletionListener completionListener) throws AblyException { @@ -142,29 +204,48 @@ public void subscribe(PresenceMessage.Action action, PresenceListener listener, } /** - * Same as above without completion listener + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceMessage.Action}, + * or an action within an array of {@link PresenceMessage.Action}, is received on the channel, + * such as a new member entering the presence set. + * + *

+ * Spec: RTP6b + * + * @param action A {@link PresenceMessage.Action} to register the listener for. + * @param listener An event listener function. + *

+ * This listener is invoked on a background thread. + * @throws AblyException */ public void subscribe(PresenceMessage.Action action, PresenceListener listener) throws AblyException { subscribe(action, listener, null); } /** - * Unsubscribe a previously subscribed presence listener for this channel from specific action. - * - * @param action - * @param listener + * Deregisters a specific listener that is registered to receive + * {@link PresenceMessage} on the channel for a given {@link PresenceMessage.Action}. + *

+ * Spec: RTP7b + * @param action A specific {@link PresenceMessage.Action} to deregister the listener for. + * @param listener An event listener function. */ public void unsubscribe(PresenceMessage.Action action, PresenceListener listener) { unsubscribeImpl(action, listener); } /** - * Subscribe to presence events with specific actions on the associated Channel. - * This implicitly attaches the Channel if it is not already attached. + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceMessage.Action}, + * or an action within an array of {@link PresenceMessage.Action}, is received on the channel, + * such as a new member entering the presence set. + * + *

+ * Spec: RTP6b * - * @param actions to be observed - * @param listener - * @param completionListener listener to be called on success/failure + * @param actions An array of {@link PresenceMessage.Action} to register the listener for. + * @param listener An event listener function. + * @param completionListener A callback to be notified of success or failure of the channel {@link Channel#attach()} operation. + *

+ * These listeners are invoked on a background thread. * @throws AblyException */ public void subscribe(EnumSet actions, PresenceListener listener, CompletionListener completionListener) throws AblyException { @@ -175,17 +256,30 @@ public void subscribe(EnumSet actions, PresenceListener } /** - * Same as above without completion listener + * Registers a listener that is called each time a {@link PresenceMessage} matching a given {@link PresenceMessage.Action}, + * or an action within an array of {@link PresenceMessage.Action}, is received on the channel, + * such as a new member entering the presence set. + * + *

+ * Spec: RTP6b + * + * @param actions An array of {@link PresenceMessage.Action} to register the listener for. + * @param listener An event listener function. + *

+ * These listeners are invoked on a background thread. + * @throws AblyException */ public void subscribe(EnumSet actions, PresenceListener listener) throws AblyException { subscribe(actions, listener, null); } /** - * Unsubscribe a previously subscribed presence listener for this channel from specific actions. - * - * @param actions - * @param listener + * Deregisters a specific listener that is registered to receive + * {@link PresenceMessage} on the channel for a given {@link PresenceMessage.Action}. + *

+ * Spec: RTP7b + * @param actions An array of specific {@link PresenceMessage.Action} to deregister the listener for. + * @param listener An event listener function. */ public void unsubscribe(EnumSet actions, PresenceListener listener) { for (PresenceMessage.Action action : actions) { @@ -194,28 +288,35 @@ public void unsubscribe(EnumSet actions, PresenceListene } /** - * Unsubscribe all subscribed presence lisceners for this channel. + * Deregisters all listeners currently receiving {@link PresenceMessage} for the channel. + *

+ * Spec: RTP7a, RTE5 */ public void unsubscribe() { listeners.clear(); eventListeners.clear(); } - - /*** - * internal - * - */ - /** - * Implicitly attach channel on subscribe. Throw exception if channel is in failed state - * @param completionListener - * @throws AblyException + * Implicitly attach channel on subscribe. Throw exception if channel is in failed state. + * @param completionListener Registers listener, gets called when ATTACH operation is a success. + * @throws AblyException Throws exception when channel is in failed state. */ private void implicitAttachOnSubscribe(CompletionListener completionListener) throws AblyException { + // RTP6e + if (!channel.attachOnSubscribeEnabled()) { + if (completionListener != null) { + String errorString = String.format( + "Channel %s: attachOnSubscribe=false doesn't expect attach completion callback", channel.name); + Log.e(TAG, errorString); + ErrorInfo errorInfo = new ErrorInfo(errorString, 400,40000); + throw AblyException.fromErrorInfo(errorInfo); + } + return; + } if (channel.state == ChannelState.failed) { - String errorString = String.format("Channel %s: subscribe in FAILED channel state", channel.name); - Log.v(TAG, errorString); + String errorString = String.format(Locale.ROOT, "Channel %s: subscribe in FAILED channel state", channel.name); + Log.e(TAG, errorString); ErrorInfo errorInfo = new ErrorInfo(errorString, 90001); throw AblyException.fromErrorInfo(errorInfo); } @@ -223,124 +324,102 @@ private void implicitAttachOnSubscribe(CompletionListener completionListener) th } /* End sync and emit leave messages for residual members */ - private void endSyncAndEmitLeaves() { - currentSyncChannelSerial = null; + private void endSync() { List residualMembers = presence.endSync(); - for (PresenceMessage member: residualMembers) { - /* - * RTP19: ... The PresenceMessage published should contain the original attributes of the presence - * member with the action set to LEAVE, PresenceMessage#id set to null, and the timestamp set - * to the current time ... - */ + for (PresenceMessage member: residualMembers) { // RTP19 member.action = PresenceMessage.Action.leave; member.id = null; member.timestamp = System.currentTimeMillis(); } - broadcastPresence(residualMembers.toArray(new PresenceMessage[residualMembers.size()])); + broadcastPresence(residualMembers); + } - /** - * (RTP5c2) If a SYNC is initiated as part of the attach, then once the SYNC is complete, - * all members not present in the PresenceMap but present in the internal PresenceMap must - * be re-entered automatically by the client using the clientId and data attributes from - * each. The members re-entered automatically must be removed from the internal PresenceMap - * ensuring that members present on the channel are constructed from presence events sent - * from Ably since the channel became ATTACHED - */ - if (syncAsResultOfAttach) { - syncAsResultOfAttach = false; - for (PresenceMessage item: internalPresence.values()) { - if (presence.put(item)) { - /* Message is new to presence map, send it */ - final String clientId = item.clientId; - try { - PresenceMessage itemToSend = (PresenceMessage)item.clone(); - itemToSend.action = PresenceMessage.Action.enter; - updatePresence(itemToSend, new CompletionListener() { - @Override - public void onSuccess() { - } - - @Override - public void onError(ErrorInfo reason) { - /* - * (RTP5c3) If any of the automatic ENTER presence messages published - * in RTP5c2 fail, then an UPDATE event should be emitted on the channel - * with resumed set to true and reason set to an ErrorInfo object with error - * code value 91004 and the error message string containing the message - * received from Ably (if applicable), the code received from Ably - * (if applicable) and the explicit or implicit client_id of the PresenceMessage - */ - String errorString = String.format("Cannot automatically re-enter %s on channel %s (%s)", - clientId, channel.name, reason.message); - Log.e(TAG, errorString); - channel.emitUpdate(new ErrorInfo(errorString, 91004), true); - } - }); - } catch(AblyException e) { - String errorString = String.format("Cannot automatically re-enter %s on channel %s (%s)", - clientId, channel.name, e.errorInfo.message); - Log.e(TAG, errorString); - channel.emitUpdate(new ErrorInfo(errorString, 91004), true); - } - } + private void updateInnerPresenceMessageFields(ProtocolMessage message) { + for(int i = 0; i < message.presence.length; i++) { + PresenceMessage msg = message.presence[i]; + try { + msg.decode(channel.options); + } catch (MessageDecodeException e) { + Log.e(TAG, String.format(Locale.ROOT, "%s on channel %s", e.errorInfo.message, channel.name)); } - internalPresence.clear(); + /* populate fields derived from protocol message */ + if(msg.connectionId == null) msg.connectionId = message.connectionId; + if(msg.timestamp == 0) msg.timestamp = message.timestamp; + if(msg.id == null) msg.id = message.id + ':' + i; } } - void setPresence(PresenceMessage[] messages, boolean broadcast, String syncChannelSerial) { - Log.v(TAG, "setPresence(); channel = " + channel.name + "; broadcast = " + broadcast + "; syncChannelSerial = " + syncChannelSerial); + void onSync(ProtocolMessage protocolMessage) { String syncCursor = null; - if(syncChannelSerial != null) { - int colonPos = syncChannelSerial.indexOf(':'); - String serial = colonPos >= 0 ? syncChannelSerial.substring(0, colonPos) : syncChannelSerial; - /* Discard incomplete sync if serial has changed */ - if (presence.syncInProgress && currentSyncChannelSerial != null && !currentSyncChannelSerial.equals(serial)) - endSyncAndEmitLeaves(); - syncCursor = syncChannelSerial.substring(colonPos); - if(syncCursor.length() > 1) { - presence.startSync(); - currentSyncChannelSerial = serial; + String syncChannelSerial = protocolMessage.channelSerial; + // RTP18a + if(!StringUtils.isNullOrEmpty(syncChannelSerial)) { + String[] serials = syncChannelSerial.split(":"); + String syncSequenceId = serials[0]; + syncCursor = serials.length > 1 ? serials[1] : ""; + + /* If a new sequence identifier is sent from Ably, then the client library + * must consider that to be the start of a new sync sequence + * and any previous in-flight sync should be discarded. (part of RTP18)*/ + if (presence.syncInProgress && !StringUtils.isNullOrEmpty(currentSyncChannelSerial) + && !currentSyncChannelSerial.equals(syncSequenceId)) { + endSync(); } - } - for(PresenceMessage update : messages) { - boolean updateInternalPresence = update.connectionId.equals(channel.ably.connection.id); - boolean broadcastThisUpdate = broadcast; - PresenceMessage originalUpdate = update; - - switch(update.action) { - case enter: - case update: - update = (PresenceMessage)update.clone(); - update.action = PresenceMessage.Action.present; - case present: - broadcastThisUpdate &= presence.put(update); - if(updateInternalPresence) - internalPresence.put(update); - break; - case leave: - broadcastThisUpdate &= presence.remove(update); - if(updateInternalPresence) - internalPresence.remove(update); - break; - case absent: + + presence.startSync(); + + if (!StringUtils.isNullOrEmpty(syncCursor)) + { + currentSyncChannelSerial = syncSequenceId; } + } - /* - * RTP2g: Any incoming presence message that passes the newness check should be emitted on the - * Presence object, with an event name set to its original action. - */ - if (broadcastThisUpdate) - broadcastPresence(new PresenceMessage[]{originalUpdate}); + onPresence(protocolMessage); + + // RTP18b, RTP18c + if (StringUtils.isNullOrEmpty(syncChannelSerial) || StringUtils.isNullOrEmpty(syncCursor)) + { + endSync(); + currentSyncChannelSerial = null; } + } - /* if this is the last message in a sequence of sync updates, end the sync */ - if(syncChannelSerial == null || syncCursor.length() <= 1) { - endSyncAndEmitLeaves(); + void onPresence(ProtocolMessage protocolMessage) { + updateInnerPresenceMessageFields(protocolMessage); + List updatedPresenceMessages = new ArrayList<>(); + for(PresenceMessage presenceMessage : protocolMessage.presence) { + boolean updateInternalPresence = presenceMessage.connectionId.equals(channel.ably.connection.id); + boolean memberUpdated = false; + + switch(presenceMessage.action) { + case enter: + case update: + case present: + PresenceMessage shallowPresenceCopy = (PresenceMessage)presenceMessage.clone(); + shallowPresenceCopy.action = PresenceMessage.Action.present; + memberUpdated = presence.put(shallowPresenceCopy); + if(updateInternalPresence) + internalPresence.put(presenceMessage); + break; + case leave: + memberUpdated = presence.remove(presenceMessage); + if(updateInternalPresence) + internalPresence.remove(presenceMessage); + break; + case absent: + } + if (memberUpdated) { + updatedPresenceMessages.add(presenceMessage); + } } + /* + * RTP2g: Any incoming presence message that passes the newness check should be emitted on the + * Presence object, with an event name set to its original action. + */ + broadcastPresence(updatedPresenceMessages); } - private void broadcastPresence(PresenceMessage[] messages) { + private void broadcastPresence(List messages) { for(PresenceMessage message : messages) { listeners.onPresenceMessage(message); @@ -356,7 +435,7 @@ private void broadcastPresence(PresenceMessage[] messages) { private static class Multicaster extends io.ably.lib.util.Multicaster implements PresenceListener { @Override public void onPresenceMessage(PresenceMessage message) { - for(PresenceListener member : members) + for (final PresenceListener member : getMembers()) try { member.onPresenceMessage(message); } catch(Throwable t) {} @@ -388,11 +467,17 @@ private void unsubscribeImpl(PresenceMessage.Action action, PresenceListener lis ************************************/ /** - * Enter this client into this channel. This client will be added to the presence set - * and presence subscribers will see an enter message for this client. - * @param data optional data (eg a status message) for this member. - * See {@link io.ably.types.Data} for the supported data types. - * @param listener a listener to be notified on completion of the operation. + * Enters the presence set for the channel, optionally passing a data payload. + * A clientId is required to be present on a channel. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP8 + * + * @param data The payload associated with the presence member. + * @param listener An callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void enter(Object data, CompletionListener listener) throws AblyException { @@ -401,12 +486,17 @@ public void enter(Object data, CompletionListener listener) throws AblyException } /** - * Update the presence data for this client. If the client is not already a member of - * the presence set it will be added, and presence subscribers will see an enter or - * update message for this client. - * @param data optional data (eg a status message) for this member. - * See {@link io.ably.types.Data} for the supported data types. - * @param listener a listener to be notified on completion of the operation. + * Updates the data payload for a presence member. + * If called before entering the presence set, this is treated as an {@link PresenceMessage.Action#enter} event. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP9 + * + * @param data The payload associated with the presence member. + * @param listener An callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void update(Object data, CompletionListener listener) throws AblyException { @@ -415,11 +505,16 @@ public void update(Object data, CompletionListener listener) throws AblyExceptio } /** - * Leave this client from this channel. This client will be removed from the presence - * set and presence subscribers will see a leave message for this client. - * @param data optional data (eg a status message) for this member. - * See {@link io.ably.types.Data} for the supported data types. - * @param listener a listener to be notified on completion of the operation. + * Leaves the presence set for the channel. + * A client must have previously entered the presence set before they can leave it. + * + *

+ * Spec: RTP10 + * + * @param data The payload associated with the presence member. + * @param listener a listener to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void leave(Object data, CompletionListener listener) throws AblyException { @@ -428,9 +523,15 @@ public void leave(Object data, CompletionListener listener) throws AblyException } /** - * Leave this client from this channel. This client will be removed from the presence - * set and presence subscribers will see a leave message for this client. - * @param listener a listener to be notified on completion of the operation. + * Leaves the presence set for the channel. + * A client must have previously entered the presence set before they can leave it. + * + *

+ * Spec: RTP10 + * + * @param listener a listener to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void leave(CompletionListener listener) throws AblyException { @@ -438,49 +539,51 @@ public void leave(CompletionListener listener) throws AblyException { } /** - * Enter a specified client into this channel. The given clientId will be added to - * the presence set and presence subscribers will see a corresponding presence message - * with an empty data payload. - * This method is provided to support connections (eg connections from application - * server instances) that act on behalf of multiple clientIds. In order to be able to - * enter the channel with this method, the client library must have been instanced - * either with a key, or with a token bound to the wildcard clientId. - * @param clientId the id of the client. + * Enters the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP4, RTP14, RTP15 + * + * @param clientId The ID of the client to enter into the presence set. */ public void enterClient(String clientId) throws AblyException { enterClient(clientId, null); } /** - * Enter a specified client into this channel. The given client will be added to the - * presence set and presence subscribers will see a corresponding presence message. - * This method is provided to support connections (eg connections from application - * server instances) that act on behalf of multiple clientIds. In order to be able to - * enter the channel with this method, the client library must have been instanced - * either with a key, or with a token bound to the wildcard clientId. - * @param clientId the id of the client. - * @param data optional data (eg a status message) for this member. - * @throws AblyException + * Enters the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP4, RTP14, RTP15 + * + * @param clientId The ID of the client to enter into the presence set. + * @param data The payload associated with the presence member. */ public void enterClient(String clientId, Object data) throws AblyException { enterClient(clientId, data, null); } /** - * Enter a specified client into this channel. The given client will be added to the - * presence set and presence subscribers will see a corresponding presence message. - * This method is provided to support connections (eg connections from application - * server instances) that act on behalf of multiple clientIds. In order to be able to - * enter the channel with this method, the client library must have been instanced - * either with a key, or with a token bound to the wildcard clientId. - * @param clientId the id of the client. - * @param data optional data (eg a status message) for this member. - * @param listener a listener to be notified on completion of the operation. - * @throws AblyException + * Enters the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP4, RTP14, RTP15 + * + * @param clientId The ID of the client to enter into the presence set. + * @param data The payload associated with the presence member. + * @param listener An callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. */ public void enterClient(String clientId, Object data, CompletionListener listener) throws AblyException { if(clientId == null) { - String errorMessage = String.format("Channel %s: unable to enter presence channel (null clientId specified)", channel.name); + String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to enter presence channel (null clientId specified)", channel.name); Log.v(TAG, errorMessage); if(listener != null) { listener.onError(new ErrorInfo(errorMessage, 40000)); @@ -491,47 +594,70 @@ public void enterClient(String clientId, Object data, CompletionListener listene updatePresence(new PresenceMessage(PresenceMessage.Action.enter, clientId, data), listener); } + private void enterClientWithId(String id, String clientId, Object data, CompletionListener listener) throws AblyException { + if(clientId == null) { + String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to enter presence channel (null clientId specified)", channel.name); + Log.v(TAG, errorMessage); + if(listener != null) { + listener.onError(new ErrorInfo(errorMessage, 40000)); + return; + } + } + PresenceMessage presenceMsg = new PresenceMessage(PresenceMessage.Action.enter, clientId, data); + presenceMsg.id = id; + Log.v(TAG, "enterClient(); channel = " + channel.name + "; clientId = " + clientId); + updatePresence(presenceMsg, listener); + } + /** - * Update the presence data for a specified client into this channel. - * If the client is not already a member of the presence set it will be added, - * and presence subscribers will see a corresponding presence message - * with an empty data payload. As for #enterClient above, the connection - * must be authenticated in a way that enables it to represent an arbitrary clientId. - * @param clientId the id of the client. - * @throws AblyException + * Updates the data payload for a presence member using a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to update in the presence set. */ public void updateClient(String clientId) throws AblyException { updateClient(clientId, null); } /** - * Update the presence data for a specified client into this channel. - * If the client is not already a member of the presence set it will be added, and - * presence subscribers will see an enter or update message for this client. - * As for #enterClient above, the connection must be authenticated in a way that - * enables it to represent an arbitrary clientId. - * @param clientId the id of the client. - * @param data optional data (eg a status message) for this member. - * @throws AblyException + * Updates the data payload for a presence member using a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to update in the presence set. + * @param data The payload to update for the presence member. */ public void updateClient(String clientId, Object data) throws AblyException { updateClient(clientId, data, null); } /** - * Update the presence data for a specified client into this channel. - * If the client is not already a member of the presence set it will be added, and - * presence subscribers will see an enter or update message for this client. - * As for #enterClient above, the connection must be authenticated in a way that - * enables it to represent an arbitrary clientId. - * @param clientId the id of the client. - * @param data optional data (eg a status message) for this member. - * @param listener a listener to be notified on completion of the operation. - * @throws AblyException + * Updates the data payload for a presence member using a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to update in the presence set. + * @param data The payload to update for the presence member. + * @param listener An callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. */ public void updateClient(String clientId, Object data, CompletionListener listener) throws AblyException { if(clientId == null) { - String errorMessage = String.format("Channel %s: unable to update presence channel (null clientId specified)", channel.name); + String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to update presence channel (null clientId specified)", channel.name); Log.v(TAG, errorMessage); if(listener != null) { listener.onError(new ErrorInfo(errorMessage, 40000)); @@ -543,38 +669,51 @@ public void updateClient(String clientId, Object data, CompletionListener listen } /** - * Leave a given client from this channel. This client will be removed from the - * presence set and presence subscribers will see a corresponding presence message - * with an empty data payload. - * @param clientId the id of the client. - * @throws AblyException + * Leaves the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to leave the presence set for. */ public void leaveClient(String clientId) throws AblyException { leaveClient(clientId, null); } /** - * Leave a given client from this channel. This client will be removed from the - * presence set and presence subscribers will see a leave message for this client. - * @param clientId the id of the client. - * @param data optional data (eg a status message) for this member. - * @throws AblyException + * Leaves the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to leave the presence set for. + * @param data The payload associated with the presence member. */ public void leaveClient(String clientId, Object data) throws AblyException { leaveClient(clientId, data, null); } /** - * Leave a given client from this channel. This client will be removed from the - * presence set and presence subscribers will see a leave message for this client. - * @param clientId the id of the client. - * @param data optional data (eg a status message) for this member. - * @param listener a listener to be notified on completion of the operation. - * @throws AblyException + * Leaves the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to leave the presence set for. + * @param data The payload associated with the presence member. + * @param listener An callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. */ public void leaveClient(String clientId, Object data, CompletionListener listener) throws AblyException { if(clientId == null) { - String errorMessage = String.format("Channel %s: unable to leave presence channel (null clientId specified)", channel.name); + String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to leave presence channel (null clientId specified)", channel.name); Log.v(TAG, errorMessage); if(listener != null) { listener.onError(new ErrorInfo(errorMessage, 40000)); @@ -589,18 +728,20 @@ public void leaveClient(String clientId, Object data, CompletionListener listene * Update the presence for this channel with a given PresenceMessage update. * The connection must be authenticated in a way that enables it to represent * the clientId in the message. + * * @param msg the presence message * @param listener a listener to be notified on completion of the operation. + *

+ * This listener is invoked on a background thread. * @throws AblyException */ public void updatePresence(PresenceMessage msg, CompletionListener listener) throws AblyException { - Log.v(TAG, "update(); channel = " + channel.name); + Log.v(TAG, "updatePresence(); channel = " + channel.name); AblyRealtime ably = channel.ably; boolean connected = (ably.connection.state == ConnectionState.connected); - String clientId; try { - clientId = ably.auth.checkClientId(msg, false, connected); + ably.auth.checkClientId(msg, false, connected); } catch(AblyException e) { if(listener != null) { listener.onError(e.errorInfo); @@ -614,8 +755,7 @@ public void updatePresence(PresenceMessage msg, CompletionListener listener) thr case initialized: channel.attach(); case attaching: - QueuedPresence queued = new QueuedPresence(msg, listener); - pendingPresence.put(clientId, queued); + pendingPresence.add(new QueuedPresence(msg, listener)); break; case attached: ProtocolMessage message = new ProtocolMessage(ProtocolMessage.Action.presence, channel.name); @@ -634,23 +774,66 @@ public void updatePresence(PresenceMessage msg, CompletionListener listener) thr ************************************/ /** - * Obtain recent history for this channel using the REST API. - * The history provided relates to all clients of this application, - * not just this instance. - * @param params the request params. See the Ably REST API - * documentation for more details. - * @return an array of Messgaes for this Channel. + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. + * If the channel is configured to persist messages, + * then presence messages can be retrieved from history for up to 72 hours in the past. + * If not, presence messages can only be retrieved from history for up to two minutes in the past. + *

+ * Spec: RTP12c + * @param params the request params: + *

+ * start (RTP12a) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RTP12a) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RTP12a) - The order for which messages are returned in. + * Valid values are backwards which orders messages from most recent to oldest, + * or forwards which orders messages from oldest to most recent. + * The default is backwards. + * limit (RTP12a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @return A {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. * @throws AblyException */ public PaginatedResult history(Param[] params) throws AblyException { - return historyImpl(params).sync(); + return history(channel.ably.http, params); + } + + PaginatedResult history(Http http, Param[] params) throws AblyException { + return historyImpl(http, params).sync(); } + /** + * Asynchronously retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. + * If the channel is configured to persist messages, + * then presence messages can be retrieved from history for up to 72 hours in the past. + * If not, presence messages can only be retrieved from history for up to two minutes in the past. + *

+ * Spec: RTP12c + * @param params the request params: + *

+ * start (RTP12a) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RTP12a) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RTP12a) - The order for which messages are returned in. + * Valid values are backwards which orders messages from most recent to oldest, + * or forwards which orders messages from oldest to most recent. + * The default is backwards. + * limit (RTP12a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @param callback A Callback returning {@link AsyncPaginatedResult} object containing an array of {@link PresenceMessage} objects. + *

+ * This callback is invoked on a background thread. + * @throws AblyException + */ public void historyAsync(Param[] params, Callback> callback) { - historyImpl(params).async(callback); + historyImpl(channel.ably.http, params).async(callback); + } + + void historyAsync(Http http, Param[] params, Callback> callback) { + historyImpl(http, params).async(callback); } - private BasePaginatedQuery.ResultRequest historyImpl(Param[] params) { + private BasePaginatedQuery.ResultRequest historyImpl(Http http, Param[] params) { try { params = Channel.replacePlaceholderParams(channel, params); } catch (AblyException e) { @@ -659,7 +842,7 @@ private BasePaginatedQuery.ResultRequest historyImpl(Param[] pa AblyRealtime ably = channel.ably; HttpCore.BodyHandler bodyHandler = PresenceSerializer.getPresenceResponseHandler(channel.options); - return new BasePaginatedQuery(ably.http, channel.basePath + "/presence/history", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler).get(); + return new BasePaginatedQuery(http, channel.basePath + "/presence/history", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler).get(); } /** @@ -672,7 +855,7 @@ private static class QueuedPresence { QueuedPresence(PresenceMessage msg, CompletionListener listener) { this.msg = msg; this.listener = listener; } } - private final Map pendingPresence = new HashMap(); + private final List pendingPresence = new ArrayList(); private void sendQueuedMessages() { Log.v(TAG, "sendQueuedMessages()"); @@ -684,7 +867,7 @@ private void sendQueuedMessages() { return; ProtocolMessage message = new ProtocolMessage(ProtocolMessage.Action.presence, channel.name); - Iterator allQueued = pendingPresence.values().iterator(); + Iterator allQueued = pendingPresence.iterator(); PresenceMessage[] presenceMessages = message.presence = new PresenceMessage[count]; CompletionListener listener; @@ -703,7 +886,9 @@ private void sendQueuedMessages() { } listener = mListener.isEmpty() ? null : mListener; } + pendingPresence.clear(); + try { connectionManager.send(message, queueMessages, listener); } catch(AblyException e) { @@ -715,7 +900,7 @@ private void sendQueuedMessages() { private void failQueuedMessages(ErrorInfo reason) { Log.v(TAG, "failQueuedMessages()"); - for(QueuedPresence msg : pendingPresence.values()) + for(QueuedPresence msg : pendingPresence) if(msg.listener != null) try { msg.listener.onError(reason); @@ -730,46 +915,62 @@ private void failQueuedMessages(ErrorInfo reason) { * attach / detach ************************************/ - void setAttached(boolean hasPresence) { - /* Start sync, if hasPresence is not set end sync immediately dropping all the current presence members */ + void onAttached(boolean hasPresence) { presence.startSync(); - syncAsResultOfAttach = true; - if (!hasPresence) { - /* - * RTP19a If the PresenceMap has existing members when an ATTACHED message is received without a - * HAS_PRESENCE flag, the client library should emit a LEAVE event for each existing member ... - */ - endSyncAndEmitLeaves(); + if (!hasPresence) { // RTP19a + endSync(); + } + sendQueuedMessages(); // RTP5b + enterInternalMembers(); // RTP17f + } + + /** + * Spec: RTP17g + */ + void enterInternalMembers() { + for (final PresenceMessage item: internalPresence.members.values()) { + try { + enterClientWithId(item.id, item.clientId, item.data, new CompletionListener() { + @Override + public void onSuccess() { + } + + @Override + public void onError(ErrorInfo reason) { + String errorString = String.format(Locale.ROOT, "Cannot automatically re-enter %s on channel %s (%s)", + item.clientId, channel.name, reason.message); + Log.e(TAG, errorString); + channel.emitUpdate(new ErrorInfo(errorString, 91004), true); + } + }); + } catch(AblyException e) { + String errorString = String.format(Locale.ROOT, "Cannot automatically re-enter %s on channel %s (%s)", + item.clientId, channel.name, e.errorInfo.message); + Log.e(TAG, errorString); + channel.emitUpdate(new ErrorInfo(errorString, 91004), true); + } } - sendQueuedMessages(); } - void setDetached(ErrorInfo reason) { + // RTP5a + void onChannelDetachedOrFailed(ErrorInfo reason) { /* Interrupt get() call if needed */ synchronized (presence) { presence.notifyAll(); } - /** - * (RTP5a) If the channel enters the DETACHED or FAILED state then all queued presence - * messages will fail immediately, and the PresenceMap and internal PresenceMap is cleared. - * The latter ensures members are not automatically re-entered if the Channel later becomes attached - */ - failQueuedMessages(reason); presence.clear(); internalPresence.clear(); + failQueuedMessages(reason); } - void setSuspended(ErrorInfo reason) { + // RTP5f, RTP16b + void onChannelSuspended(ErrorInfo reason) { /* Interrupt get() call if needed */ synchronized (presence) { presence.notifyAll(); } - /* - * (RTP5f) If the channel enters the SUSPENDED state then all queued presence messages will fail - * immediately, and the PresenceMap is maintained - */ failQueuedMessages(reason); } @@ -792,11 +993,18 @@ private class PresenceMap { * state other than attached or attaching */ synchronized void waitForSync() throws AblyException, InterruptedException { - boolean syncIsComplete = false; /* temporary variable to avoid potential race conditions */ - while((channel.state == ChannelState.attached || channel.state == ChannelState.attaching) && - /* = (and not ==) is intentional */ - !(syncIsComplete = (!syncInProgress && syncComplete))) + boolean syncIsComplete = false; /* temporary variable to avoid potential race conditions */ + while (channel.state == ChannelState.attaching) { wait(); + } + if (channel.state == ChannelState.attached) { + do { + syncIsComplete = !syncInProgress && syncComplete; + if (!syncIsComplete) { + wait(); + } + } while (!syncIsComplete); + } /* invalid channel state */ int errorCode; @@ -807,12 +1015,12 @@ synchronized void waitForSync() throws AblyException, InterruptedException { * or if waitForSync is set to true, result in an error with code 91005 and a message stating * that the presence state is out of sync due to the channel being in a SUSPENDED state */ errorCode = 91005; - errorMessage = String.format("Channel %s: presence state is out of sync due to the channel being in a SUSPENDED state", channel.name); + errorMessage = String.format(Locale.ROOT, "Channel %s: presence state is out of sync due to the channel being in a SUSPENDED state", channel.name); } else if(syncIsComplete) { return; } else { errorCode = 90001; - errorMessage = String.format("Channel %s: cannot get presence state because channel is in invalid state", channel.name); + errorMessage = String.format(Locale.ROOT, "Channel %s: cannot get presence state because channel is in invalid state", channel.name); } Log.v(TAG, errorMessage); throw AblyException.fromErrorInfo(new ErrorInfo(errorMessage, errorCode)); @@ -826,7 +1034,7 @@ synchronized Collection get(Param[] params) throws AblyExceptio for (Param param: params) { switch (param.key) { case GET_WAITFORSYNC: - waitForSync = Boolean.valueOf(param.value); + waitForSync = Boolean.parseBoolean(param.value); break; case GET_CLIENTID: clientId = param.value; @@ -841,8 +1049,7 @@ synchronized Collection get(Param[] params) throws AblyExceptio if (waitForSync) waitForSync(); - for (Map.Entry entry: members.entrySet()) { - PresenceMessage member = entry.getValue(); + for (PresenceMessage member: members.values()) { if ((clientId == null || member.clientId.equals(clientId)) && (connectionId == null || member.connectionId.equals(connectionId))) result.add(member); @@ -858,7 +1065,7 @@ synchronized Collection get(Param[] params) throws AblyExceptio * false if the message is already superseded */ synchronized boolean put(PresenceMessage item) { - String key = item.memberKey(); + String key = memberKey(item); /* we've seen this member, so do not remove it at the end of sync */ if(residualMembers != null) residualMembers.remove(key); @@ -906,10 +1113,10 @@ synchronized boolean hasNewerItem(String key, PresenceMessage item) { return false; try { - long messageSerial = Long.valueOf(itemComponents[1]); - long messageIndex = Long.valueOf(itemComponents[2]); - long existingMessageSerial = Long.valueOf(existingItemComponents[1]); - long existingMessageIndex = Long.valueOf(existingItemComponents[2]); + long messageSerial = Long.parseLong(itemComponents[1]); + long messageIndex = Long.parseLong(itemComponents[2]); + long existingMessageSerial = Long.parseLong(existingItemComponents[1]); + long existingMessageIndex = Long.parseLong(existingItemComponents[2]); return existingMessageSerial > messageSerial || (existingMessageSerial == messageSerial && existingMessageIndex >= messageIndex); @@ -919,41 +1126,13 @@ synchronized boolean hasNewerItem(String key, PresenceMessage item) { } } - /** - * Get all members based on the current state (even if sync is in progress) - * @return - */ - synchronized Collection values() { - try { return values(false); } catch (InterruptedException|AblyException e) { return null; } - } - - /** - * Get all members, optionally waiting if a sync is in progress. - * @param wait - * @return - * @throws InterruptedException - */ - synchronized Collection values(boolean wait) throws AblyException, InterruptedException { - Set result = new HashSet(); - if(wait) - waitForSync(); - result.addAll(members.values()); - for(Iterator it = result.iterator(); it.hasNext();) { - PresenceMessage entry = it.next(); - if(entry.action == PresenceMessage.Action.absent) { - it.remove(); - } - } - return result; - } - /** * Remove a member. * @param item * @return */ synchronized boolean remove(PresenceMessage item) { - String key = item.memberKey(); + String key = memberKey(item); if (hasNewerItem(key, item)) return false; PresenceMessage existingItem = members.remove(key); @@ -994,9 +1173,13 @@ synchronized List endSync() { /* any members that were present at the start of the sync, * and have not been seen in sync, can be removed */ for(String itemKey: residualMembers) { - /* clone presence message as it still can be in the internal presence map */ - removedEntries.add((PresenceMessage)members.get(itemKey).clone()); - members.remove(itemKey); + PresenceMessage removedMember = members.remove(itemKey); + /* This null check is added as a potential fix for an issue that + * could not be reproduced, reported here https://github.com/ably/ably-java/issues/853 */ + if(removedMember != null) { + /* clone presence message as it still can be in the internal presence map */ + removedEntries.add((PresenceMessage) removedMember.clone()); + } } residualMembers = null; @@ -1017,13 +1200,36 @@ synchronized void clear() { residualMembers.clear(); } + /** + * Combines clientId and connectionId to ensure that multiple connected clients with an identical clientId are uniquely identifiable. + * A string function that returns the combined clientId and connectionId. + *

+ * Spec: TP3h + * @return A combination of clientId and connectionId. + */ + public String memberKey(PresenceMessage item) { + return item.memberKey(); + } + private boolean syncInProgress; private Collection residualMembers; private final HashMap members = new HashMap(); } + private class InternalPresenceMap extends PresenceMap { + /** + * Get the member key for the internal PresenceMessage. + * Spec: RTP17h + * @return key of the presence message + */ + @Override + public String memberKey(PresenceMessage item) { + return item.clientId; + } + } + private final PresenceMap presence = new PresenceMap(); - private final PresenceMap internalPresence = new PresenceMap(); + private final PresenceMap internalPresence = new InternalPresenceMap(); // RTP17 /************************************ * general @@ -1039,12 +1245,11 @@ synchronized void clear() { /* channel serial if sync is in progress */ private String currentSyncChannelSerial; - /* Sync in progress is a result of attach operation */ - private boolean syncAsResultOfAttach; - /** - * (RTP13) Presence#syncComplete returns true if the initial SYNC operation has completed for - * the members present on the channel + * Indicates whether the presence set synchronization between Ably and the clients on the channel has been completed. + * Set to true when the sync is complete. + *

+ * Spec: RTP13 */ public boolean syncComplete; } diff --git a/lib/src/main/java/io/ably/lib/realtime/RealtimeAnnotations.java b/lib/src/main/java/io/ably/lib/realtime/RealtimeAnnotations.java new file mode 100644 index 000000000..2be2b36c2 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/realtime/RealtimeAnnotations.java @@ -0,0 +1,390 @@ +package io.ably.lib.realtime; + +import io.ably.lib.rest.RestAnnotations; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Annotation; +import io.ably.lib.types.AnnotationAction; +import io.ably.lib.types.AsyncPaginatedResult; +import io.ably.lib.types.Callback; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.MessageDecodeException; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.util.Log; +import io.ably.lib.util.Multicaster; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * RealtimeAnnotation provides subscription capabilities for annotations received on a channel. + * It allows adding or removing listeners to handle annotation events and facilitates broadcasting + * those events to the appropriate listeners. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + */ +public class RealtimeAnnotations { + + private static final String TAG = RealtimeAnnotations.class.getName(); + + private final ChannelBase channel; + private final RestAnnotations restAnnotations; + private final AnnotationMulticaster listeners = new AnnotationMulticaster(); + private final Map typeListeners = new HashMap<>(); + + public RealtimeAnnotations(ChannelBase channel, RestAnnotations restAnnotations) { + this.channel = channel; + this.restAnnotations = restAnnotations; + } + + /** + * Publishes an annotation to the specified channel with the given message serial. + * Validates and encodes the annotation before sending it as a protocol message. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message to be annotated + * @param annotation the annotation object associated with the message + * @param listener the completion listener to handle success or failure during the publish process + * @throws AblyException if an error occurs during validation, encoding, or sending the annotation + */ + public void publish(String messageSerial, Annotation annotation, CompletionListener listener) throws AblyException { + Log.v(TAG, String.format("publish(MsgSerial, Annotation); channel = %s", channel.name)); + validateMessageSerial(messageSerial); + // (RSAN1, RSAN1c2) + annotation.action = AnnotationAction.ANNOTATION_CREATE; + sendAnnotation(messageSerial, annotation, listener); + } + + /** + * See {@link #publish(String, Annotation, CompletionListener)} + */ + public void publish(Message message, Annotation annotation, CompletionListener listener) throws AblyException { + publish(message.serial, annotation, listener); + } + + /** + * Publishes an annotation to the specified channel with the given message serial. + * Validates and encodes the annotation before sending it as a protocol message. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message to be annotated + * @param annotation the annotation object associated with the message + * @throws AblyException if an error occurs during validation, encoding, or sending the annotation + */ + public void publish(String messageSerial, Annotation annotation) throws AblyException { + publish(messageSerial, annotation, null); + } + + /** + * See {@link #publish(String, Annotation)} + */ + public void publish(Message message, Annotation annotation) throws AblyException { + publish(message.serial, annotation); + } + + private void sendAnnotation(String messageSerial, Annotation annotation, CompletionListener listener) throws AblyException { + // (RSAN1, RSAN1a3) + if (annotation.type == null) { + throw AblyException.fromErrorInfo(new ErrorInfo("Annotation type must be specified", 400, 40000)); + } + + // (RSAN1, RSAN1c1) + annotation.messageSerial = messageSerial; + + try { + // (RSAN1, RSAN1c3) + annotation.encode(channel.options); + } catch (MessageDecodeException e) { + throw AblyException.fromThrowable(e); + } + + Log.v(TAG, String.format("RealtimeAnnotations.sendAnnotation(): channelName = %s, sending annotation with messageSerial = %s, type = %s, action = %s", + channel.name, messageSerial, annotation.type, annotation.action.name())); + + ProtocolMessage protocolMessage = new ProtocolMessage(); + protocolMessage.action = ProtocolMessage.Action.annotation; + protocolMessage.channel = channel.name; + protocolMessage.annotations = new Annotation[]{annotation}; + + channel.sendProtocolMessage(protocolMessage, listener); + } + + /** + * Deletes an annotation associated with the specified message serial. + * Sets the annotation action to `ANNOTATION_DELETE` and publishes the + * update to the channel with the given completion listener. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated + * @param annotation the annotation object to be deleted + * @param listener the completion listener to handle success or failure during the deletion process + * @throws AblyException if an error occurs during the deletion or publishing process + */ + public void delete(String messageSerial, Annotation annotation, CompletionListener listener) throws AblyException { + Log.v(TAG, String.format("delete(MsgSerial, Annotation); channel = %s", channel.name)); + annotation.action = AnnotationAction.ANNOTATION_DELETE; + sendAnnotation(messageSerial, annotation, listener); + } + + /** + * See {@link #delete(String, Annotation, CompletionListener)} + */ + public void delete(Message message, Annotation annotation, CompletionListener listener) throws AblyException { + delete(message.serial, annotation, listener); + } + + public void delete(String messageSerial, Annotation annotation) throws AblyException { + delete(messageSerial, annotation, null); + } + + /** + * See {@link #delete(String, Annotation)} + */ + public void delete(Message message, Annotation annotation) throws AblyException { + delete(message.serial, annotation); + } + + /** + * Retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param params an array of query parameters for filtering or modifying the request. + * @return a {@link PaginatedResult} containing the matching annotations. + * @throws AblyException if an error occurs during the retrieval process. + */ + public PaginatedResult get(String messageSerial, Param[] params) throws AblyException { + return restAnnotations.get(messageSerial, params); + } + + /** + * See {@link #get(String, Param[])} + */ + public PaginatedResult get(Message message, Param[] params) throws AblyException { + return get(message.serial, params); + } + + /** + * Retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated + * @return a PaginatedResult containing the matching annotations + * @throws AblyException if an error occurs during the retrieval process + */ + public PaginatedResult get(String messageSerial) throws AblyException { + return restAnnotations.get(messageSerial, null); + } + + /** + * See {@link #get(String)} + */ + public PaginatedResult get(Message message) throws AblyException { + return get(message.serial); + } + + /** + * Asynchronously retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param params an array of query parameters for filtering or modifying the request. + * @param callback a callback to handle the result asynchronously, providing an {@link AsyncPaginatedResult} containing the matching annotations. + */ + public void getAsync(String messageSerial, Param[] params, Callback> callback) throws AblyException { + restAnnotations.getAsync(messageSerial, params, callback); + } + + /** + * See {@link #getAsync(String, Param[], Callback)} + */ + public void getAsync(Message message, Param[] params, Callback> callback) throws AblyException { + getAsync(message.serial, params, callback); + } + + /** + * Asynchronously retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param callback a callback to handle the result asynchronously, providing an {@link AsyncPaginatedResult} containing the matching annotations. + */ + public void getAsync(String messageSerial, Callback> callback) throws AblyException { + restAnnotations.getAsync(messageSerial, null, callback); + } + + /** + * See {@link #getAsync(String, Callback)} + */ + public void getAsync(Message message, Callback> callback) throws AblyException { + getAsync(message.serial, callback); + } + + /** + * Subscribes the given {@link AnnotationListener} to the channel, allowing it to receive annotations. + * If the channel's attach on subscribe option is enabled, the channel is attached automatically. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param listener the listener to be subscribed to the channel + * @throws AblyException if an error occurs during channel attachment + */ + public synchronized void subscribe(AnnotationListener listener) throws AblyException { + Log.v(TAG, String.format("subscribe(); annotations in channel = %s", channel.name)); + listeners.add(listener); + if (channel.attachOnSubscribeEnabled()) { + channel.attach(); + } + } + + /** + * Unsubscribes the specified {@link AnnotationListener} from the channel, stopping it + * from receiving further annotations. Any corresponding type-specific listeners + * associated with the listener are also removed. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param listener the {@link AnnotationListener} to be unsubscribed + */ + public synchronized void unsubscribe(AnnotationListener listener) { + Log.v(TAG, String.format("unsubscribe(); annotations in channel = %s", channel.name)); + listeners.remove(listener); + for (AnnotationMulticaster multicaster : typeListeners.values()) { + multicaster.remove(listener); + } + } + + /** + * Subscribes the given {@link AnnotationListener} to the channel for a specific annotation type, + * allowing it to receive annotations of the specified type. If the channel's attach on subscribe + * option is enabled, the channel is attached automatically. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param type the specific annotation type to subscribe to; if null, subscribes to all types + * @param listener the {@link AnnotationListener} to be subscribed + */ + public synchronized void subscribe(String type, AnnotationListener listener) throws AblyException { + Log.v(TAG, String.format("subscribe(); annotations in channel = %s; single type = %s", channel.name, type)); + subscribeImpl(type, listener); + if (channel.attachOnSubscribeEnabled()) { + channel.attach(); + } + } + + /** + * Unsubscribes the specified {@link AnnotationListener} from receiving annotations + * of a particular type within the channel. If there are no remaining listeners + * for the specified type, the type-specific listener collection is also removed. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param type the specific annotation type to unsubscribe from; if null, unsubscribes + * from all annotations associated with the listener + * @param listener the {@link AnnotationListener} to be unsubscribed + */ + public synchronized void unsubscribe(String type, AnnotationListener listener) { + Log.v(TAG, String.format("unsubscribe(); annotations in channel = %s; single type = %s", channel.name, type)); + unsubscribeImpl(type, listener); + } + + /** + * Internal method. Handles incoming annotation messages from the protocol layer. + * + * @param protocolMessage the protocol message containing annotation data + */ + public void onAnnotation(ProtocolMessage protocolMessage) { + List annotations = new ArrayList<>(); + for (int i = 0; i < protocolMessage.annotations.length; i++) { + Annotation annotation = protocolMessage.annotations[i]; + try { + if (annotation.data != null) annotation.decode(channel.options); + } catch (MessageDecodeException e) { + Log.e(TAG, String.format(Locale.ROOT, "%s on channel %s", e.errorInfo.message, channel.name)); + } + /* populate fields derived from protocol message */ + if (annotation.connectionId == null) annotation.connectionId = protocolMessage.connectionId; + if (annotation.timestamp == 0) annotation.timestamp = protocolMessage.timestamp; + if (annotation.id == null) annotation.id = protocolMessage.id + ':' + i; + annotations.add(annotation); + } + broadcastAnnotation(annotations); + } + + private void validateMessageSerial(String messageSerial) throws AblyException { + if (messageSerial == null) throw AblyException.fromErrorInfo( + new ErrorInfo("Message serial can not be empty", 400, 40003) + ); + } + + private void broadcastAnnotation(List annotations) { + for (Annotation annotation : annotations) { + listeners.onAnnotation(annotation); + + String type = annotation.type != null ? annotation.type : ""; + AnnotationMulticaster eventListener = typeListeners.get(type); + if (eventListener != null) eventListener.onAnnotation(annotation); + } + } + + private void subscribeImpl(String type, AnnotationListener listener) { + String annotationType = type != null ? type : ""; + AnnotationMulticaster typeSpecificListeners = typeListeners.get(annotationType); + if (typeSpecificListeners == null) { + typeSpecificListeners = new AnnotationMulticaster(); + typeListeners.put(annotationType, typeSpecificListeners); + } + typeSpecificListeners.add(listener); + } + + private void unsubscribeImpl(String type, AnnotationListener listener) { + AnnotationMulticaster listeners = typeListeners.get(type); + if (listeners != null) { + listeners.remove(listener); + if (listeners.isEmpty()) { + typeListeners.remove(type); + } + } + } + + public interface AnnotationListener { + void onAnnotation(Annotation annotation); + } + + private static class AnnotationMulticaster extends Multicaster implements AnnotationListener { + @Override + public void onAnnotation(Annotation annotation) { + for (final AnnotationListener member : getMembers()) { + try { + member.onAnnotation(annotation); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + } + } + } +} diff --git a/lib/src/main/java/io/ably/lib/rest/AblyBase.java b/lib/src/main/java/io/ably/lib/rest/AblyBase.java index d5be54d0c..8782b9290 100644 --- a/lib/src/main/java/io/ably/lib/rest/AblyBase.java +++ b/lib/src/main/java/io/ably/lib/rest/AblyBase.java @@ -1,7 +1,5 @@ package io.ably.lib.rest; -import java.util.HashMap; - import io.ably.annotation.Experimental; import io.ably.lib.http.AsyncHttpScheduler; import io.ably.lib.http.Http; @@ -15,6 +13,7 @@ import io.ably.lib.http.PaginatedQuery; import io.ably.lib.platform.Platform; import io.ably.lib.push.Push; +import io.ably.lib.realtime.Connection; import io.ably.lib.types.AblyException; import io.ably.lib.types.AsyncHttpPaginatedResponse; import io.ably.lib.types.AsyncPaginatedResult; @@ -34,42 +33,63 @@ import io.ably.lib.util.Crypto; import io.ably.lib.util.InternalMap; import io.ably.lib.util.Log; +import io.ably.lib.util.PlatformAgentProvider; import io.ably.lib.util.Serialisation; /** - * AblyBase - * The top-level class to be instanced for the Ably REST library. + * A client that offers a simple stateless API to interact directly with Ably's REST API. * + * This class implements {@link AutoCloseable} so you can use it in + * try-with-resources constructs and have the JDK close it for you. */ -public abstract class AblyBase { +public abstract class AblyBase implements AutoCloseable { public final ClientOptions options; public final Http http; public final HttpCore httpCore; + /** + * An {@link Auth} object. + *

+ * Spec: RSC5 + */ public final Auth auth; + /** + * An {@link Channels} object. + *

+ * Spec: RSN1 + */ public final Channels channels; public final Platform platform; + /** + * An {@link Push} object. + *

+ * Spec: RSH7 + */ public final Push push; + protected final PlatformAgentProvider platformAgentProvider; /** - * Instance the Ably library using a key only. - * This is simply a convenience constructor for the - * simplest case of instancing the library with a key - * for basic authentication and no other options. - * @param key String key (obtained from application dashboard) + * Constructs a client object using an Ably API key or token string. + *

+ * Spec: RSC1 + * @param key The Ably API key or token string used to validate the client. + * @param platformAgentProvider provides platform agent for the agent header. * @throws AblyException */ - public AblyBase(String key) throws AblyException { - this(new ClientOptions(key)); + public AblyBase(String key, PlatformAgentProvider platformAgentProvider) throws AblyException { + this(new ClientOptions(key), platformAgentProvider); } /** - * Instance the Ably library with the given options. - * @param options see {@link io.ably.lib.types.ClientOptions} for options + * Construct a client object using an Ably {@link ClientOptions} object. + *

+ * Spec: RSC1 + * @param options A {@link ClientOptions} object to configure the client connection to Ably. + * @param platformAgentProvider provides platform agent for the agent header. * @throws AblyException */ - public AblyBase(ClientOptions options) throws AblyException { + public AblyBase(ClientOptions options, PlatformAgentProvider platformAgentProvider) throws AblyException { /* normalise options */ if(options == null) { String msg = "no options provided"; @@ -83,8 +103,9 @@ public AblyBase(ClientOptions options) throws AblyException { Log.setHandler(options.logHandler); Log.i(getClass().getName(), "started"); + this.platformAgentProvider = platformAgentProvider; auth = new Auth(this, options); - httpCore = new HttpCore(options, auth); + httpCore = new HttpCore(options, auth, this.platformAgentProvider); http = new Http(new AsyncHttpScheduler(httpCore, options), new SyncHttpScheduler(httpCore)); channels = new InternalChannels(); @@ -93,6 +114,19 @@ public AblyBase(ClientOptions options) throws AblyException { push = new Push(this); } + /** + * Causes the connection to close, entering the [{@link io.ably.lib.realtime.ConnectionState#closing} state. + * Once closed, the library does not attempt to re-establish the connection without an explicit call to + * {@link Connection#connect()}. + *

+ * Spec: RTN12 + * @throws Exception + */ + @Override + public void close() throws Exception { + http.close(); + } + /** * A collection of Channels associated with an Ably instance. */ @@ -105,10 +139,6 @@ public interface Channels extends ReadOnlyMap { } private class InternalChannels extends InternalMap implements Channels { - InternalChannels() { - super(new HashMap()); - } - @Override public Channel get(String channelName) { try { @@ -137,33 +167,52 @@ public void release(String channelName) { } /** - * Obtain the time from the Ably service. - * This may be required on clients that do not have access - * to a sufficiently well maintained time source, to provide - * timestamps for use in token requests - * @return time in millis since the epoch + * Retrieves the time from the Ably service as milliseconds + * since the Unix epoch. Clients that do not have access + * to a sufficiently well maintained time source and wish + * to issue Ably {@link Auth.TokenRequest} with + * a more accurate timestamp should use the + * {@link ClientOptions#queryTime} property instead of this method. + *

+ * Spec: RSC16 + * @return The time as milliseconds since the Unix epoch. * @throws AblyException */ public long time() throws AblyException { - return timeImpl().sync().longValue(); + return time(http); + } + + long time(Http http) throws AblyException { + return timeImpl(http).sync(); } /** - * Asynchronously obtain the time from the Ably service. - * This may be required on clients that do not have access - * to a sufficiently well maintained time source, to provide - * timestamps for use in token requests - * @param callback + * Asynchronously retrieves the time from the Ably service as milliseconds + * since the Unix epoch. Clients that do not have access + * to a sufficiently well maintained time source and wish + * to issue Ably {@link Auth.TokenRequest} with + * a more accurate timestamp should use the + * {@link ClientOptions#queryTime} property instead of this method. + *

+ * Spec: RSC16 + * @param callback Listener with the time as milliseconds since the Unix epoch. + *

+ * This callback is invoked on a background thread */ public void timeAsync(Callback callback) { - timeImpl().async(callback); + timeAsync(http, callback); } - private Http.Request timeImpl() { + void timeAsync(Http http, Callback callback) { + timeImpl(http).async(callback); + } + + private Http.Request timeImpl(Http http) { + final Param[] params = this.options.addRequestIds ? Param.array(Crypto.generateRandomRequestId()) : null; // RSC7c return http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, Callback callback) throws AblyException { - http.get("/time", HttpUtils.defaultAcceptHeaders(false), null, new HttpCore.ResponseHandler() { + http.get("/time", HttpUtils.defaultAcceptHeaders(false), params, new HttpCore.ResponseHandler() { @Override public Long handleResponse(HttpCore.Response response, ErrorInfo error) throws AblyException { if(error != null) { @@ -177,64 +226,128 @@ public Long handleResponse(HttpCore.Response response, ErrorInfo error) throws A } /** - * Request usage statistics for this application. Returned stats - * are application-wide and not just relating to this instance. - * @param params query options: see Ably REST API documentation - * for available options - * @return a PaginatedResult of Stats records for the requested params + * Queries the REST /stats API and retrieves your application's usage statistics. + * @param params query options: + *

+ * start (RSC6b1) - The time from which stats are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RSC6b1) - The time until stats are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RSC6b2) - The order for which stats are returned in. Valid values are backwards which orders stats from most recent to oldest, + * or forwards which orders stats from oldest to most recent. The default is backwards. + *

+ * limit (RSC6b3) - An upper limit on the number of stats returned. The default is 100, and the maximum is 1000. + *

+ * unit (RSC6b4) - minute, hour, day or month. Based on the unit selected, the given start or end times are rounded down to the start of the relevant interval depending on the unit granularity of the query.) + *

+ * Spec: RSC6a + * @return A {@link PaginatedResult} object containing an array of {@link Stats} objects. * @throws AblyException */ public PaginatedResult stats(Param[] params) throws AblyException { - return new PaginatedQuery(http, "/stats", HttpUtils.defaultAcceptHeaders(false), params, StatsReader.statsResponseHandler).get(); + return stats(http, params); + } + + PaginatedResult stats(Http http, Param[] params) throws AblyException { + return new PaginatedQuery<>(http, "/stats", HttpUtils.defaultAcceptHeaders(false), params, StatsReader.statsResponseHandler).get(); } /** - * Asynchronously obtain usage statistics for this application using the REST API. - * @param params the request params. See the Ably REST API - * @param callback - * @return + * Asynchronously queries the REST /stats API and retrieves your application's usage statistics. + * @param params query options: + *

+ * start (RSC6b1) - The time from which stats are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RSC6b1) - The time until stats are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RSC6b2) - The order for which stats are returned in. Valid values are backwards which orders stats from most recent to oldest, + * or forwards which orders stats from oldest to most recent. The default is backwards. + *

+ * limit (RSC6b3) - An upper limit on the number of stats returned. The default is 100, and the maximum is 1000. + *

+ * unit (RSC6b4) - minute, hour, day or month. Based on the unit selected, the given start or end times are rounded down to the start of the relevant interval depending on the unit granularity of the query.) + *

+ * Spec: RSC6a + * @param callback Listener which returns a {@link AsyncPaginatedResult} object containing an array of {@link Stats} objects. + *

+ * This callback is invoked on a background thread */ public void statsAsync(Param[] params, Callback> callback) { + statsAsync(http, params, callback); + } + + void statsAsync(Http http, Param[] params, Callback> callback) { (new AsyncPaginatedQuery(http, "/stats", HttpUtils.defaultAcceptHeaders(false), params, StatsReader.statsResponseHandler)).get(callback); } /** - * Make a generic HTTP request against an endpoint representing a collection - * of some type; this is to provide a forward compatibility path for new APIs. - * @param method the HTTP method to use (see constants in io.ably.lib.httpCore.HttpCore) - * @param path the path component of the resource URI - * @param params (optional; may be null): any parameters to send with the request; see API-specific documentation - * @param body (optional; may be null): an instance of RequestBody; either a JSONRequestBody or ByteArrayRequestBody - * @param headers (optional; may be null): any additional headers to send; see API-specific documentation - * @return a page of results, each represented as a JsonElement + * Makes a REST request to a provided path. This is provided as a convenience + * for developers who wish to use REST API functionality that is either not + * documented or is not yet included in the public API, without having to + * directly handle features such as authentication, paging, fallback hosts, + * MsgPack and JSON support. + *

+ * Spec: RSC19 + * @param method The request method to use, such as GET, POST. + * @param path The request path. + * @param params The parameters to include in the URL query of the request. + * The parameters depend on the endpoint being queried. + * See the REST API reference + * for the available parameters of each endpoint. + * @param body The RequestBody of the request. + * @param headers Additional HTTP headers to include in the request. + * @return An {@link HttpPaginatedResponse} object returned by the HTTP request, containing an empty or JSON-encodable object. * @throws AblyException if it was not possible to complete the request, or an error response was received */ public HttpPaginatedResponse request(String method, String path, Param[] params, HttpCore.RequestBody body, Param[] headers) throws AblyException { + return request(http, method, path, params, body, headers); + } + + HttpPaginatedResponse request(Http http, String method, String path, Param[] params, HttpCore.RequestBody body, Param[] headers) throws AblyException { headers = HttpUtils.mergeHeaders(HttpUtils.defaultAcceptHeaders(false), headers); return new HttpPaginatedQuery(http, method, path, headers, params, body).exec(); } /** - * Make an async generic HTTP request against an endpoint representing a collection - * of some type; this is to provide a forward compatibility path for new APIs. - * @param method the HTTP method to use (see constants in io.ably.lib.httpCore.HttpCore) - * @param path the path component of the resource URI - * @param params (optional; may be null): any parameters to send with the request; see API-specific documentation - * @param body (optional; may be null): an instance of RequestBody; either a JSONRequestBody or ByteArrayRequestBody - * @param headers (optional; may be null): any additional headers to send; see API-specific documentation - * @param callback called with the asynchronous result + * Makes a async REST request to a provided path. This is provided as a convenience + * for developers who wish to use REST API functionality that is either not + * documented or is not yet included in the public API, without having to + * directly handle features such as authentication, paging, fallback hosts, + * MsgPack and JSON support. + *

+ * Spec: RSC19 + * @param method The request method to use, such as GET, POST. + * @param path The request path. + * @param params The parameters to include in the URL query of the request. + * The parameters depend on the endpoint being queried. + * See the REST API reference + * for the available parameters of each endpoint. + * @param body The RequestBody of the request. + * @param headers Additional HTTP headers to include in the request. + * @param callback called with the asynchronous result, + * returns an {@link AsyncHttpPaginatedResponse} object returned by the HTTP request, + * containing an empty or JSON-encodable object. + *

+ * This callback is invoked on a background thread */ public void requestAsync(String method, String path, Param[] params, HttpCore.RequestBody body, Param[] headers, final AsyncHttpPaginatedResponse.Callback callback) { + requestAsync(http, method, path, params, body, headers, callback); + } + + void requestAsync(Http http, String method, String path, Param[] params, HttpCore.RequestBody body, Param[] headers, final AsyncHttpPaginatedResponse.Callback callback) { headers = HttpUtils.mergeHeaders(HttpUtils.defaultAcceptHeaders(false), headers); (new AsyncHttpPaginatedQuery(http, method, path, headers, params, body)).exec(callback); } /** - * Publish a messages on one or more channels. When there are - * messages to be sent on multiple channels simultaneously, - * it is more efficient to use this method to publish them in - * a single request, as compared with publishing via multiple - * independent requests. + * Publish an array of {@link Message.Batch} objects to one or more channels, up to a maximum of 100 channels. + * Each {@link Message.Batch} object can contain a single message or an array of messages. + * Returns an array of {@link PublishResponse} object. + *

+ * Spec: BO2a + * @param pubSpecs An array of {@link Message.Batch} objects. + * @param channelOptions A {@link ClientOptions} object to configure the client connection to Ably. + * @return A {@link PublishResponse} object. * @throws AblyException */ @Experimental @@ -242,22 +355,61 @@ public PublishResponse[] publishBatch(Message.Batch[] pubSpecs, ChannelOptions c return publishBatchImpl(pubSpecs, channelOptions, null).sync(); } + /** + * Publish an array of {@link Message.Batch} objects to one or more channels, up to a maximum of 100 channels. + * Each {@link Message.Batch} object can contain a single message or an array of messages. + * Returns an array of {@link PublishResponse} object. + *

+ * Spec: BO2a + * @param pubSpecs An array of {@link Message.Batch} objects. + * @param channelOptions A {@link ClientOptions} object to configure the client connection to Ably. + * @param params params to pass into the initial query + * @return A {@link PublishResponse} object. + * @throws AblyException + */ @Experimental public PublishResponse[] publishBatch(Message.Batch[] pubSpecs, ChannelOptions channelOptions, Param[] params) throws AblyException { return publishBatchImpl(pubSpecs, channelOptions, params).sync(); } + /** + * Asynchronously publish an array of {@link Message.Batch} objects to one or more channels, up to a maximum of 100 channels. + * Each {@link Message.Batch} object can contain a single message or an array of messages. + * Returns an array of {@link PublishResponse} object. + *

+ * Spec: BO2a + * @param pubSpecs An array of {@link Message.Batch} objects. + * @param channelOptions A {@link ClientOptions} object to configure the client connection to Ably. + * @param callback callback A callback with {@link PublishResponse} object. + *

+ * This callback is invoked on a background thread + * @throws AblyException + */ @Experimental public void publishBatchAsync(Message.Batch[] pubSpecs, ChannelOptions channelOptions, final Callback callback) throws AblyException { publishBatchImpl(pubSpecs, channelOptions, null).async(callback); } + /** + * Asynchronously publish an array of {@link Message.Batch} objects to one or more channels, up to a maximum of 100 channels. + * Each {@link Message.Batch} object can contain a single message or an array of messages. + * Returns an array of {@link PublishResponse} object. + *

+ * Spec: BO2a + * @param pubSpecs An array of {@link Message.Batch} objects. + * @param channelOptions A {@link ClientOptions} object to configure the client connection to Ably. + * @param params params to pass into the initial query + * @param callback A callback with {@link PublishResponse} object. + *

+ * This callback is invoked on a background thread + * @throws AblyException + */ @Experimental public void publishBatchAsync(Message.Batch[] pubSpecs, ChannelOptions channelOptions, Param[] params, final Callback callback) throws AblyException { publishBatchImpl(pubSpecs, channelOptions, params).async(callback); } - private Http.Request publishBatchImpl(final Message.Batch[] pubSpecs, ChannelOptions channelOptions, final Param[] params) throws AblyException { + private Http.Request publishBatchImpl(final Message.Batch[] pubSpecs, ChannelOptions channelOptions, final Param[] initialParams) throws AblyException { boolean hasClientSuppliedId = false; for(Message.Batch spec : pubSpecs) { for(Message message : spec.messages) { @@ -270,7 +422,7 @@ private Http.Request publishBatchImpl(final Message.Batch[] p } if(!hasClientSuppliedId && options.idempotentRestPublishing) { /* RSL1k1: populate the message id with a library-generated id */ - String messageId = Crypto.getRandomMessageId(); + String messageId = Crypto.getRandomId(); for (int i = 0; i < spec.messages.length; i++) { spec.messages[i].id = messageId + ':' + i; } @@ -280,6 +432,7 @@ private Http.Request publishBatchImpl(final Message.Batch[] p @Override public void execute(HttpScheduler http, final Callback callback) throws AblyException { HttpCore.RequestBody requestBody = options.useBinaryProtocol ? MessageSerializer.asMsgpackRequest(pubSpecs) : MessageSerializer.asJSONRequest(pubSpecs); + final Param[] params = options.addRequestIds ? Param.set(initialParams, Crypto.generateRandomRequestId()) : initialParams ; // RSC7c http.post("/messages", HttpUtils.defaultAcceptHeaders(options.useBinaryProtocol), params, requestBody, new HttpCore.ResponseHandler() { @Override public PublishResponse[] handleResponse(HttpCore.Response response, ErrorInfo error) throws AblyException { @@ -308,6 +461,15 @@ protected void onAuthUpdated(String token, boolean waitForResponse) throws AblyE /* Default is to do nothing. Overridden by subclass. */ } + /** + * Override this method in AblyRealtime and pass updated token to ConnectionManager + * @param token new token + * @param authUpdateResult Callback result + */ + protected void onAuthUpdatedAsync(String token, Auth.AuthUpdateResult authUpdateResult) { + //this must be overriden by subclass + } + /** * Authentication error occurred */ diff --git a/lib/src/main/java/io/ably/lib/rest/Auth.java b/lib/src/main/java/io/ably/lib/rest/Auth.java index 314238b0e..4abc7c68b 100644 --- a/lib/src/main/java/io/ably/lib/rest/Auth.java +++ b/lib/src/main/java/io/ably/lib/rest/Auth.java @@ -4,7 +4,9 @@ import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -22,6 +24,7 @@ import io.ably.lib.types.Capability; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.NonRetriableTokenException; import io.ably.lib.types.Param; import io.ably.lib.util.Base64Coder; import io.ably.lib.util.Log; @@ -31,10 +34,9 @@ * Token-generation and authentication operations for the Ably API. * See the Ably Authentication documentation for details of the * authentication methods available. - * + * Creates Ably {@link TokenRequest} objects and obtains Ably Tokens from Ably to subsequently issue to less trusted clients. */ public class Auth { - /** * Authentication methods */ @@ -44,74 +46,127 @@ public enum AuthMethod { } /** - * Authentication options when instancing the Ably library + * Passes authentication-specific properties in authentication requests to Ably. + * Properties set using AuthOptions are used instead of the default values set when the client library + * is instantiated, as opposed to being merged with them. */ public static class AuthOptions { /** - * A callback to call to obtain a signed TokenRequest, - * TokenDetails or a token string. This enables a client - * to obtain token requests or tokens from another entity, - * so tokens can be renewed without the client requiring a - * key + * Called when a new token is required. + * The role of the callback is to obtain a fresh token, one of: an Ably Token string (in plain text format); + * a signed {@link TokenRequest}; a {@link TokenDetails} (in JSON format); + * an Ably JWT. + * See the authentication documentation + * for details of the Ably {@link TokenRequest} format and associated API calls. + *

+ * This callback is invoked on a background thread. + *

+ * Spec: + * RSA4a, RSA4, TO3j5, AO2b */ public TokenCallback authCallback; /** - * A URL to query to obtain a signed TokenRequest, - * TokenDetails or a token string. This enables a client - * to obtain token request or token from another entity, - * so tokens can be renewed without the client requiring - * a key + * A URL that the library may use to obtain a token string (in plain text format), or a signed {@link TokenRequest} + * or {@link TokenDetails} (in JSON format) from. + *

+ * Spec: + * RSA4a, RSA4, RSA8c, TO3j6, AO2c */ public String authUrl; /** - * TO3j7: authMethod: The HTTP verb to be used when a request - * is made by the library to the authUrl. Defaults to GET, - * supports GET and POST + * The HTTP verb to use for any request made to the authUrl, either GET or POST. The default value is GET. + *

+ * Spec: + * RSA8c, TO3j7, AO2d */ public String authMethod; /** - * Full Ably key string as obtained from dashboard. + * The full API key string, as obtained from the Ably dashboard. + * Use this option if you wish to use Basic authentication, + * or wish to be able to issue Ably Tokens without needing to defer to a separate entity to sign Ably {@link TokenRequest}. + * Read more about Basic authentication. + *

+ * Spec: + * RSA11, RSA14, TO3j1, AO2a */ public String key; /** - * An authentication token issued for this application - * against a specific key and {@link TokenParams} + * An authenticated token. + * This can either be a {@link TokenDetails} object, a {@link TokenRequest} object, or token string + * (obtained from the token property of a {@link TokenDetails} component of an Ably {@link TokenRequest} response, or a + * JSON Web Token satisfying the + * Ably requirements for JWTs). + * This option is mostly useful for testing: since tokens are short-lived, + * in production you almost always want to use an authentication method that enables the + * client library to renew the token automatically when the previous one expires, such as authUrl or authCallback. + * Read more about Token authentication. + *

+ * Spec: + * RSA4a, RSA4, TO3j2 */ public String token; /** - * An authentication token issued for this application - * against a specific key and {@link TokenParams} + * An authenticated {@link TokenDetails} object (most commonly obtained from an Ably Token Request response). + * This option is mostly useful for testing: since tokens are short-lived, + * in production you almost always want to use an authentication method that enables the + * client library to renew the token automatically when the previous one expires, such as authUrl or authCallback. + * Use this option if you wish to use Token authentication. + * Read more about Token authentication. + *

+ * Spec: + * RSA4a, RSA4, TO3j2 */ public TokenDetails tokenDetails; /** - * Headers to be included in any request made by the library - * to the authURL. + * A set of key-value pair headers to be added to any request made to the authUrl. + * Useful when an application requires these to be added to validate the request or implement the response. + * If the authHeaders object contains an authorization key, then withCredentials is set on the XHR request. + *

+ * Spec: + * RSA8c3, TO3j8, AO2e */ public Param[] authHeaders; /** - * Query params to be included in any request made by the library - * to the authURL. + * A set of key-value pair params to be added to any request made to the authUrl. + * When the authMethod is GET, query params are added to the URL, whereas when authMethod is POST, + * the params are sent as URL encoded form data. + * Useful when an application requires these to be added to validate the request or implement the response. + *

+ * Spec: + * RSA8c3, RSA8c1, TO3j9, AO2f */ public Param[] authParams; /** - * This may be set in instances that the library is to sign - * token requests based on a given key. If true, the library - * will query the Ably system for the current time instead of - * relying on a locally-available time of day. + * If true, the library queries the Ably servers for the current time when issuing {@link TokenRequest} + * instead of relying on a locally-available time of day. + * Knowing the time accurately is needed to create valid signed Ably {@link TokenRequest}, + * so this option is useful for library instances on auth servers where for some reason the server clock + * cannot be kept synchronized through normal means, such as an NTP daemon. + * The server is queried for the current time once per client library instance (which stores the offset from the local clock), + * so if using this option you should avoid instancing a new version of the library for each request. + * The default is false. + *

+ * Spec: + * RSA9d, TO3j10, AO2a */ public boolean queryTime; /** - * TO3j4: Use token authorization even if no clientId + * When true, forces token authentication to be used by the library. + * If a clientId is not specified in the {@link ClientOptions} or {@link TokenParams}, + * then the Ably Token issued is anonymous. + *

+ * Spec: + * RSA4, RSA14, TO3j4 */ public boolean useTokenAuth; @@ -130,6 +185,9 @@ public AuthOptions(String key) throws AblyException { if (key == null) { throw AblyException.fromErrorInfo(new ErrorInfo("key string cannot be null", 40000, 400)); } + if (key.isEmpty()) { + throw new IllegalArgumentException("Key string cannot be empty"); + } if(key.indexOf(':') > -1) this.key = key; else @@ -178,37 +236,51 @@ private AuthOptions copy() { } /** - * A class providing details of a token and its associated metadata, - * provided when the system successfully requests a token from the system. - * + * Contains an Ably Token and its associated metadata. */ public static class TokenDetails { /** - * The token itself + * The Ably Token itself. + *

+ * A typical Ably Token string appears with the form xVLyHw.A-pwh7wicf3afTfgiw4k2Ku33kcnSA7z6y8FjuYpe3QaNRTEo4. + *

+ * Spec: TD2 */ public String token; /** - * The time (in millis since the epoch) at which this token expires. + * The timestamp at which this token expires as milliseconds since the Unix epoch. + *

+ * Spec: TD3 */ public long expires; /** - * The time (in millis since the epoch) at which this token was issued. + * The timestamp at which this token was issued as milliseconds since the Unix epoch. + *

+ * Spec: TD4 */ public long issued; /** - * The capability associated with this token. See the Ably Authentication - * documentation for details. + * The capabilities associated with this Ably Token. + * The capabilities value is a JSON-encoded representation of the resource paths and associated operations. + * Read more about capabilities in the + * capabilities docs. + *

+ * Spec: TD5 */ public String capability; /** - * The clientId, if any, bound to this token. If a clientId is included, - * then the token authenticates its bearer as that clientId, and the - * token may only be used to perform operations on behalf of that clientId. + * The client ID, if any, bound to this Ably Token. + * If a client ID is included, then the Ably Token authenticates its bearer as that client ID, + * and the Ably Token may only be used to perform operations on behalf of that client ID. + * The client is then considered to be an + * identified client. + *

+ * Spec: TD6 */ public String clientId; @@ -216,10 +288,17 @@ public TokenDetails() {} public TokenDetails(String token) { this.token = token; } /** - * Convert a JSON response body to a TokenDetails. - * Deprecated: use fromJson() instead - * @param json - * @return + * A static factory method to create a TokenDetails object from a deserialized + * TokenDetails-like object or a JSON stringified TokenDetails object. + * This method is provided to minimize bugs as a result of differing types by platform for fields such as timestamp or ttl. + * For example, in Ruby ttl in the TokenDetails object is exposed in seconds as that is idiomatic for the language, + * yet when serialized to JSON using to_json it is automatically converted to the Ably standard which is milliseconds. + * By using the fromJson() method when constructing a TokenDetails object, + * Ably ensures that all fields are consistently serialized and deserialized across platforms. + *

+ * Spec: TD7 + * @param json A deserialized TokenDetails-like object or a JSON stringified TokenDetails object. + * @return An Ably authentication token. */ @Deprecated public static TokenDetails fromJSON(JsonObject json) { @@ -227,19 +306,34 @@ public static TokenDetails fromJSON(JsonObject json) { } /** - * Convert a JSON element response body to a TokenDetails. + * A static factory method to create a TokenDetails object from a deserialized + * TokenDetails-like object or a JSON stringified TokenDetails object. + * This method is provided to minimize bugs as a result of differing types by platform for fields such as timestamp or ttl. + * For example, in Ruby ttl in the TokenDetails object is exposed in seconds as that is idiomatic for the language, + * yet when serialized to JSON using to_json it is automatically converted to the Ably standard which is milliseconds. + * By using the fromJson() method when constructing a TokenDetails object, + * Ably ensures that all fields are consistently serialized and deserialized across platforms. + *

* Spec: TD7 - * @param json - * @return + * @param json A deserialized TokenDetails-like object or a JSON stringified TokenDetails object. + * @return An Ably authentication token. */ public static TokenDetails fromJson(String json) { return Serialisation.gson.fromJson(json, TokenDetails.class); } /** - * Convert a JSON element response body to a TokenDetails. - * @param json - * @return + * A static factory method to create a TokenDetails object from a deserialized + * TokenDetails-like object or a JSON stringified TokenDetails object. + * This method is provided to minimize bugs as a result of differing types by platform for fields such as timestamp or ttl. + * For example, in Ruby ttl in the TokenDetails object is exposed in seconds as that is idiomatic for the language, + * yet when serialized to JSON using to_json it is automatically converted to the Ably standard which is milliseconds. + * By using the fromJson() method when constructing a TokenDetails object, + * Ably ensures that all fields are consistently serialized and deserialized across platforms. + *

+ * Spec: TD7 + * @param json A deserialized TokenDetails-like object or a JSON stringified TokenDetails object. + * @return An Ably authentication token. */ public static TokenDetails fromJsonElement(JsonObject json) { return Serialisation.gson.fromJson(json, TokenDetails.class); @@ -265,6 +359,8 @@ public String asJson() { */ @Override public boolean equals(Object obj) { + if (!(obj instanceof TokenDetails)) return false; + TokenDetails details = (TokenDetails)obj; return equalNullableStrings(this.token, details.token) & equalNullableStrings(this.capability, details.capability) & @@ -273,40 +369,54 @@ public boolean equals(Object obj) { (this.expires == details.expires); } -} + @Override + public int hashCode() { + return Objects.hash(token, capability, clientId, issued, expires); + } + + } /** - * A class providing parameters of a token request. + * Defines the properties of an Ably Token. */ public static class TokenParams { /** - * Requested time to live for the token. If the token request - * is successful, the TTL of the returned token will be less - * than or equal to this value depending on application settings - * and the attributes of the issuing key. - * - * 0 means Ably will set it to the default value. + * Requested time to live for the token in milliseconds. The default is 60 minutes. + *

+ * Spec: RSA9e, TK2a */ public long ttl; /** - * Capability of the token. If the token request is successful, - * the capability of the returned token will be the intersection of - * this capability with the capability of the issuing key. + * The capabilities associated with this Ably Token. + * The capabilities value is a JSON-encoded representation of the resource paths and associated operations. + * Read more about capabilities in the + * capabilities docs. + *

+ * Spec: RSA9f, TK2b */ public String capability; /** - * A clientId to associate with this token. The generated token - * may be used to authenticate as this clientId. + * A client ID, used for identifying this client when publishing messages or for presence purposes. + * The clientId can be any non-empty string, except it cannot contain a *. + * This option is primarily intended to be used in situations where the library is instantiated with a key. + * Note that a clientId may also be implicit in a token used to instantiate the library. + * An error is raised if a clientId specified here conflicts with the clientId implicit in the token. + * Find out more about identified clients. + *

+ * Spec: TK2c */ public String clientId; /** - * The timestamp (in millis since the epoch) of this request. - * Timestamps, in conjunction with the nonce, are used to prevent - * token requests from being replayed. + * The timestamp of this request as milliseconds since the Unix epoch. + * Timestamps, in conjunction with the nonce, are used to prevent requests from being replayed. + * timestamp is a "one-time" value, and is valid in a request, + * but is not validly a member of any default token params such as ClientOptions.defaultTokenParams. + *

+ * Spec: RSA9d, Tk2d */ public long timestamp; @@ -367,7 +477,8 @@ private TokenParams copy() { } /** - * A class providing parameters of a token request. + * Contains the properties of a request for a token to Ably. + * Tokens are generated using {@link Auth#requestToken}. */ public static class TokenRequest extends TokenParams { @@ -381,28 +492,39 @@ public TokenRequest(TokenParams params) { } /** - * The keyName of the key against which this request is made. + * The name of the key against which this request is made. The key name is public, whereas the key secret is private. + *

+ * Spec: TE2 */ public String keyName; /** - * An opaque nonce string of at least 16 characters to ensure - * uniqueness of this request. Any subsequent request using the - * same nonce will be rejected. + * A cryptographically secure random string of at least 16 characters, used to ensure the TokenRequest cannot be reused. + *

+ * Spec: TE2 */ public String nonce; /** - * The Message Authentication Code for this request. See the Ably - * Authentication documentation for more details. + * The Message Authentication Code for this request. + *

+ * Spec: TE2 */ public String mac; /** - * Convert a JSON serialisation to a TokenParams. - * Deprecated: use fromJson() instead - * @param json - * @return + * A static factory method to create a TokenRequest object from a deserialized TokenRequest-like object + * or a JSON stringified TokenRequest object. + * This method is provided to minimize bugs as a result of differing types by platform for fields such as timestamp or ttl. + * For example, in Ruby ttl in the TokenRequest object is exposed in seconds as that is idiomatic for the language, + * yet when serialized to JSON using to_json it is automatically converted to the Ably standard which is milliseconds. + * By using the fromJson() method when constructing a TokenRequest object, + * Ably ensures that all fields are consistently serialized and deserialized across platforms. + *

+ * Spec: TE6 + * @param json A deserialized TokenRequest-like object or a JSON stringified TokenRequest object to create a TokenRequest. + * @return An Ably token request object. + * @deprecated use fromJsonElement(JsonObject json) instead */ @Deprecated public static TokenRequest fromJSON(JsonObject json) { @@ -410,19 +532,34 @@ public static TokenRequest fromJSON(JsonObject json) { } /** - * Convert a parsed JSON response body to a TokenParams. - * @param json - * @return + * A static factory method to create a TokenRequest object from a deserialized TokenRequest-like object + * or a JSON stringified TokenRequest object. + * This method is provided to minimize bugs as a result of differing types by platform for fields such as timestamp or ttl. + * For example, in Ruby ttl in the TokenRequest object is exposed in seconds as that is idiomatic for the language, + * yet when serialized to JSON using to_json it is automatically converted to the Ably standard which is milliseconds. + * By using the fromJson() method when constructing a TokenRequest object, + * Ably ensures that all fields are consistently serialized and deserialized across platforms. + *

+ * Spec: TE6 + * @param json A deserialized TokenRequest-like object or a JSON stringified TokenRequest object to create a TokenRequest. + * @return An Ably token request object. */ public static TokenRequest fromJsonElement(JsonObject json) { return Serialisation.gson.fromJson(json, TokenRequest.class); } /** - * Convert a string JSON response body to a TokenParams. + * A static factory method to create a TokenRequest object from a deserialized TokenRequest-like object + * or a JSON stringified TokenRequest object. + * This method is provided to minimize bugs as a result of differing types by platform for fields such as timestamp or ttl. + * For example, in Ruby ttl in the TokenRequest object is exposed in seconds as that is idiomatic for the language, + * yet when serialized to JSON using to_json it is automatically converted to the Ably standard which is milliseconds. + * By using the fromJson() method when constructing a TokenRequest object, + * Ably ensures that all fields are consistently serialized and deserialized across platforms. + *

* Spec: TE6 - * @param json - * @return + * @param json A deserialized TokenRequest-like object or a JSON stringified TokenRequest object to create a TokenRequest. + * @return An Ably token request object. */ public static TokenRequest fromJson(String json) { return Serialisation.gson.fromJson(json, TokenRequest.class); @@ -463,6 +600,33 @@ public boolean equals(Object obj) { } } + /** + * An interface providing update result for onAuthUpdated + */ + public interface AuthUpdateResult{ + /** + * Signals an update from {@link io.ably.lib.transport.ConnectionManager#onAuthUpdatedAsync(String, AuthUpdateResult)} + * @param success If Update was successful + * @param errorInfo optional errorInfo if update wasn't successful + */ + void onUpdate(boolean success, ErrorInfo errorInfo); + } + + /** + * An interface providing completion callbackk for renewAuth + */ + public interface RenewAuthResult { + /** + * Signals completion of {@link Auth#renewAuth(RenewAuthResult)} + * @param success if token renewal was successful. Please note that success for this operation means that + * other operations relating to this also succeeded. + * @param tokenDetails New token details. Please note that this value can exist regardless of value of + * success state. + * @param errorInfo Error details if operation is completed with error. + */ + void onCompletion(boolean success,TokenDetails tokenDetails, ErrorInfo errorInfo); + } + /** * An interface implemented by a callback that provides either tokens, * or signed token requests, in response to a request with given token params. @@ -472,41 +636,32 @@ public interface TokenCallback { } /** - * The clientId for this library instance - * Spec RSA7b + * A client ID, used for identifying this client when publishing messages or for presence purposes. + * The clientId can be any non-empty string, except it cannot contain a *. + * This option is primarily intended to be used in situations where the library is instantiated with a key. + * Note that a clientId may also be implicit in a token used to instantiate the library. + * An error is raised if a clientId specified here conflicts with the clientId implicit in the token. + * Find out more about identified clients. + *

+ * Spec: RSA7, RSC17, RSA12 */ public String clientId; /** - * Ensure valid auth credentials are present. This may rely in an already-known - * and valid token, and will obtain a new token if necessary or explicitly - * requested. - * Authorization will use the parameters supplied on construction except - * where overridden with the options supplied in the call. - * - * @param params - * an object containing the request params: - * - key: (optional) the key to use; if not specified, the key - * passed in constructing the Rest interface may be used - * - * - ttl: (optional) the requested life of any new token in ms. If none - * is specified a default of 1 hour is provided. The maximum lifetime - * is 24hours; any request exceeeding that lifetime will be rejected - * with an error. - * - * - capability: (optional) the capability to associate with the access token. - * If none is specified, a token will be requested with all of the - * capabilities of the specified key. - * - * - clientId: (optional) a client Id to associate with the token + * Instructs the library to get a new token immediately. + * When using the realtime client, it upgrades the current realtime connection to use the new token, + * or if not connected, initiates a connection to Ably, once the new token has been obtained. + * Also stores any {@link TokenParams} and {@link AuthOptions} passed in as the new defaults, + * to be used for all subsequent implicit or explicit token requests. + * Any {@link TokenParams} and {@link AuthOptions} objects passed in entirely replace, + * as opposed to being merged with, the current client library saved values. + *

+ * Spec: RSA10 * - * - timestamp: (optional) the time in ms since the epoch. If none is specified, - * the system will be queried for a time value to use. - * - * - queryTime (optional) boolean indicating that the Ably system should be - * queried for the current time when none is specified explicitly. - * - * @param options + * @param params A {@link TokenParams} object. + * @param options An {@link AuthOptions} object. + * @return A {@link TokenDetails} object. + * @throws AblyException */ public TokenDetails authorize(TokenParams params, AuthOptions options) throws AblyException { /* Spec: RSA10g */ @@ -551,11 +706,20 @@ public TokenDetails authorise(TokenParams params, AuthOptions options) throws Ab } /** - * Make a token request. This will make a token request now, even if the library already - * has a valid token. It would typically be used to issue tokens for use by other clients. - * @param params : see {@link #authorize} for params - * @param tokenOptions : see {@link #authorize} for options - * @return the TokenDetails + * Calls the requestToken REST API endpoint to obtain an Ably Token + * according to the specified {@link TokenParams} and {@link AuthOptions}. + * Both {@link TokenParams} and {@link AuthOptions} are optional. + * When omitted or null, the default token parameters and authentication options for the client library are used, + * as specified in the {@link ClientOptions} when the client library was instantiated, + * or later updated with an explicit authorize request. Values passed in are used instead of, + * rather than being merged with, the default values. + * To understand why an Ably {@link TokenRequest} may be issued to clients in favor of a token, + * see Token Authentication explained. + *

+ * Spec: RSA8e + * @param params : A {@link TokenParams} object. + * @param tokenOptions : An {@link AuthOptions} object. + * @return A {@link TokenDetails} object. * @throws AblyException */ public TokenDetails requestToken(TokenParams params, AuthOptions tokenOptions) throws AblyException { @@ -584,8 +748,12 @@ public TokenDetails requestToken(TokenParams params, AuthOptions tokenOptions) t signedTokenRequest = (TokenRequest)authCallbackResponse; else throw AblyException.fromErrorInfo(new ErrorInfo("Invalid authCallback response", 400, 40000)); - } catch(AblyException e) { - throw AblyException.fromErrorInfo(e, new ErrorInfo("authCallback failed with an exception", 401, 80019)); + } catch (final Exception e) { + final boolean isTokenExceptionNonRetriable = e instanceof NonRetriableTokenException; + final boolean isAblyExceptionNonRetriable = e instanceof AblyException && ((AblyException) e).errorInfo.statusCode == 403; + final boolean shouldNotRetryAuthOperation = isTokenExceptionNonRetriable || isAblyExceptionNonRetriable; + final int statusCode = shouldNotRetryAuthOperation ? 403 : 401; // RSA4c & RSA4d + throw AblyException.fromErrorInfo(e, new ErrorInfo("authCallback failed with an exception", statusCode, 80019)); } } else if(tokenOptions.authUrl != null) { Log.i("Auth.requestToken()", "using token auth with auth_url"); @@ -637,6 +805,7 @@ public Object handleResponse(HttpCore.Response response, ErrorInfo error) throws /* append all relevant params to token params */ Map urlParams = null; URL authUrl = HttpUtils.parseUrl(authOptions.authUrl); + final String urlWithoutQueryParams = HttpUtils.urlWithQueryStringRemoved(authOptions.authUrl); String queryString = authUrl.getQuery(); if(queryString != null && !queryString.isEmpty()) { urlParams = HttpUtils.decodeParams(queryString); @@ -652,13 +821,15 @@ public Object handleResponse(HttpCore.Response response, ErrorInfo error) throws } } if (HttpConstants.Methods.POST.equals(tokenOptions.authMethod)) { - authUrlResponse = HttpHelpers.postUri(ably.httpCore, tokenOptions.authUrl, tokenOptions.authHeaders, HttpUtils.flattenParams(urlParams), HttpUtils.flattenParams(tokenParams), responseHandler); + authUrlResponse = HttpHelpers.postUri(ably.httpCore, urlWithoutQueryParams, tokenOptions.authHeaders, + HttpUtils.flattenParams(urlParams), HttpUtils.flattenParams(tokenParams), responseHandler); } else { Map requestParams = (urlParams != null) ? HttpUtils.mergeParams(urlParams, tokenParams) : tokenParams; - authUrlResponse = HttpHelpers.getUri(ably.httpCore, tokenOptions.authUrl, tokenOptions.authHeaders, HttpUtils.flattenParams(requestParams), responseHandler); + authUrlResponse = HttpHelpers.getUri(ably.httpCore, urlWithoutQueryParams, tokenOptions.authHeaders, + HttpUtils.flattenParams(requestParams), responseHandler); } } catch(AblyException e) { - throw AblyException.fromErrorInfo(e, new ErrorInfo("authUrl failed with an exception", 401, 80019)); + throw AblyException.fromErrorInfo(e, new ErrorInfo("authUrl failed with an exception", e.errorInfo.statusCode, 80019)); } if(authUrlResponse == null) { throw AblyException.fromErrorInfo(null, new ErrorInfo("Empty response received from authUrl", 401, 80019)); @@ -695,12 +866,23 @@ public TokenDetails handleResponse(HttpCore.Response response, ErrorInfo error) } /** - * Create a signed token request based on known credentials - * and the given token params. This would typically be used if creating - * signed requests for submission by another client. - * @param params : see {@link #authorize} for params - * @param options : see {@link #authorize} for options - * @return the params augmented with the mac. + * Creates and signs an Ably {@link TokenRequest} based on the specified + * (or if none specified, the client library stored) {@link TokenParams} and {@link AuthOptions}. + * Note this can only be used when the API key value is available locally. + * Otherwise, the Ably {@link TokenRequest} must be obtained from the key owner. + * Use this to generate an Ably {@link TokenRequest} in order to implement an + * Ably Token request callback for use by other clients. Both {@link TokenParams} and {@link AuthOptions} are optional. + * When omitted or null, the default token parameters and authentication options for the client library are used, + * as specified in the {@link ClientOptions} when the client library was instantiated, + * or later updated with an explicit authorize request. + * Values passed in are used instead of, rather than being merged with, the default values. + * To understand why an Ably {@link TokenRequest} may be issued to clients in favor of a token, + * see Token Authentication explained. + *

+ * Spec: RSA9 + * @param params : A {@link TokenParams} object. + * @param options : An {@link AuthOptions} object. + * @return A {@link TokenRequest} object. * @throws AblyException */ public TokenRequest createTokenRequest(TokenParams params, AuthOptions options) throws AblyException { @@ -708,8 +890,7 @@ public TokenRequest createTokenRequest(TokenParams params, AuthOptions options) options = (options == null) ? this.authOptions : options.copy(); params = (params == null) ? this.tokenParams : params.copy(); - if(params.capability != null) - params.capability = Capability.c14n(params.capability); + params.capability = Capability.c14n(params.capability); TokenRequest request = new TokenRequest(params); String key = options.key; @@ -824,13 +1005,31 @@ public AuthOptions getAuthOptions() { * Renew auth credentials. * Will obtain a new token, even if we already have an apparently valid one. * Authorization will use the parameters supplied on construction. + * @deprecated Because the method returns early before renew() completes and does not provide a completion + * handler for callers. + * Please use {@link Auth#renewAuth} instead */ + @Deprecated public TokenDetails renew() throws AblyException { TokenDetails tokenDetails = assertValidToken(this.tokenParams, this.authOptions, true); ably.onAuthUpdated(tokenDetails.token, false); return tokenDetails; } + /** + * Renew auth credentials. + * Will obtain a new token, even if we already have an apparently valid one. + * Authorization will use the parameters supplied on construction. + * @param result Asynchronous result the completion + * Please note that completion callback {@link RenewAuthResult#onCompletion(boolean, TokenDetails, ErrorInfo)} + * is called on a background thread. + */ + public void renewAuth(RenewAuthResult result) throws AblyException { + final TokenDetails tokenDetails = assertValidToken(this.tokenParams, this.authOptions, true); + + ably.onAuthUpdatedAsync(tokenDetails.token, (success, errorInfo) -> result.onCompletion(success,tokenDetails,errorInfo)); + } + public void onAuthError(ErrorInfo err) { /* we're only interested in token expiry errors */ if(err.code >= 40140 && err.code < 40150) @@ -869,8 +1068,7 @@ public void onAuthError(ErrorInfo err) { /* decide default auth method (spec: RSA4) */ if(authOptions.key != null) { - if(options.clientId == null && - !options.useTokenAuth && + if(!options.useTokenAuth && options.token == null && options.tokenDetails == null && options.authCallback == null && @@ -923,14 +1121,14 @@ public String getEncodedToken() { private void setTokenDetails(String token) throws AblyException { Log.i("TokenAuth.setTokenDetails()", ""); this.tokenDetails = new TokenDetails(token); - this.encodedToken = Base64Coder.encodeString(token).replace("=", ""); + this.encodedToken = Base64Coder.encodeString(token); } private void setTokenDetails(TokenDetails tokenDetails) throws AblyException { Log.i("TokenAuth.setTokenDetails()", ""); setClientId(tokenDetails.clientId); this.tokenDetails = tokenDetails; - this.encodedToken = Base64Coder.encodeString(tokenDetails.token).replace("=", ""); + this.encodedToken = Base64Coder.encodeString(tokenDetails.token); } private void clearTokenDetails() { @@ -957,10 +1155,28 @@ private TokenDetails assertValidToken(TokenParams params, AuthOptions options, b } } Log.i("Auth.assertValidToken()", "requesting new token"); - setTokenDetails(requestToken(params, options)); + TokenDetails newTokenDetails; + try { + newTokenDetails = requestToken(params, options); + } catch (AblyException ablyException) { + if (shouldFailConnectionDueToAuthError(ablyException.errorInfo)) { + ably.onAuthError(ablyException.errorInfo); // RSA4d + } + throw ablyException; + } + setTokenDetails(newTokenDetails); return tokenDetails; } + /** + * RSA4d + * [...] the client library should transition to the FAILED state, with an ErrorInfo + * (with code 80019, statusCode 403, and cause set to the underlying cause) [...] + */ + private boolean shouldFailConnectionDueToAuthError(ErrorInfo errorInfo) { + return errorInfo.statusCode == 403 && errorInfo.code == 80019; + } + private boolean tokenValid(TokenDetails tokenDetails) { /* RSA4b1: only perform a local check for token validity if we have time sync with the server */ return (timeDelta == Long.MAX_VALUE) || (tokenDetails.expires > serverTimestamp()); @@ -992,7 +1208,7 @@ public String getAuthorizationHeader() { return authHeader; } - private static String random() { return String.format("%016d", (long)(Math.random() * 1E16)); } + private static String random() { return String.format(Locale.ROOT, "%016d", (long)(Math.random() * 1E16)); } private static boolean equalNullableStrings(String one, String two) { return (one == null) ? (two == null) : one.equals(two); diff --git a/lib/src/main/java/io/ably/lib/rest/ChannelBase.java b/lib/src/main/java/io/ably/lib/rest/ChannelBase.java index 9de0289f9..11958e7b4 100644 --- a/lib/src/main/java/io/ably/lib/rest/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/rest/ChannelBase.java @@ -38,6 +38,13 @@ public class ChannelBase { */ public final Presence presence; + /** + * Represents the annotations associated with a channel message. + * This field provides functionality for managing annotations. + */ + public final RestAnnotations annotations; + + /** * Publish a message on this channel using the REST API. * Since the REST API is stateless, this request is made independently @@ -48,23 +55,34 @@ public class ChannelBase { * @throws AblyException */ public void publish(String name, Object data) throws AblyException { - publishImpl(name, data).sync(); + publish(ably.http, name, data); + } + + void publish(Http http, String name, Object data) throws AblyException { + publishImpl(http, name, data).sync(); } /** * Publish a message on this channel using the REST API. * Since the REST API is stateless, this request is made independently * of any other request on this or any other channel. + * * @param name the event name * @param data the message payload; see {@link io.ably.types.Data} for - * @param listener + * @param listener a listener to be notified of the outcome of this message. + *

+ * This listener is invoked on a background thread. */ public void publishAsync(String name, Object data, CompletionListener listener) { - publishImpl(name, data).async(new CompletionListener.ToCallback(listener)); + publishAsync(ably.http, name, data, listener); } - private Http.Request publishImpl(String name, Object data) { - return publishImpl(new Message[] {new Message(name, data)}); + void publishAsync(Http http, String name, Object data, CompletionListener listener) { + publishImpl(http, name, data).async(new CompletionListener.ToCallback(listener)); + } + + private Http.Request publishImpl(Http http, String name, Object data) { + return publishImpl(http, new Message[] {new Message(name, data)}); } /** @@ -76,20 +94,31 @@ private Http.Request publishImpl(String name, Object data) { * @throws AblyException */ public void publish(final Message[] messages) throws AblyException { - publishImpl(messages).sync(); + publish(ably.http, messages); + } + + void publish(Http http, final Message[] messages) throws AblyException { + publishImpl(http, messages).sync(); } /** * Asynchronously publish an array of messages on this channel - * @param messages - * @param listener + * + * @param messages the message + * @param listener a listener to be notified of the outcome of this message. + *

+ * This listener is invoked on a background thread. */ public void publishAsync(final Message[] messages, final CompletionListener listener) { - publishImpl(messages).async(new CompletionListener.ToCallback(listener)); + publishAsync(ably.http, messages, listener); } - private Http.Request publishImpl(final Message[] messages) { - return ably.http.request(new Http.Execute() { + void publishAsync(Http http, final Message[] messages, final CompletionListener listener) { + publishImpl(http, messages).async(new CompletionListener.ToCallback(listener)); + } + + private Http.Request publishImpl(Http http, final Message[] messages) { + return http.request(new Http.Execute() { @Override public void execute(HttpScheduler http, final Callback callback) throws AblyException { /* handle message ids */ @@ -103,15 +132,16 @@ public void execute(HttpScheduler http, final Callback callback) throws Ab } if(!hasClientSuppliedId && ably.options.idempotentRestPublishing) { /* RSL1k1: populate the message id with a library-generated id */ - String messageId = Crypto.getRandomMessageId(); + String messageId = Crypto.getRandomId(); for (int i = 0; i < messages.length; i++) { messages[i].id = messageId + ':' + i; } } HttpCore.RequestBody requestBody = ably.options.useBinaryProtocol ? MessageSerializer.asMsgpackRequest(messages) : MessageSerializer.asJsonRequest(messages); + final Param[] params = ably.options.addRequestIds ? Param.array(Crypto.generateRandomRequestId()) : null; // RSC7c - http.post(basePath + "/messages", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), null, requestBody, null, true, callback); + http.post(basePath + "/messages", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, requestBody, null, true, callback); } }); } @@ -126,7 +156,11 @@ public void execute(HttpScheduler http, final Callback callback) throws Ab * @throws AblyException */ public PaginatedResult history(Param[] params) throws AblyException { - return historyImpl(params).sync(); + return history(ably.http, params); + } + + PaginatedResult history(Http http, Param[] params) throws AblyException { + return historyImpl(http, params).sync(); } /** @@ -136,68 +170,143 @@ public PaginatedResult history(Param[] params) throws AblyException { * @return */ public void historyAsync(Param[] params, Callback> callback) { - historyImpl(params).async(callback); + historyAsync(ably.http, params, callback); + } + + void historyAsync(Http http, Param[] params, Callback> callback) { + historyImpl(http, params).async(callback); } - private BasePaginatedQuery.ResultRequest historyImpl(Param[] params) { + private BasePaginatedQuery.ResultRequest historyImpl(Http http, Param[] initialParams) { HttpCore.BodyHandler bodyHandler = MessageSerializer.getMessageResponseHandler(options); - return (new BasePaginatedQuery(ably.http, basePath + "/messages", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler)).get(); + final Param[] params = ably.options.addRequestIds ? Param.set(initialParams, Crypto.generateRandomRequestId()) : initialParams; // RSC7c + return (new BasePaginatedQuery(http, basePath + "/messages", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler)).get(); } /** - * A class enabling access to Channel Presence information via the REST API. - * Since the library is stateless, REST clients are therefore never present - * themselves. This API enables the service to be queried to determine - * presence state for other clients on this channel. + * Enables the retrieval of the current and historic presence set for a channel. */ public class Presence { /** - * Get the presence state for this Channel. - * @return the current present members. + * Retrieves the current members present on the channel and the metadata for each member, + * such as their {@link io.ably.lib.types.PresenceMessage.Action} and ID. Returns a {@link PaginatedResult} object, + * containing an array of {@link PresenceMessage} objects. + *

+ * Spec: RSPa + * @param params the request params: + *

+ * limit (RSP3a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + *

+ * clientId (RSP3a2) - Filters the list of returned presence members by a specific client using its ID. + *

+ * connectionId (RSP3a3) - Filters the list of returned presence members by a specific connection using its ID. + * @return A {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. * @throws AblyException */ public PaginatedResult get(Param[] params) throws AblyException { - return getImpl(params).sync(); + return get(ably.http, params); + } + + PaginatedResult get(Http http, Param[] params) throws AblyException { + return getImpl(http, params).sync(); } /** - * Asynchronously get the presence state for this Channel. - * @param callback on success returns the currently present members. + * Asynchronously retrieves the current members present on the channel and the metadata for each member, + * such as their {@link io.ably.lib.types.PresenceMessage.Action} and ID. Returns a {@link PaginatedResult} object, + * containing an array of {@link PresenceMessage} objects. + *

+ * Spec: RSPa + * @param params the request params: + *

+ * limit (RSP3a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + *

+ * clientId (RSP3a2) - Filters the list of returned presence members by a specific client using its ID. + *

+ * connectionId (RSP3a3) - Filters the list of returned presence members by a specific connection using its ID. + * @param callback A Callback returning {@link AsyncPaginatedResult} object containing an array of {@link PresenceMessage} objects. + *

+ * This callback is invoked on a background thread. */ public void getAsync(Param[] params, Callback> callback) { - getImpl(params).async(callback); + getAsync(ably.http, params, callback); + } + + void getAsync(Http http, Param[] params, Callback> callback) { + getImpl(http, params).async(callback); } - private BasePaginatedQuery.ResultRequest getImpl(Param[] params) { + private BasePaginatedQuery.ResultRequest getImpl(Http http, Param[] initialParams) { HttpCore.BodyHandler bodyHandler = PresenceSerializer.getPresenceResponseHandler(options); - return (new BasePaginatedQuery(ably.http, basePath + "/presence", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler)).get(); + final Param[] params = ably.options.addRequestIds ? Param.set(initialParams, Crypto.generateRandomRequestId()) : initialParams; // RSC7c + return (new BasePaginatedQuery(http, basePath + "/presence", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler)).get(); } /** - * Asynchronously obtain presence history for this channel using the REST API. - * The history provided relqtes to all clients of this application, - * not just this instance. - * @param params the request params. See the Ably REST API - * documentation for more details. + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. + * If the channel is configured to persist messages, + * then presence messages can be retrieved from history for up to 72 hours in the past. + * If not, presence messages can only be retrieved from history for up to two minutes in the past. + *

+ * Spec: RSP4a + * @param params the request params: + *

+ * start (RSP4b1) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RSP4b1) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RSP4b2) - The order for which messages are returned in. + * Valid values are backwards which orders messages from most recent to oldest, + * or forwards which orders messages from oldest to most recent. + * The default is backwards. + * limit (RSP4b3) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @return A {@link PaginatedResult} object containing an array of {@link PresenceMessage} objects. + * @throws AblyException */ public PaginatedResult history(Param[] params) throws AblyException { - return historyImpl(params).sync(); + return history(ably.http, params); + } + + PaginatedResult history(Http http, Param[] params) throws AblyException { + return historyImpl(http, params).sync(); } /** - * Asynchronously obtain recent history for this channel using the REST API. - * @param params the request params. See the Ably REST API - * @param callback - * @return + * Asynchronously retrieves a {@link PaginatedResult} object, containing an array of historical {@link PresenceMessage} objects for the channel. + * If the channel is configured to persist messages, + * then presence messages can be retrieved from history for up to 72 hours in the past. + * If not, presence messages can only be retrieved from history for up to two minutes in the past. + *

+ * Spec: RSP4a + * @param params the request params: + *

+ * start (RSP4b1) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * end (RSP4b1) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + *

+ * direction (RSP4b2) - The order for which messages are returned in. + * Valid values are backwards which orders messages from most recent to oldest, + * or forwards which orders messages from oldest to most recent. + * The default is backwards. + * limit (RSP4b3) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @param callback A Callback returning {@link AsyncPaginatedResult} object containing an array of {@link PresenceMessage} objects. + *

+ * This callback is invoked on a background thread. + * @throws AblyException */ public void historyAsync(Param[] params, Callback> callback) { - historyImpl(params).async(callback); + historyAsync(ably.http, params, callback); + } + + void historyAsync(Http http, Param[] params, Callback> callback) { + historyImpl(http, params).async(callback); } - private BasePaginatedQuery.ResultRequest historyImpl(Param[] params) { + private BasePaginatedQuery.ResultRequest historyImpl(Http http, Param[] initialParams) { HttpCore.BodyHandler bodyHandler = PresenceSerializer.getPresenceResponseHandler(options); - return (new BasePaginatedQuery(ably.http, basePath + "/presence/history", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler)).get(); + final Param[] params = ably.options.addRequestIds ? Param.set(initialParams, Crypto.generateRandomRequestId()) : initialParams; // RSC7c + return (new BasePaginatedQuery(http, basePath + "/presence/history", HttpUtils.defaultAcceptHeaders(ably.options.useBinaryProtocol), params, bodyHandler)).get(); } } @@ -213,6 +322,7 @@ private BasePaginatedQuery.ResultRequest historyImpl(Param[] pa this.options = options; this.basePath = "/channels/" + HttpUtils.encodeURIComponent(name); this.presence = new Presence(); + this.annotations = new RestAnnotations(name, ably.http, ably.options, options); } private final AblyBase ably; diff --git a/lib/src/main/java/io/ably/lib/rest/DeviceDetails.java b/lib/src/main/java/io/ably/lib/rest/DeviceDetails.java index 3cb0b501d..a51af8e0a 100644 --- a/lib/src/main/java/io/ably/lib/rest/DeviceDetails.java +++ b/lib/src/main/java/io/ably/lib/rest/DeviceDetails.java @@ -8,18 +8,54 @@ import io.ably.lib.util.JsonUtils; import io.ably.lib.util.Serialisation; +/** + * Contains the properties of a device registered for push notifications. + */ public class DeviceDetails { + /** + * A unique ID generated by the device. + */ public String id; + /** + * The platform associated with the device. + * Describes the platform the device uses, such as android or java. + */ public String platform; + /** + * The device form factor associated with the device. + * Describes the type of the device, such as phone or tablet. + */ public String formFactor; + /** + * The client ID the device is connected to Ably with. + */ public String clientId; + /** + * A JSON object of key-value pairs that contains metadata for the device. + */ public JsonObject metadata; + /** + * The {@link Push} object associated with the device. + * Describes the details of the push registration of the device. + */ public Push push; + /** + * Contains the details of the push registration of a device. + */ public static class Push { + /** + * A JSON object of key-value pairs that contains of the push transport and address. + */ public JsonObject recipient; + /** + * The current state of the push registration. + */ public State state; + /** + * An {@link ErrorInfo} object describing the most recent error when the state is Failing or Failed. + */ public ErrorInfo errorReason; public JsonObject toJsonObject() { @@ -106,6 +142,9 @@ public boolean equals(Object o) { thisJson.remove("deviceSecret"); otherJson.remove("deviceSecret"); + normalizeRecipientField(thisJson); + normalizeRecipientField(otherJson); + if ((this.metadata == null || this.metadata.entrySet().isEmpty()) && (other.metadata == null || other.metadata.entrySet().isEmpty())) { // Empty metadata == null metadata. thisJson.remove("metadata"); @@ -134,4 +173,20 @@ public DeviceDetails fromJsonElement(JsonElement e) { public static HttpCore.ResponseHandler httpResponseHandler = new Serialisation.HttpResponseHandler(DeviceDetails.class, fromJsonElement); public static HttpCore.BodyHandler httpBodyHandler = new Serialisation.HttpBodyHandler(DeviceDetails[].class, fromJsonElement); + + /** + * Push recipient can contain some additional field, but `transportType`, `deviceToken`, `registrationToken` only matters for equals + */ + private static void normalizeRecipientField(JsonObject deviceDetailsJson) { + JsonElement push = deviceDetailsJson.get("push"); + if (push == null) return; + JsonElement recipient = push.getAsJsonObject().get("recipient"); + if (recipient == null) return; + JsonObject normalizedRecipient = JsonUtils.object() + .add("transportType", recipient.getAsJsonObject().get("transportType")) + .add("deviceToken", recipient.getAsJsonObject().get("deviceToken")) + .add("registrationToken", recipient.getAsJsonObject().get("registrationToken")) + .toJson(); + push.getAsJsonObject().add("recipient", normalizedRecipient); + } } diff --git a/lib/src/main/java/io/ably/lib/rest/RestAnnotations.java b/lib/src/main/java/io/ably/lib/rest/RestAnnotations.java new file mode 100644 index 000000000..7c0931ac4 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/rest/RestAnnotations.java @@ -0,0 +1,288 @@ +package io.ably.lib.rest; + +import io.ably.lib.http.BasePaginatedQuery; +import io.ably.lib.http.Http; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpUtils; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Annotation; +import io.ably.lib.types.AnnotationAction; +import io.ably.lib.types.AnnotationSerializer; +import io.ably.lib.types.AsyncPaginatedResult; +import io.ably.lib.types.Callback; +import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.MessageDecodeException; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import io.ably.lib.util.Crypto; +import io.ably.lib.util.Log; + +import java.util.Arrays; + +/** + * The RestAnnotation class provides methods to manage and interact with annotations + * associated with messages in a specific channel. + *

+ * Annotations can be retrieved, published, or deleted both synchronously and asynchronously. + * This class is intended as part of a client library for managing annotations via REST architecture. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + */ +public class RestAnnotations { + + private static final String TAG = RestAnnotations.class.getName(); + + private final String channelName; + private final Http http; + private final ClientOptions clientOptions; + private final ChannelOptions channelOptions; + + public RestAnnotations(String channelName, Http http, ClientOptions clientOptions, ChannelOptions channelOptions) { + this.channelName = channelName; + this.http = http; + this.clientOptions = clientOptions; + this.channelOptions = channelOptions; + } + + /** + * Retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param params an array of query parameters for filtering or modifying the request. + * @return a {@link PaginatedResult} containing the matching annotations. + * @throws AblyException if an error occurs during the retrieval process. + */ + public PaginatedResult get(String messageSerial, Param[] params) throws AblyException { + validateMessageSerial(messageSerial); + return getImpl(messageSerial, params).sync(); + } + + /** + * @see #get(String, Param[]) + */ + public PaginatedResult get(Message message, Param[] params) throws AblyException { + return get(message.serial, params); + } + + /** + * Asynchronously retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param params an array of query parameters for filtering or modifying the request. + * @param callback a callback to handle the result asynchronously, providing an {@link AsyncPaginatedResult} containing the matching annotations. + */ + public void getAsync(String messageSerial, Param[] params, Callback> callback) throws AblyException { + validateMessageSerial(messageSerial); + getImpl(messageSerial, params).async(callback); + } + + /** + * @see #getAsync(String, Param[], Callback) + */ + public void getAsync(Message message, Param[] params, Callback> callback) throws AblyException { + getAsync(message.serial, params, callback); + } + + /** + * Retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated + * @return a PaginatedResult containing the matching annotations + * @throws AblyException if an error occurs during the retrieval process + */ + public PaginatedResult get(String messageSerial) throws AblyException { + return get(messageSerial, null); + } + + /** + * @see #get(String) + */ + public PaginatedResult get(Message message) throws AblyException { + return get(message.serial); + } + + /** + * Asynchronously retrieves a paginated list of annotations associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param callback a callback to handle the result asynchronously, providing an {@link AsyncPaginatedResult} containing the matching annotations. + */ + public void getAsync(String messageSerial, Callback> callback) throws AblyException { + validateMessageSerial(messageSerial); + getImpl(messageSerial, null).async(callback); + } + + /** + * @see #getAsync(String, Callback) + */ + public void getAsync(Message message, Callback> callback) throws AblyException { + getAsync(message.serial, callback); + } + + /** + * Publishes an annotation associated with the specified message serial + * to the REST channel. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param annotation the annotation to be published. + * @throws AblyException if an error occurs during the publishing process. + */ + public void publish(String messageSerial, Annotation annotation) throws AblyException { + validateMessageSerial(messageSerial); + publishImpl(messageSerial, annotation).sync(); + } + + /** + * @see #publish(String, Annotation) + */ + public void publish(Message message, Annotation annotation) throws AblyException { + publish(message.serial, annotation); + } + + /** + * Asynchronously publishes an annotation associated with the specified message serial + * to the REST channel. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param annotation the annotation to be published. + * @param callback a callback to handle the result asynchronously, providing a + * completion indication or error information. + */ + public void publishAsync(String messageSerial, Annotation annotation, Callback callback) throws AblyException { + validateMessageSerial(messageSerial); + publishImpl(messageSerial, annotation).async(callback); + } + + /** + * @see #publishAsync(String, Annotation, Callback) + */ + public void publishAsync(Message message, Annotation annotation, Callback callback) throws AblyException { + publishAsync(message.serial, annotation, callback); + } + + /** + * Deletes an annotation associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param annotation the annotation to be deleted. + * @throws AblyException if an error occurs during the deletion process. + */ + public void delete(String messageSerial, Annotation annotation) throws AblyException { + validateMessageSerial(messageSerial); + deleteImpl(messageSerial, annotation).sync(); + } + + /** + * @see #delete(String, Annotation) + */ + public void delete(Message message, Annotation annotation) throws AblyException { + delete(message.serial, annotation); + } + + /** + * Asynchronously deletes an annotation associated with the specified message serial. + *

+ * Note: This is an experimental API. While the underlying functionality is stable, + * the public API may change in future releases. + * + * @param messageSerial the unique serial identifier for the message being annotated. + * @param annotation the annotation to be deleted. + * @param callback a callback to handle the result asynchronously, providing a completion + * indication or error information. + */ + public void deleteAsync(String messageSerial, Annotation annotation, Callback callback) throws AblyException { + validateMessageSerial(messageSerial); + deleteImpl(messageSerial, annotation).async(callback); + } + + /** + * @see #deleteAsync(String, Annotation, Callback) + */ + public void deleteAsync(Message message, Annotation annotation, Callback callback) throws AblyException { + deleteAsync(message.serial, annotation, callback); + } + + private void validateMessageSerial(String messageSerial) throws AblyException { + if (messageSerial == null) throw AblyException.fromErrorInfo( + new ErrorInfo("Message serial can not be empty", 400, 40003) + ); + } + + private String getBasePath(String messageSerial) { + return "/channels/" + HttpUtils.encodeURIComponent(channelName) + "/messages/" + HttpUtils.encodeURIComponent(messageSerial) + "/annotations"; + } + + private Http.Request deleteImpl(String messageSerial, Annotation annotation) throws AblyException { + Log.v(TAG, "delete(): annotation=" + annotation); + annotation.action = AnnotationAction.ANNOTATION_DELETE; + return sendAnnotationImpl(messageSerial, annotation); + } + + private Http.Request publishImpl(String messageSerial, Annotation annotation) throws AblyException { + Log.v(TAG, "publish(): annotation=" + annotation); + // (RSAN1c2) + annotation.action = AnnotationAction.ANNOTATION_CREATE; + return sendAnnotationImpl(messageSerial, annotation); + } + + private Http.Request sendAnnotationImpl(String messageSerial, Annotation annotation) throws AblyException { + // (RSAN1a3) + if (annotation.type == null) { + throw AblyException.fromErrorInfo(new ErrorInfo("Annotation type must be specified", 400, 40000)); + } + + // (RSAN1c1) + annotation.messageSerial = messageSerial; + + try { + // (RSAN1c3) + annotation.encode(channelOptions); + } catch (MessageDecodeException e) { + throw AblyException.fromThrowable(e); + } + + // (RSAN1c4) + if (annotation.id == null && clientOptions.idempotentRestPublishing) { + annotation.id = Crypto.getRandomId(); + } + + return http.request((http, callback) -> { + Annotation[] annotations = new Annotation[] { annotation }; + HttpCore.RequestBody requestBody = clientOptions.useBinaryProtocol ? AnnotationSerializer.asMsgpackRequest(annotations) : AnnotationSerializer.asJsonRequest(annotations); + final Param[] params = clientOptions.addRequestIds ? Param.array(Crypto.generateRandomRequestId()) : null; // RSC7c + http.post(getBasePath(messageSerial), HttpUtils.defaultAcceptHeaders(clientOptions.useBinaryProtocol), params, requestBody, null, true, callback); + }); + } + + private BasePaginatedQuery.ResultRequest getImpl(String messageSerial, Param[] initialParams) { + Log.v(TAG, "getImpl(): params=" + Arrays.toString(initialParams)); + HttpCore.BodyHandler bodyHandler = AnnotationSerializer.getAnnotationResponseHandler(channelOptions); + final Param[] params = clientOptions.addRequestIds ? Param.set(initialParams, Crypto.generateRandomRequestId()) : initialParams; // RSC7c + return (new BasePaginatedQuery<>(http, getBasePath(messageSerial), HttpUtils.defaultAcceptHeaders(clientOptions.useBinaryProtocol), params, bodyHandler)).get(); + } +} diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index 249b932b4..5e5638fb1 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -1,17 +1,32 @@ package io.ably.lib.transport; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + import io.ably.lib.debug.DebugOptions; import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpHelpers; +import io.ably.lib.objects.ObjectsPlugin; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; import io.ably.lib.realtime.Connection; import io.ably.lib.realtime.ConnectionState; import io.ably.lib.realtime.ConnectionStateListener; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; +import io.ably.lib.rest.Auth; import io.ably.lib.transport.ITransport.ConnectListener; import io.ably.lib.transport.ITransport.TransportParams; +import io.ably.lib.transport.NetworkConnectivity.NetworkConnectivityListener; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ConnectionDetails; @@ -19,15 +34,11 @@ import io.ably.lib.types.ProtocolMessage; import io.ably.lib.types.ProtocolSerializer; import io.ably.lib.util.Log; -import io.ably.lib.transport.NetworkConnectivity.NetworkConnectivityListener; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; +import io.ably.lib.util.PlatformAgentProvider; +import io.ably.lib.util.ReconnectionStrategy; public class ConnectionManager implements ConnectListener { + final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); /************************************************************** * ConnectionManager @@ -62,6 +73,34 @@ public class ConnectionManager implements ConnectListener { static ErrorInfo REASON_REFUSED = new ErrorInfo("Access refused", 401, 40100); static ErrorInfo REASON_TOO_BIG = new ErrorInfo("Connection closed; message too large", 400, 40000); + /** + * When connection manager entering terminal state {@code currentState.terminal == true} it should clean up + * {@link #handlerThread} and invoke {@link #stopConnectivityListener}. + *

+ * If this flag is true that means that current state is terminal but cleaning up still in progress + */ + private boolean cleaningUpAfterEnteringTerminalState = false; + + /** + * Indicates whether a close request has been initiated for the connection. + *

+ * This variable is set to true when a close request is made, typically to + * signal that the connection should transition into a closing state. + * It helps manage the connection lifecycle, ensuring that no further + * operations for this connection are attempted once closure is requested. + *

+ * Default value is false, indicating the connection remains active unless + * explicitly requested to close. + */ + private volatile boolean closeRequested = false; + + /** + * A nullable reference to the LiveObjects plugin. + *

+ * This field is initialized only if the LiveObjects plugin is present in the classpath. + */ + private final ObjectsPlugin objectsPlugin; + /** * Methods on the channels map owned by the {@link AblyRealtime} instance * which the {@link ConnectionManager} needs access to. @@ -70,6 +109,8 @@ public interface Channels { void onMessage(ProtocolMessage msg); void suspendAll(ErrorInfo error, boolean notifyStateChange); Iterable values(); + + void transferToChannelQueue(List queuedMessages); } /*********************************** @@ -112,7 +153,7 @@ public abstract class State { public final boolean sendEvents; final boolean terminal; - public final long timeout; + public long timeout; State(ConnectionState state, boolean queueEvents, boolean sendEvents, boolean terminal, long timeout, ErrorInfo defaultErrorInfo) { this.state = state; @@ -213,6 +254,11 @@ StateIndication validateTransition(StateIndication target) { @Override void enact(StateIndication stateIndication, ConnectionStateChange change) { super.enact(stateIndication, change); + + if (hasConnectBeenInvokeOnClosedOrFailedState(change)) { + cleanMsgSerialAndErrorReason(); + } + connectImpl(stateIndication); } } @@ -243,6 +289,12 @@ StateIndication validateTransition(StateIndication target) { void enactForChannel(StateIndication stateIndication, ConnectionStateChange change, Channel channel) { channel.setConnected(); } + + @Override + void enact(StateIndication stateIndication, ConnectionStateChange change) { + super.enact(stateIndication, change); + pendingConnect = null; + } } /************************************************** @@ -254,7 +306,7 @@ void enactForChannel(StateIndication stateIndication, ConnectionStateChange chan class Disconnected extends State { Disconnected() { - super(ConnectionState.disconnected, true, false, false, Defaults.TIMEOUT_DISCONNECT, REASON_DISCONNECTED); + super(ConnectionState.disconnected, true, false, false, ably.options.disconnectedRetryTimeout, REASON_DISCONNECTED); } @Override @@ -287,10 +339,13 @@ void enactForChannel(StateIndication stateIndication, ConnectionStateChange chan void enact(StateIndication stateIndication, ConnectionStateChange change) { super.enact(stateIndication, change); clearTransport(); + + // If we were connected, immediately retry if(change.previous == ConnectionState.connected) { setSuspendTime(); - /* we were connected, so retry immediately */ - if(!suppressRetry) { + + if (!suppressRetry) { + Log.v(TAG, "Was previously connected, retrying immediately"); requestState(ConnectionState.connecting); } } @@ -306,7 +361,7 @@ void enact(StateIndication stateIndication, ConnectionStateChange change) { class Suspended extends State { Suspended() { - super(ConnectionState.suspended, false, false, false, Defaults.connectionStateTtl, REASON_SUSPENDED); + super(ConnectionState.suspended, false, false, false, ably.options.suspendedRetryTimeout, REASON_SUSPENDED); } @Override @@ -368,8 +423,9 @@ StateIndication onTimeout() { @Override void enact(StateIndication stateIndication, ConnectionStateChange change) { super.enact(stateIndication, change); - boolean closed = closeImpl(); - if(closed) { + boolean shouldAwaitConnection = change.previous == ConnectionState.connecting; + boolean closed = closeImpl(shouldAwaitConnection); + if (closed) { addAction(new AsynchronousStateChangeAction(ConnectionState.closed)); } } @@ -453,14 +509,18 @@ public boolean isActive() { return currentState.queueEvents || currentState.sendEvents; } - /************************************* - * a class that listens for currentState change - * events for in-place authorization - *************************************/ - + /** + * Listens for connection state changes. + * + * The close() method must be called when the ConnectionWaiter is no longer needed. + */ private class ConnectionWaiter implements ConnectionStateListener { private ConnectionStateChange change; + private boolean closed = false; + /** + * Create a ConnectionWaiter as a connection listener. + */ private ConnectionWaiter() { connection.on(this); } @@ -469,6 +529,10 @@ private ConnectionWaiter() { * Wait for a currentState change notification */ private synchronized ErrorInfo waitForChange() { + if (closed) { + throw new IllegalStateException("Already closed."); + } + Log.d(TAG, "ConnectionWaiter.waitFor()"); if (change == null) { try { wait(); } catch(InterruptedException e) {} @@ -483,11 +547,22 @@ private synchronized ErrorInfo waitForChange() { * ConnectionStateListener interface */ @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - synchronized(this) { - change = state; - notify(); + public synchronized void onConnectionStateChanged(ConnectionStateChange state) { + change = state; + notify(); + } + + /** + * Remove this ConnectionWaiter as a connection listener. + */ + private void close() { + // This method is explicitly not synchronized. There may be a case for this in the + // future, however its addition is designed to be lightweight with minimal impact. + if (closed) { + return; } + closed = true; + connection.off(this); } } @@ -656,6 +731,8 @@ public void run() { /* indicate that this thread is committed to die */ handlerThread = null; stopConnectivityListener(); + cleaningUpAfterEnteringTerminalState = false; + ConnectionManager.this.notifyAll(); return; } @@ -696,10 +773,12 @@ public void run() { * ConnectionManager ***********************/ - public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels) throws AblyException { + public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider, ObjectsPlugin objectsPlugin) throws AblyException { this.ably = ably; this.connection = connection; this.channels = channels; + this.platformAgentProvider = platformAgentProvider; + this.objectsPlugin = objectsPlugin; ClientOptions options = ably.options; this.hosts = new Hosts(options.realtimeHost, Defaults.HOST_REALTIME, options); @@ -749,12 +828,24 @@ public synchronized State getConnectionState() { public synchronized void connect() { /* connect() is the only action that will bring the ConnectionManager out of a terminal currentState */ if(currentState.terminal || currentState.state == ConnectionState.initialized) { - startup(); + try { + startup(); + } catch(InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "Failed to start up connection", e); + return; + } + } + if (closeRequested || currentState.terminal) { + // (RTN11d) + reinitializeChannelsAfterReconnect(); + closeRequested = false; } requestState(ConnectionState.connecting); } public void close() { + closeRequested = true; requestState(ConnectionState.closing); } @@ -785,16 +876,35 @@ private synchronized ConnectionStateChange setState(ITransport transport, StateI return null; } + if (stateIndication.state == ConnectionState.connected || stateIndication.state == ConnectionState.suspended) { + this.disconnectedRetryAttempt = 0; + } + + if (stateIndication.state == ConnectionState.disconnected) { + states.get(ConnectionState.disconnected).timeout = + ReconnectionStrategy.getRetryTime(ably.options.disconnectedRetryTimeout, ++disconnectedRetryAttempt); + } + + // RTN8c, RTN9c + if (stateIndication.state == ConnectionState.closing || stateIndication.state == ConnectionState.closed + || stateIndication.state == ConnectionState.suspended || stateIndication.state == ConnectionState.failed) { + connection.id = null; + connection.key = null; + } + /* update currentState */ ConnectionState newConnectionState = validatedStateIndication.state; State newState = states.get(newConnectionState); + ErrorInfo reason = validatedStateIndication.reason; if (reason == null) { reason = newState.defaultErrorInfo; } Log.v(TAG, "setState(): setting " + newState.state + "; reason " + reason); ConnectionStateChange change = new ConnectionStateChange(currentState.state, newConnectionState, newState.timeout, reason); + currentState = newState; + cleaningUpAfterEnteringTerminalState = currentState.terminal; stateError = reason; return change; @@ -881,9 +991,84 @@ public void run() { * the current connection to use that token; or if not currently connected, * to connect with the token. */ - public void onAuthUpdated(String token, boolean waitForResponse) throws AblyException { - ConnectionWaiter waiter = new ConnectionWaiter(); - switch(currentState.state) { + public void onAuthUpdated(final String token, final boolean waitForResponse) throws AblyException { + final ConnectionWaiter waiter = new ConnectionWaiter(); + try { + switch(currentState.state) { + case connected: + /* (RTC8a) If the connection is in the CONNECTED currentState and + * auth.authorize is called or Ably requests a re-authentication + * (see RTN22), the client must obtain a new token, then send an + * AUTH ProtocolMessage to Ably with an auth attribute + * containing an AuthDetails object with the token string. */ + try { + ProtocolMessage msg = new ProtocolMessage(ProtocolMessage.Action.auth); + msg.auth = new ProtocolMessage.AuthDetails(token); + send(msg, false, null); + } catch (AblyException e) { + /* The send failed. Close the transport; if a subsequent + * reconnect succeeds, it will be with the new token. */ + Log.v(TAG, "onAuthUpdated: closing transport after send failure"); + transport.close(); + } + break; + + case connecting: + /* Close the connecting transport. */ + Log.v(TAG, "onAuthUpdated: closing connecting transport"); + ErrorInfo disconnectError = new ErrorInfo("Aborting incomplete connection with superseded auth params", 503, 80003); + requestState(new StateIndication(ConnectionState.disconnected, disconnectError, null, null)); + /* Start a new connection attempt. */ + connect(); + break; + + default: + /* Start a new connection attempt. */ + connect(); + break; + } + + if(!waitForResponse) { + return; + } + + /* Wait for a currentState transition into anything other than connecting or + * disconnected. Note that this includes the case that the connection + * was already connected, and the AUTH message prompted the server to + * send another connected message. */ + boolean waitingForConnected = true; + while (waitingForConnected) { + final ErrorInfo reason = waiter.waitForChange(); + final ConnectionState connectionState = currentState.state; + switch (connectionState) { + case connected: + Log.v(TAG, "onAuthUpdated: got connected"); + waitingForConnected = false; + break; + + case connecting: + case disconnected: + Log.v(TAG, "onAuthUpdated: " + connectionState); + break; + + default: + /* suspended/closed/error: throw the error. */ + Log.v(TAG, "onAuthUpdated: throwing exception"); + throw AblyException.fromErrorInfo(reason); + } + } + } finally { + waiter.close(); + } + } + + + /** + * Async version of onAuthUpdated that returns a Future that includes an option Ably exception + **/ + public void onAuthUpdatedAsync(final String token, final Auth.AuthUpdateResult authUpdateResult) { + final ConnectionWaiter waiter = new ConnectionWaiter(); + switch (currentState.state) { case connected: /* (RTC8a) If the connection is in the CONNECTED currentState and * auth.authorize is called or Ably requests a re-authentication @@ -917,29 +1102,35 @@ public void onAuthUpdated(String token, boolean waitForResponse) throws AblyExce break; } - if(!waitForResponse) { - return; - } - /* Wait for a currentState transition into anything other than connecting or - * disconnected. Note that this includes the case that the connection - * was already connected, and the AUTH message prompted the server to - * send another connected message. */ - for (;;) { - ErrorInfo reason = waiter.waitForChange(); - switch (currentState.state) { - case connected: - Log.v(TAG, "onAuthUpdated: got connected"); - return; - case connecting: - case disconnected: - continue; - default: - /* suspended/closed/error: throw the error. */ - Log.v(TAG, "onAuthUpdated: throwing exception"); - throw AblyException.fromErrorInfo(reason); + * disconnected in a background thread */ + singleThreadExecutor.execute(() -> { + boolean waitingForConnected = true; + while (waitingForConnected) { + final ErrorInfo reason = waiter.waitForChange(); + final ConnectionState connectionState = currentState.state; + switch (connectionState) { + case connected: + authUpdateResult.onUpdate(true, null); + Log.v(TAG, "onAuthUpdated: got connected"); + waitingForConnected = false; + break; + + case connecting: + case disconnected: + Log.v(TAG, "onAuthUpdated: " + connectionState); + break; + + default: + /* suspended/closed/error: throw the error. */ + Log.v(TAG, "onAuthUpdated: throwing exception"); + authUpdateResult.onUpdate(false, reason); + waitingForConnected = false; + } } - } + waiter.close(); + }); + } /** @@ -948,7 +1139,20 @@ public void onAuthUpdated(String token, boolean waitForResponse) throws AblyExce * @param errorInfo Error associated with unsuccessful authentication */ public void onAuthError(ErrorInfo errorInfo) { - Log.i(TAG, String.format("onAuthError: (%d) %s", errorInfo.code, errorInfo.message)); + Log.i(TAG, String.format(Locale.ROOT, "onAuthError: (%d) %s", errorInfo.code, errorInfo.message)); + + if(errorInfo.statusCode == 403) { + ConnectionStateChange failedStateChange = + new ConnectionStateChange( + connection.state, + ConnectionState.failed, + 0, + errorInfo); + + this.connection.onConnectionStateChange(failedStateChange); + return; + } + switch (currentState.state) { case connecting: ITransport transport = this.transport; @@ -981,6 +1185,7 @@ public void onMessage(ITransport transport, ProtocolMessage message) throws Ably if (transport != null && this.transport != transport) { return; } + // Check the logging level to avoid performance hit associated with building the message if (Log.level <= Log.VERBOSE) { Log.v(TAG, "onMessage() (transport = " + transport + "): " + message.action + ": " + new String(ProtocolSerializer.writeJSON(message))); } @@ -1008,7 +1213,13 @@ public void onMessage(ITransport transport, ProtocolMessage message) throws Ably } break; case connected: - onConnected(message); + if (currentState.state == ConnectionState.closing) { + // Based on RTN12f, if a connected protocol message comes while in the closing state, + // send a close protocol message. + if (!trySendCloseProtocolMessage()) requestState(ConnectionState.closed); + } else { + onConnected(message); + } break; case disconnect: case disconnected: @@ -1026,6 +1237,16 @@ public void onMessage(ITransport transport, ProtocolMessage message) throws Ably case auth: addAction(new ReauthAction()); break; + case object: + case object_sync: + if (objectsPlugin != null) { + try { + objectsPlugin.handle(message); + } catch (Throwable t) { + Log.e(TAG, "objectsPlugin threw while handling message", t); + } + } + break; default: onChannelMessage(message); } @@ -1037,55 +1258,45 @@ public void onMessage(ITransport transport, ProtocolMessage message) throws Ably } private void onChannelMessage(ProtocolMessage message) { - if(message.connectionSerial != null) { - connection.serial = message.connectionSerial.longValue(); - if (connection.key != null) - connection.recoveryKey = connection.key + ":" + message.connectionSerial; - } channels.onMessage(message); + connection.recoveryKey = connection.createRecoveryKey(); } private synchronized void onConnected(ProtocolMessage message) { - /* if the returned connection id differs from - * the existing connection id, then this means - * we need to suspend all existing attachments to - * the old connection. - * If realtime did not reply with an error, it - * signifies that this was a result of an earlier - * connection being invalidated due to being stale. - * - * Suspend all channels attached to the previous id; - * this will be reattached in setConnection() */ - ErrorInfo error = message.error; - if(connection.id != null && !message.connectionId.equals(connection.id)) { - /* we need to suspend the original connection */ - if(error == null) { - error = REASON_SUSPENDED; - } - channels.suspendAll(error, false); - } + ably.options.recover = null; // RTN16k, explicitly setting null, so it won't be used for subsequent connection requests + connection.reason = message.error; + + if (connection.id != null) { // there was a previous connection, so this is a resume and RTN15c applies + Log.d(TAG, "There was a connection resume"); + if(message.connectionId.equals(connection.id)) { // RTN15c6 - resume success + if(message.error == null) { + Log.d(TAG, "connection has reconnected and resumed successfully"); + } else { + Log.d(TAG, "connection resume success with non-fatal error: " + message.error.message); + } + addPendingMessagesToQueuedMessages(false); + } else { // RTN15c7, RTN16d - resume failure + if (message.error != null) { + Log.d(TAG, "connection resume failed with error: " + message.error.message); + } else { // This shouldn't happen but, putting it here for safety + Log.d(TAG, "connection resume failed without error" ); + } - /* set the new connection id */ - ConnectionDetails connectionDetails = message.connectionDetails; - connection.key = connectionDetails.connectionKey; - if (!message.connectionId.equals(connection.id)) { - /* The connection id has changed. Reset the message serial and the - * pending message queue (which fails the messages currently in - * there). */ - pendingMessages.reset(msgSerial, - new ErrorInfo("Connection resume failed", 500, 50000)); + addPendingMessagesToQueuedMessages(true); + channels.transferToChannelQueue(extractConnectionQueuePresenceMessages()); + } + } else { msgSerial = 0; } + connection.id = message.connectionId; - if(message.connectionSerial != null) { - connection.serial = message.connectionSerial.longValue(); - if (connection.key != null) - connection.recoveryKey = connection.key + ":" + message.connectionSerial; - } + ConnectionDetails connectionDetails = message.connectionDetails; /* Get any parameters from connectionDetails. */ + connection.key = connectionDetails.connectionKey; //RTN16d maxIdleInterval = connectionDetails.maxIdleInterval; connectionStateTtl = connectionDetails.connectionStateTtl; + maxMessageSize = connectionDetails.maxMessageSize; /* set the clientId resolved from token, if any */ String clientId = connectionDetails.clientId; @@ -1096,9 +1307,54 @@ private synchronized void onConnected(ProtocolMessage message) { return; } + connection.recoveryKey = connection.createRecoveryKey(); + /* indicated connected currentState */ - setSuspendTime(); - requestState(new StateIndication(ConnectionState.connected, error)); + final StateIndication stateIndication = new StateIndication(ConnectionState.connected, message.error, null, null); + requestState(stateIndication); + } + + /* + This method removes all messages in queuedMessages which has presence in them, moves them to a new + list and returns them. We can't yet use Java 8's stream and predicates for this purpose as we support below + Android v24. + * */ + private synchronized List extractConnectionQueuePresenceMessages() { + final Iterator queuedIterator = queuedMessages.iterator(); + final List queuedPresenceMessages = new ArrayList<>(); + while (queuedIterator.hasNext()){ + final QueuedMessage queuedMessage = queuedIterator.next(); + if (queuedMessage.msg.presence != null){ + queuedPresenceMessages.add(queuedMessage); + queuedIterator.remove(); + } + } + return queuedPresenceMessages; + } + + /** + * Add all pending queued messages to the front of QueuedMessages for them to be sent later + * Spec: RTN19a, RTN19a1, RTN19a2 + * @param resetMessageSerial whether to reset message serial, this will determine whether to reset message serials + * on pending queue, for example when a connection resume failed + */ + private void addPendingMessagesToQueuedMessages(boolean resetMessageSerial) { + synchronized (this) { + List allPendingMessages = pendingMessages.popAll(); + + if (resetMessageSerial){ // failed resume, so all new published messages start with msgSerial = 0 + msgSerial = 0; //msgSerial will increase in sendImpl when messages are sent, RTN15c7 + } else if (!allPendingMessages.isEmpty()) { // pendingMessages needs to expect next msgSerial to be the earliest previously unacknowledged message + msgSerial = allPendingMessages.get(0).msg.msgSerial; + } + + // Add messages from pending messages to front of queuedMessages in order to retry them + queuedMessages.addAll(0, allPendingMessages); + } + } + + public List getPendingMessages() { + return pendingMessages.queue; } private synchronized void onDisconnected(ProtocolMessage message) { @@ -1147,10 +1403,17 @@ private void onHeartbeat(ProtocolMessage message) { * ConnectionManager lifecycle ******************************/ - private synchronized void startup() { - if(handlerThread == null) { + private synchronized void startup() throws InterruptedException { + while (cleaningUpAfterEnteringTerminalState) { + Log.v(TAG, "Waiting for termination action to clean up handler thread"); + wait(); + } + + if (handlerThread == null) { (handlerThread = new Thread(new ActionHandler())).start(); startConnectivityListener(); + } else { + Log.v(TAG, "`connect()` has been called twice on uninitialized or terminal state"); } } @@ -1256,12 +1519,24 @@ public synchronized void onTransportAvailable(ITransport transport) { @Override public synchronized void onTransportUnavailable(ITransport transport, ErrorInfo reason) { + Log.v(TAG, "onTransportUnavailable()"); if (this.transport != transport) { /* This is from a transport that we have already abandoned. */ Log.v(TAG, "onTransportUnavailable: ignoring disconnection event from superseded transport"); return; } + // If we're currently connected, start the suspend timer + if (currentState.state == ConnectionState.connected) { + setSuspendTime(); + } + + // Do not fallback for closing + if (currentState.state == ConnectionState.closing) { + requestState(ConnectionState.closed); + return; + } + /* if this is a failure of a pending connection attempt, decide whether or not to attempt a fallback host */ StateIndication fallbackAttempt = checkFallback(reason); if(fallbackAttempt != null) { @@ -1285,10 +1560,9 @@ public synchronized void onTransportUnavailable(ITransport transport, ErrorInfo } private class ConnectParams extends TransportParams { - ConnectParams(ClientOptions options) { - super(options); + ConnectParams(ClientOptions options, PlatformAgentProvider platformAgentProvider) { + super(options, platformAgentProvider); this.connectionKey = connection.key; - this.connectionSerial = String.valueOf(connection.serial); this.port = Defaults.getPort(options); } } @@ -1306,7 +1580,7 @@ private void connectImpl(StateIndication request) { host = hosts.getPreferredHost(); } checkConnectionStale(); - pendingConnect = new ConnectParams(ably.options); + pendingConnect = new ConnectParams(ably.options, platformAgentProvider); pendingConnect.host = host; lastUsedHost = host; @@ -1327,39 +1601,76 @@ private void connectImpl(StateIndication request) { if (oldTransport != null) { oldTransport.close(); } + transport.connect(this); if(protocolListener != null) { protocolListener.onRawConnectRequested(transport.getURL()); } } + /** + * (RTN11d) + */ + private void cleanMsgSerialAndErrorReason() { + this.msgSerial = 0; + this.connection.reason = null; + } + + /** + * (RTN11d) + */ + private void reinitializeChannelsAfterReconnect() { + for (final Channel channel : channels.values()) { + // (RTN11b) + if (channel.state == ChannelState.attached || channel.state == ChannelState.attaching) { + channel.setConnectionClosed(REASON_CLOSED); + } + + // (RTN11d) + channel.setReinitialized(); + } + } + + private boolean hasConnectBeenInvokeOnClosedOrFailedState(ConnectionStateChange change) { + return change.previous == ConnectionState.failed + || change.previous == ConnectionState.closed + || change.previous == ConnectionState.closing; + } + /** * Close any existing transport + * @param shouldAwaitConnection true if `CONNECTING` state, moves immediately to `CLOSING` * @return closed if true, otherwise awaiting closed indication */ - private boolean closeImpl() { - if(transport == null) { + private boolean closeImpl(boolean shouldAwaitConnection) { + if (transport == null) { return true; } - /* if connected, send an explicit close message and await response */ - boolean isConnected = currentState.state == ConnectionState.connected; - if(isConnected) { - try { - Log.v(TAG, "Requesting connection close"); - transport.send(new ProtocolMessage(ProtocolMessage.Action.close)); - return false; - } catch (AblyException e) { - /* we're closing, and the attempt to send the CLOSE message failed; - * continue, because we're not going to reinstate the transport - * just to send a CLOSE message */ - } + // Based on RTN12f we need to wait until connected protocol message come + if (shouldAwaitConnection) { + return false; } - /* just close the transport */ - Log.v(TAG, "Closing incomplete transport"); - clearTransport(); - return true; + return !trySendCloseProtocolMessage(); + } + + /** + * @return true if we successfully send `close` protocol message, false otherwise + */ + private boolean trySendCloseProtocolMessage() { + try { + Log.v(TAG, "Requesting connection close"); + transport.send(new ProtocolMessage(ProtocolMessage.Action.close)); + return true; + } catch (AblyException e) { + /* we're closing, and the attempt to send the CLOSE message failed; + * continue, because we're not going to reinstate the transport + * just to send a CLOSE message */ + Log.v(TAG, "Closing incomplete transport"); + clearTransport(); + return false; + } } private void clearTransport() { @@ -1380,6 +1691,7 @@ protected boolean checkConnectivity() { try { return HttpHelpers.getUrlString(ably.httpCore, INTERNET_CHECK_URL).contains(INTERNET_CHECK_OK); } catch(AblyException e) { + Log.d(TAG, "Exception whilst checking connectivity", e); return false; } } @@ -1450,9 +1762,14 @@ private void sendImpl(QueuedMessage msg) throws AblyException { private void sendQueuedMessages() { synchronized(this) { - while(queuedMessages.size() > 0) { + while(!queuedMessages.isEmpty()) { try { - sendImpl(queuedMessages.get(0)); + QueuedMessage message = queuedMessages.get(0); + // Do not send attach message from queued messages to prevent duplication + // (we always send attach on connect event) + if (message.msg.action != ProtocolMessage.Action.attach) { + sendImpl(message); + } } catch (AblyException e) { Log.e(TAG, "sendQueuedMessages(): Unexpected error sending queued messages", e); } finally { @@ -1474,15 +1791,17 @@ private void failQueuedMessages(ErrorInfo reason) { } } queuedMessages.clear(); + + //also pending messages + pendingMessages.fail(reason); } } /** * A class containing a queue of messages awaiting acknowledgement */ - private class PendingMessageQueue { - private long startSerial = 0L; - private ArrayList queue = new ArrayList(); + private static class PendingMessageQueue { + private final List queue = new ArrayList<>(); public synchronized void push(QueuedMessage msg) { queue.add(msg); @@ -1491,6 +1810,8 @@ public synchronized void push(QueuedMessage msg) { public void ack(long msgSerial, int count, ErrorInfo reason) { QueuedMessage[] ackMessages = null, nackMessages = null; synchronized(this) { + if (queue.isEmpty()) return; + long startSerial = queue.get(0).msg.msgSerial; if(msgSerial < startSerial) { /* this is an error condition and shouldn't happen but * we can handle it gracefully by only processing the @@ -1513,7 +1834,6 @@ public void ack(long msgSerial, int count, ErrorInfo reason) { List ackList = queue.subList(0, count); ackMessages = ackList.toArray(new QueuedMessage[count]); ackList.clear(); - startSerial += count; } } if(nackMessages != null) { @@ -1543,6 +1863,8 @@ public void ack(long msgSerial, int count, ErrorInfo reason) { public synchronized void nack(long serial, int count, ErrorInfo reason) { QueuedMessage[] nackMessages = null; synchronized(this) { + if (queue.isEmpty()) return; + long startSerial = queue.get(0).msg.msgSerial; if(serial != startSerial) { /* this is an error condition and shouldn't happen but * we can handle it gracefully by only processing the @@ -1570,17 +1892,23 @@ public synchronized void nack(long serial, int count, ErrorInfo reason) { } /** - * reset the pending message queue, failing any currently pending messages. - * Used when a resume fails and we get a different connection id. - * @param oldMsgSerial the next message serial number for the old - * connection, and thus one more than the highest message serial - * in the queue. + * @return all pending queued messages and clear the queue */ - public synchronized void reset(long oldMsgSerial, ErrorInfo err) { - nack(startSerial, (int)(oldMsgSerial - startSerial), err); - startSerial = 0; + synchronized List popAll() { + List allPendingMessages = new ArrayList<>(queue); + queue.clear(); + return allPendingMessages; } + //fail all pending queued messages + synchronized void fail(ErrorInfo reason) { + for (QueuedMessage queuedMessage: queue){ + if (queuedMessage.listener != null) { + queuedMessage.listener.onError(reason); + } + } + queue.clear(); + } } /*********************** @@ -1666,6 +1994,7 @@ private boolean isFatalError(ErrorInfo err) { private final HashSet heartbeatWaiters = new HashSet(); private final ActionQueue actionQueue = new ActionQueue(); private final Hosts hosts; + private final PlatformAgentProvider platformAgentProvider; private Thread handlerThread; private final Map states = new HashMap<>(); @@ -1675,11 +2004,13 @@ private boolean isFatalError(ErrorInfo err) { private boolean suppressRetry; /* for tests only; modified via reflection */ private ITransport transport; private long suspendTime; - private long msgSerial; + public long msgSerial; private long lastActivity; private CMConnectivityListener connectivityListener; private long connectionStateTtl = Defaults.connectionStateTtl; + public int maxMessageSize = Defaults.maxMessageSize; long maxIdleInterval = Defaults.maxIdleInterval; + private int disconnectedRetryAttempt = 0; /* for debug/test only */ private final RawProtocolListener protocolListener; 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 4d094ab65..1c1b6c0a6 100644 --- a/lib/src/main/java/io/ably/lib/transport/Defaults.java +++ b/lib/src/main/java/io/ably/lib/transport/Defaults.java @@ -3,23 +3,27 @@ import io.ably.lib.BuildConfig; import io.ably.lib.types.ClientOptions; -import java.text.DecimalFormat; -import java.text.DecimalFormatSymbols; -import java.util.Locale; - public class Defaults { - /* versions */ - public static final float ABLY_VERSION_NUMBER = 1.2f; - public static final String ABLY_VERSION = new DecimalFormat("0.0", new DecimalFormatSymbols(Locale.ENGLISH)).format(ABLY_VERSION_NUMBER); - public static final String ABLY_LIB_VERSION = String.format("%s-%s", BuildConfig.LIBRARY_NAME, BuildConfig.VERSION); + /** + * The level of compatibility with the Ably service that this SDK supports. + * Also referred to as the 'wire protocol version'. + * This value is presented as a string, as specified in G4a. + *

+ * spec: G4 + *

+ */ + public static final String ABLY_PROTOCOL_VERSION = "2"; + + public static final String ABLY_AGENT_VERSION = String.format("%s/%s", "ably-java", BuildConfig.VERSION); - /* params */ - public static final String ABLY_VERSION_PARAM = "v"; - public static final String ABLY_LIB_PARAM = "lib"; + /* realtime params */ + public static final String ABLY_PROTOCOL_VERSION_PARAM = "v"; + public static final String ABLY_AGENT_PARAM = "agent"; - /* Headers */ - public static final String ABLY_VERSION_HEADER = "X-Ably-Version"; - public static final String ABLY_LIB_HEADER = "X-Ably-Lib"; + /* http headers */ + public static final String ABLY_PROTOCOL_VERSION_HEADER = "X-Ably-Version"; + public static final String ABLY_CLIENT_ID_HEADER = "X-Ably-ClientId"; + public static final String ABLY_AGENT_HEADER = "Ably-Agent"; /* Hosts */ public static final String[] HOST_FALLBACKS = { "A.ably-realtime.com", "B.ably-realtime.com", "C.ably-realtime.com", "D.ably-realtime.com", "E.ably-realtime.com" }; @@ -33,18 +37,25 @@ public class Defaults { public static int TIMEOUT_DISCONNECT = 15000; public static int TIMEOUT_CHANNEL_RETRY = 15000; - /* TO313 */ + /* TO3l3 */ public static int TIMEOUT_HTTP_OPEN = 4000; - /* TO314 */ - public static int TIMEOUT_HTTP_REQUEST = 15000; + /* TO3l4 */ + public static int TIMEOUT_HTTP_REQUEST = 10000; + /* TO3l6 */ + public static int httpMaxRetryDuration = 15000; + /* DF1b */ public static long realtimeRequestTimeout = 10000L; + /* TO3l2 */ + public static long suspendedRetryTimeout = 30000L; /* TO3l10 */ public static long fallbackRetryTimeout = 10*60*1000L; /* CD2h (but no default in the spec) */ public static long maxIdleInterval = 20000L; + // 64kB, as per CD2c + public static int maxMessageSize = 65536; /* DF1a */ - public static long connectionStateTtl = 60000L; + public static long connectionStateTtl = 120000L; public static final ITransport.Factory TRANSPORT = new WebSocketTransport.Factory(); public static final int HTTP_MAX_RETRY_COUNT = 3; diff --git a/lib/src/main/java/io/ably/lib/transport/Hosts.java b/lib/src/main/java/io/ably/lib/transport/Hosts.java index 3ce5bcaa4..a4559b4f6 100644 --- a/lib/src/main/java/io/ably/lib/transport/Hosts.java +++ b/lib/src/main/java/io/ably/lib/transport/Hosts.java @@ -10,18 +10,19 @@ /** * Object to encapsulate primary host name and shuffled fallback host names. + * + * Methods on this class are safe to be called from any thread. */ public class Hosts { - private String primaryHost; - private String prefHost; - private long prefHostExpiry; - boolean primaryHostIsDefault; + private final String primaryHost; + private final boolean primaryHostIsDefault; private final String defaultHost; private final String[] fallbackHosts; private final boolean fallbackHostsIsDefault; private final boolean fallbackHostsUseDefault; private final long fallbackRetryTimeout; + private final Preferred preferred = new Preferred(); /** * Create Hosts object @@ -38,7 +39,7 @@ public class Hosts { * code, but the results are ignored because ConnectionManager then calls * setHost() and fallback is not used. */ - public Hosts(String primaryHost, String defaultHost, ClientOptions options) throws AblyException { + public Hosts(final String primaryHost, final String defaultHost, final ClientOptions options) throws AblyException { this.defaultHost = defaultHost; this.fallbackHostsUseDefault = options.fallbackHostsUseDefault; boolean hasCustomPrimaryHost = primaryHost != null && !primaryHost.equalsIgnoreCase(defaultHost); @@ -60,15 +61,16 @@ public Hosts(String primaryHost, String defaultHost, ClientOptions options) thro } if (hasCustomPrimaryHost) { - setPrimaryHost(primaryHost); + this.primaryHost = primaryHost; if (options.environment != null) { /* TO3k2: It is never valid to provide both a restHost and environment value * TO3k3: It is never valid to provide both a realtimeHost and environment value */ throw AblyException.fromErrorInfo(new ErrorInfo("cannot set both restHost/realtimeHost and environment options", 40000, 400)); } } else { - setPrimaryHost(isProduction ? defaultHost : options.environment + "-" + defaultHost); + this.primaryHost = isProduction ? defaultHost : options.environment + "-" + defaultHost; } + primaryHostIsDefault = this.primaryHost.equalsIgnoreCase(defaultHost); fallbackHostsIsDefault = Arrays.equals(Defaults.HOST_FALLBACKS, tempFallbackHosts); fallbackHosts = tempFallbackHosts == null ? new String[] {} : tempFallbackHosts.clone(); @@ -77,36 +79,22 @@ public Hosts(String primaryHost, String defaultHost, ClientOptions options) thro fallbackRetryTimeout = options.fallbackRetryTimeout; } - /** - * set primary hostname - */ - private void setPrimaryHost(String primaryHost) { - this.primaryHost = primaryHost; - primaryHostIsDefault = primaryHost.equalsIgnoreCase(defaultHost); - } - /** * set preferred hostname, which might not be the primary */ - public void setPreferredHost(String prefHost, boolean temporary) { - if(prefHost.equals(this.prefHost)) { + public synchronized void setPreferredHost(final String prefHost, final boolean temporary) { + if (preferred.isHost(prefHost)) { /* a successful request against a fallback; don't update the expiry time */ return; } - if(prefHost.equals(this.primaryHost)) { + if(prefHost.equals(primaryHost)) { /* a successful request against the primary host; reset */ - clearPreferredHost(); + preferred.clear(); } else { - this.prefHost = prefHost; - this.prefHostExpiry = temporary ? System.currentTimeMillis() + fallbackRetryTimeout : 0; + preferred.setHost(prefHost, temporary ? System.currentTimeMillis() + fallbackRetryTimeout : 0); } } - private void clearPreferredHost() { - this.prefHost = null; - this.prefHostExpiry = 0; - } - /** * Get primary host name */ @@ -117,18 +105,9 @@ public String getPrimaryHost() { /** * Get preferred host name (taking into account any affinity to a fallback: see RSC15f) */ - public String getPreferredHost() { - checkPreferredHostExpiry(); - return (prefHost == null) ? primaryHost : prefHost; - } - - private String checkPreferredHostExpiry() { - /* reset if expired */ - if(prefHostExpiry > 0 && prefHostExpiry <= System.currentTimeMillis()) { - prefHostExpiry = 0; - prefHost = null; - } - return prefHost; + public synchronized String getPreferredHost() { + final String host = preferred.getHostOrClearIfExpired(); + return (host == null) ? primaryHost : host; } /** @@ -138,7 +117,7 @@ private String checkPreferredHostExpiry() { * @return Successor host that can be used as a fallback. * null, if there is no successor fallback available. */ - public String getFallback(String lastHost) { + public synchronized String getFallback(String lastHost) { if (fallbackHosts == null) return null; int idx; @@ -149,9 +128,9 @@ public String getFallback(String lastHost) { if (!primaryHostIsDefault && !fallbackHostsUseDefault && fallbackHostsIsDefault) return null; idx = 0; - } else if(lastHost.equals(checkPreferredHostExpiry())) { + } else if(lastHost.equals(preferred.getHostOrClearIfExpired())) { /* RSC15f: there was a failure on an unexpired, cached fallback; so try again using the primary */ - clearPreferredHost(); + preferred.clear(); return primaryHost; } else { /* Onto next fallback. */ @@ -167,13 +146,43 @@ public String getFallback(String lastHost) { return fallbackHosts[idx]; } - public int fallbackHostsRemaining(String candidateHost) { + public synchronized int fallbackHostsRemaining(String candidateHost) { if(fallbackHosts == null) { return 0; } - if(candidateHost.equals(primaryHost) || candidateHost.equals(prefHost)) { + if(candidateHost.equals(primaryHost) || candidateHost.equals(preferred.getHost())) { return fallbackHosts.length; } return fallbackHosts.length - Arrays.asList(fallbackHosts).indexOf(candidateHost) - 1; } + + private static class Preferred { + private String host; + private long expiry; + + public void clear() { + host = null; + expiry = 0; + } + + public boolean isHost(final String host) { + return (this.host == null) ? (host == null) : this.host.equals(host); + } + + public void setHost(final String host, final long expiry) { + this.host = host; + this.expiry = expiry; + } + + public String getHostOrClearIfExpired() { + if(expiry > 0 && expiry <= System.currentTimeMillis()) { + clear(); // expired, so reset + } + return host; + } + + public String getHost() { + return host; + } + } } diff --git a/lib/src/main/java/io/ably/lib/transport/ITransport.java b/lib/src/main/java/io/ably/lib/transport/ITransport.java index e23ce3b67..cdcbf92b5 100644 --- a/lib/src/main/java/io/ably/lib/transport/ITransport.java +++ b/lib/src/main/java/io/ably/lib/transport/ITransport.java @@ -5,15 +5,17 @@ import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.Param; import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.types.RecoveryKeyContext; +import io.ably.lib.util.AgentHeaderCreator; import io.ably.lib.util.Log; +import io.ably.lib.util.PlatformAgentProvider; +import io.ably.lib.util.StringUtils; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; public interface ITransport { @@ -37,12 +39,13 @@ class TransportParams { protected String host; protected int port; protected String connectionKey; - protected String connectionSerial; protected Mode mode; protected boolean heartbeats; + private final PlatformAgentProvider platformAgentProvider; - public TransportParams(ClientOptions options) { + public TransportParams(ClientOptions options, PlatformAgentProvider platformAgentProvider) { this.options = options; + this.platformAgentProvider = platformAgentProvider; heartbeats = true; /* default to requiring Ably heartbeats */ } @@ -60,24 +63,18 @@ public ClientOptions getClientOptions() { public Param[] getConnectParams(Param[] baseParams) { List paramList = new ArrayList(Arrays.asList(baseParams)); - paramList.add(new Param(Defaults.ABLY_VERSION_PARAM, Defaults.ABLY_VERSION)); + paramList.add(new Param(Defaults.ABLY_PROTOCOL_VERSION_PARAM, Defaults.ABLY_PROTOCOL_VERSION)); paramList.add(new Param("format", (options.useBinaryProtocol ? "msgpack" : "json"))); if(!options.echoMessages) paramList.add(new Param("echo", "false")); - if(connectionKey != null) { + if(!StringUtils.isNullOrEmpty(connectionKey)) { mode = Mode.resume; - paramList.add(new Param("resume", connectionKey)); - if(connectionSerial != null) - paramList.add(new Param("connectionSerial", connectionSerial)); - } else if(options.recover != null) { + paramList.add(new Param("resume", connectionKey)); // RTN15b1 + } else if(!StringUtils.isNullOrEmpty(options.recover)) { // RTN16k mode = Mode.recover; - Pattern recoverSpec = Pattern.compile("^([\\w\\-\\!]+):(\\-?\\d+)$"); - Matcher match = recoverSpec.matcher(options.recover); - if(match.matches()) { - paramList.add(new Param("recover", match.group(1))); - paramList.add(new Param("connectionSerial", match.group(2))); - } else { - Log.e(TAG, "Invalid recover string specified"); + RecoveryKeyContext recoveryKeyContext = RecoveryKeyContext.decode(options.recover); + if (recoveryKeyContext != null) { + paramList.add(new Param("recover", recoveryKeyContext.getConnectionKey())); } } if(options.clientId != null) @@ -88,7 +85,7 @@ public Param[] getConnectParams(Param[] baseParams) { if(options.transportParams != null) { paramList.addAll(Arrays.asList(options.transportParams)); } - paramList.add(new Param(Defaults.ABLY_LIB_PARAM, Defaults.ABLY_LIB_VERSION)); + paramList.add(new Param(Defaults.ABLY_AGENT_PARAM, AgentHeaderCreator.create(options.agents, platformAgentProvider))); Log.d(TAG, "getConnectParams: params = " + paramList); return paramList.toArray(new Param[paramList.size()]); } @@ -118,6 +115,8 @@ interface ConnectListener { */ void send(ProtocolMessage msg) throws AblyException; + void receive(ProtocolMessage msg) throws AblyException; + /** * Get connection URL * @return diff --git a/lib/src/main/java/io/ably/lib/transport/SafeSSLSocketFactory.java b/lib/src/main/java/io/ably/lib/transport/SafeSSLSocketFactory.java new file mode 100644 index 000000000..c8fc5d6b3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/transport/SafeSSLSocketFactory.java @@ -0,0 +1,102 @@ +package io.ably.lib.transport; + +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * This is a decorator for the {@link SSLSocketFactory} which modifies the enabled TLS protocols + * for each created {@link SSLSocket} to only use the protocols which are considered to be safe. + *

+ * This class was created because the {@code SSLContext.getInstance()} method does not allow specifying + * precisely which TLS protocols can be used and which cannot. + */ +public class SafeSSLSocketFactory extends SSLSocketFactory { + /** + * The protocols that are considered to be safe. + */ + private final String[] SAFE_PROTOCOLS = { + "TLSv1.2", + "TLSv1.3" + }; + + /** + * All API calls should be delegated to this factory instance. + */ + private final SSLSocketFactory factory; + + public SafeSSLSocketFactory(SSLSocketFactory factory) { + this.factory = factory; + } + + @Override + public String[] getDefaultCipherSuites() { + return factory.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return factory.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return getSocketWithOnlySafeProtocolsEnabled(factory.createSocket()); + } + + @Override + public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException { + return getSocketWithOnlySafeProtocolsEnabled(factory.createSocket(socket, host, port, autoClose)); + } + + @Override + public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + return getSocketWithOnlySafeProtocolsEnabled(factory.createSocket(host, port)); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { + return getSocketWithOnlySafeProtocolsEnabled(factory.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return getSocketWithOnlySafeProtocolsEnabled(factory.createSocket(host, port)); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { + return getSocketWithOnlySafeProtocolsEnabled(factory.createSocket(address, port, localAddress, localPort)); + } + + /** + * Modifies the socket's enabled protocols list to only support the safe ones. + * If no safe protocol is supported then the socket won't have any protocols enabled. + */ + private Socket getSocketWithOnlySafeProtocolsEnabled(Socket socket) { + if (!(socket instanceof SSLSocket)) { + throw new IllegalArgumentException("The socket is not an instance of the SSL socket"); + } + SSLSocket sslSocket = (SSLSocket) socket; + Set allSupportedProtocols = new HashSet<>(Arrays.asList(sslSocket.getSupportedProtocols())); + List safeSupportedProtocols = new ArrayList<>(); + for (String safeProtocol : SAFE_PROTOCOLS) { + if (allSupportedProtocols.contains(safeProtocol)) { + safeSupportedProtocols.add(safeProtocol); + } + } + if (safeSupportedProtocols.isEmpty()) { + throw new SecurityException("No safe protocol version is supported for this SSL socket"); + } + sslSocket.setEnabledProtocols(safeSupportedProtocols.toArray(new String[0])); + return sslSocket; + } +} diff --git a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java index 146cb7158..69ce91a34 100644 --- a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java +++ b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java @@ -1,52 +1,93 @@ package io.ably.lib.transport; import io.ably.lib.http.HttpUtils; +import io.ably.lib.network.EngineType; +import io.ably.lib.network.NotConnectedException; +import io.ably.lib.network.WebSocketClient; +import io.ably.lib.network.WebSocketEngine; +import io.ably.lib.network.WebSocketEngineConfig; +import io.ably.lib.network.WebSocketEngineFactory; +import io.ably.lib.network.WebSocketListener; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.Param; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.types.ProtocolSerializer; +import io.ably.lib.util.ClientOptionsUtils; import io.ably.lib.util.Log; -import java.net.URI; +import javax.net.ssl.SSLContext; import java.nio.ByteBuffer; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; import java.util.Timer; import java.util.TimerTask; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; - -import org.java_websocket.client.WebSocketClient; -import org.java_websocket.framing.CloseFrame; -import org.java_websocket.framing.Framedata; -import org.java_websocket.handshake.ServerHandshake; -import org.java_websocket.WebSocket; - public class WebSocketTransport implements ITransport { private static final String TAG = WebSocketTransport.class.getName(); - + private static final int NEVER_CONNECTED = -1; + private static final int BUGGYCLOSE = -2; + private static final int CLOSE_NORMAL = 1000; + private static final int GOING_AWAY = 1001; + private static final int CLOSE_PROTOCOL_ERROR = 1002; + private static final int REFUSE = 1003; + /* private static final int UNUSED = 1004; */ + /* private static final int NOCODE = 1005; */ + private static final int ABNORMAL_CLOSE = 1006; + private static final int NO_UTF8 = 1007; + private static final int POLICY_VALIDATION = 1008; + private static final int TOOBIG = 1009; + private static final int EXTENSION = 1010; + private static final int UNEXPECTED_CONDITION = 1011; + private static final int TLS_ERROR = 1015; /****************** - * public factory API + * private members ******************/ - public static class Factory implements ITransport.Factory { - @Override - public WebSocketTransport getTransport(TransportParams params, ConnectionManager connectionManager) { - return new WebSocketTransport(params, connectionManager); - } - } + private final TransportParams params; + private final ConnectionManager connectionManager; + private final boolean channelBinaryMode; + private String wsUri; + private ConnectListener connectListener; + private WebSocketClient webSocketClient; + private final WebSocketEngine webSocketEngine; + private boolean activityCheckTurnedOff = false; /****************** * protected constructor ******************/ - protected WebSocketTransport(TransportParams params, ConnectionManager connectionManager) { this.params = params; this.connectionManager = connectionManager; this.channelBinaryMode = params.options.useBinaryProtocol; - /* We do not require Ably heartbeats, as we can use WebSocket pings instead. */ - params.heartbeats = false; + this.webSocketEngine = createWebSocketEngine(params); + params.heartbeats = !this.webSocketEngine.isPingListenerSupported(); + + } + + private static WebSocketEngine createWebSocketEngine(TransportParams params) { + WebSocketEngineFactory engineFactory = WebSocketEngineFactory.getFirstAvailable(); + Log.v(TAG, String.format("Using %s WebSocket Engine", engineFactory.getEngineType().name())); + WebSocketEngineConfig.WebSocketEngineConfigBuilder configBuilder = WebSocketEngineConfig.builder(); + configBuilder + .tls(params.options.tls) + .host(params.host) + .proxy(ClientOptionsUtils.convertToProxyConfig(params.getClientOptions())); + + // OkHttp supports modern TLS algorithms by default + if (params.options.tls && engineFactory.getEngineType() != EngineType.OKHTTP) { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + SafeSSLSocketFactory factory = new SafeSSLSocketFactory(sslContext.getSocketFactory()); + configBuilder.sslSocketFactory(factory); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new IllegalStateException("Can't get safe tls algorithms", e); + } + } + + return engineFactory.create(configBuilder.build()); } /****************** @@ -62,24 +103,18 @@ public void connect(ConnectListener connectListener) { wsUri = wsScheme + params.host + ':' + params.port + "/"; Param[] authParams = connectionManager.ably.auth.getAuthParams(); Param[] connectParams = params.getConnectParams(authParams); - if(connectParams.length > 0) + if (connectParams.length > 0) wsUri = HttpUtils.encodeParams(wsUri, connectParams); Log.d(TAG, "connect(); wsUri = " + wsUri); - synchronized(this) { - wsConnection = new WsClient(URI.create(wsUri)); - if(isTls) { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init( null, null, null ); - SSLSocketFactory factory = sslContext.getSocketFactory();// (SSLSocketFactory) SSLSocketFactory.getDefault(); - wsConnection.setSocketFactory(factory); - } + synchronized (this) { + webSocketClient = this.webSocketEngine.create(wsUri, new WebSocketHandler(this::receive)); } - wsConnection.connect(); - } catch(AblyException e) { + webSocketClient.connect(); + } catch (AblyException e) { Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, e); connectListener.onTransportUnavailable(this, e.errorInfo); - } catch(Throwable t) { + } catch (Throwable t) { Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, t); connectListener.onTransportUnavailable(this, AblyException.fromThrowable(t).errorInfo); } @@ -88,30 +123,43 @@ public void connect(ConnectListener connectListener) { @Override public void close() { Log.d(TAG, "close()"); - synchronized(this) { - if(wsConnection != null) { - wsConnection.close(); - wsConnection = null; + synchronized (this) { + if (webSocketClient != null) { + webSocketClient.close(); + webSocketClient = null; } } } + @Override + public void receive(ProtocolMessage msg) throws AblyException { + connectionManager.onMessage(this, msg); + } + @Override public void send(ProtocolMessage msg) throws AblyException { Log.d(TAG, "send(); action = " + msg.action); try { - if(channelBinaryMode) { + if (channelBinaryMode) { byte[] encodedMsg = ProtocolSerializer.writeMsgpack(msg); + + // Check the logging level to avoid performance hit associated with building the message if (Log.level <= Log.VERBOSE) { ProtocolMessage decodedMsg = ProtocolSerializer.readMsgpack(encodedMsg); Log.v(TAG, "send(): " + decodedMsg.action + ": " + new String(ProtocolSerializer.writeJSON(decodedMsg))); } - wsConnection.send(encodedMsg); + webSocketClient.send(encodedMsg); } else { + // Check the logging level to avoid performance hit associated with building the message if (Log.level <= Log.VERBOSE) Log.v(TAG, "send(): " + new String(ProtocolSerializer.writeJSON(msg))); - wsConnection.send(ProtocolSerializer.writeJSON(msg)); + webSocketClient.send(ProtocolSerializer.writeJSON(msg)); } + } catch (NotConnectedException e) { + if (connectListener != null) { + connectListener.onTransportUnavailable(this, AblyException.fromThrowable(e).errorInfo); + } else + throw AblyException.fromThrowable(e); } catch (Exception e) { throw AblyException.fromThrowable(e); } @@ -122,23 +170,63 @@ public String getHost() { return params.host; } - protected void preProcessReceivedMessage(ProtocolMessage message) - { + protected void preProcessReceivedMessage(ProtocolMessage message) { //Gives the chance to child classes to do message pre-processing } + /** + * Visible For Testing + *

+ * We need to turn off activity check for some tests (e.g. io.ably.lib.test.realtime.RealtimeConnectFailTest.disconnect_retry_channel_timeout_jitter_after_consistent_detach[binary_protocol]) + * Those tests expects that activity checks are passing, but protocol messages are not coming + */ + protected void turnOffActivityCheckIfPingListenerIsNotSupported() { + if (!webSocketEngine.isPingListenerSupported()) activityCheckTurnedOff = true; + } + + public String toString() { + return WebSocketTransport.class.getName() + " {" + getURL() + "}"; + } + + public String getURL() { + return wsUri; + } + //interface to transfer Protocol message from websocket + interface WebSocketReceiver { + void onMessage(ProtocolMessage protocolMessage) throws AblyException; + } + + /****************** + * public factory API + ******************/ + + public static class Factory implements ITransport.Factory { + @Override + public WebSocketTransport getTransport(TransportParams params, ConnectionManager connectionManager) { + return new WebSocketTransport(params, connectionManager); + } + } + /************************** * WebSocketHandler methods **************************/ - class WsClient extends WebSocketClient { + class WebSocketHandler implements WebSocketListener { + private final WebSocketReceiver receiver; + /*************************** + * WsClient private members + ***************************/ - WsClient(URI serverUri) { - super(serverUri); + private Timer timer = new Timer(); + private TimerTask activityTimerTask = null; + private long lastActivityTime; + + WebSocketHandler(WebSocketReceiver receiver) { + this.receiver = receiver; } @Override - public void onOpen(ServerHandshake handshakedata) { + public void onOpen() { Log.d(TAG, "onOpen()"); connectListener.onTransportAvailable(WebSocketTransport.this); flagActivity(); @@ -150,7 +238,7 @@ public void onMessage(ByteBuffer blob) { ProtocolMessage msg = ProtocolSerializer.readMsgpack(blob.array()); Log.d(TAG, "onMessage(): msg (binary) = " + msg); WebSocketTransport.this.preProcessReceivedMessage(msg); - connectionManager.onMessage(WebSocketTransport.this, msg); + receiver.onMessage(msg); } catch (AblyException e) { String msg = "Unexpected exception processing received binary message"; Log.e(TAG, msg, e); @@ -164,7 +252,7 @@ public void onMessage(String string) { ProtocolMessage msg = ProtocolSerializer.fromJSON(string); Log.d(TAG, "onMessage(): msg (text) = " + msg); WebSocketTransport.this.preProcessReceivedMessage(msg); - connectionManager.onMessage(WebSocketTransport.this, msg); + receiver.onMessage(msg); } catch (AblyException e) { String msg = "Unexpected exception processing received text message"; Log.e(TAG, msg, e); @@ -174,19 +262,17 @@ public void onMessage(String string) { /* This allows us to detect a websocket ping, so we don't need Ably pings. */ @Override - public void onWebsocketPing( WebSocket conn, Framedata f ) { + public void onWebsocketPing() { Log.d(TAG, "onWebsocketPing()"); - /* Call superclass to ensure the pong is sent. */ - super.onWebsocketPing( conn, f ); flagActivity(); } @Override - public void onClose(final int wsCode, final String wsReason, final boolean remote) { - Log.d(TAG, "onClose(): wsCode = " + wsCode + "; wsReason = " + wsReason + "; remote = " + remote); + public void onClose(final int wsCode, final String wsReason) { + Log.d(TAG, "onClose(): wsCode = " + wsCode + "; wsReason = " + wsReason + "; remote = " + false); ErrorInfo reason; - switch(wsCode) { + switch (wsCode) { case NEVER_CONNECTED: case CLOSE_NORMAL: case BUGGYCLOSE: @@ -222,8 +308,14 @@ public void onClose(final int wsCode, final String wsReason, final boolean remot } @Override - public void onError(final Exception e) { - connectListener.onTransportUnavailable(WebSocketTransport.this, new ErrorInfo(e.getMessage(), 503, 80000)); + public void onError(Throwable throwable) { + Log.e(TAG, "Connection error ", throwable); + connectListener.onTransportUnavailable(WebSocketTransport.this, new ErrorInfo(throwable.getMessage(), 503, 80000)); + } + + @Override + public void onOldJavaVersionDetected(Throwable throwable) { + Log.w(TAG, "Error when trying to set SSL parameters, most likely due to an old Java API version", throwable); } private synchronized void dispose() { @@ -231,13 +323,14 @@ private synchronized void dispose() { try { timer.cancel(); timer = null; - } catch(IllegalStateException e) {} + } catch (IllegalStateException e) { + } } private synchronized void flagActivity() { lastActivityTime = System.currentTimeMillis(); connectionManager.setLastActivity(lastActivityTime); - if (activityTimerTask == null && connectionManager.maxIdleInterval != 0) { + if (activityTimerTask == null && connectionManager.maxIdleInterval != 0 && !activityCheckTurnedOff) { /* No timer currently running because previously there was no * maxIdleInterval configured, but now there is a * maxIdleInterval configured. Call checkActivity so a timer @@ -249,92 +342,67 @@ private synchronized void flagActivity() { } private synchronized void checkActivity() { - long timeout = connectionManager.maxIdleInterval; + long timeout = getActivityTimeout(); if (timeout == 0) { Log.v(TAG, "checkActivity: infinite timeout"); return; } - if(activityTimerTask != null) { - /* timer already running */ + + // Check if timer already running + if (activityTimerTask != null) { return; } - timeout += connectionManager.ably.options.realtimeRequestTimeout; - long now = System.currentTimeMillis(); - long next = lastActivityTime + timeout; - if (now < next) { - /* We have not reached maxIdleInterval+realtimeRequestTimeout - * of inactivity. Schedule a new timer for that long after the - * last activity time. */ - Log.v(TAG, "checkActivity: ok"); + + // Start the activity timer task + startActivityTimer(timeout + 100); + } + + private synchronized void startActivityTimer(long timeout) { + if (activityTimerTask == null) { schedule((activityTimerTask = new TimerTask() { public void run() { try { - checkActivity(); - } catch(Throwable t) { + onActivityTimerExpiry(); + } catch (Throwable t) { Log.e(TAG, "Unexpected exception in activity timer handler", t); } } - }), next - now); - } else { - /* Timeout has been reached. Close the connection. */ - Log.e(TAG, "No activity for " + timeout + "ms, closing connection"); - closeConnection(CloseFrame.ABNORMAL_CLOSE, "timed out"); + }), timeout); } } private synchronized void schedule(TimerTask task, long delay) { - if(timer != null) { + if (timer != null) { try { timer.schedule(task, delay); - } catch(IllegalStateException ise) { + } catch (IllegalStateException ise) { Log.e(TAG, "Unexpected exception scheduling activity timer", ise); } } } - /*************************** - * WsClient private members - ***************************/ + private void onActivityTimerExpiry() { + long timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime; + long timeRemaining = getActivityTimeout() - timeSinceLastActivity; - private Timer timer = new Timer(); - private TimerTask activityTimerTask = null; - private long lastActivityTime; - } + // If we have no time remaining, then close the connection + if (timeRemaining <= 0) { + Log.e(TAG, "No activity for " + getActivityTimeout() + "ms, closing connection"); + webSocketClient.cancel(ABNORMAL_CLOSE, "timed out"); + return; + } - public String toString() { - return WebSocketTransport.class.getName() + " [" + getURL() + "]"; - } + synchronized (this) { + activityTimerTask = null; + // Otherwise, we've had some activity, restart the timer for the next timeout + Log.v(TAG, "onActivityTimerExpiry: ok"); + startActivityTimer(timeRemaining + 100); + } + } - public String getURL() { - return wsUri; + private long getActivityTimeout() { + return connectionManager.maxIdleInterval + connectionManager.ably.options.realtimeRequestTimeout; + } } - /****************** - * private members - ******************/ - - private final TransportParams params; - private final ConnectionManager connectionManager; - private final boolean channelBinaryMode; - private String wsUri; - private ConnectListener connectListener; - - private WsClient wsConnection; - - private static final int NEVER_CONNECTED = -1; - private static final int BUGGYCLOSE = -2; - private static final int CLOSE_NORMAL = 1000; - private static final int GOING_AWAY = 1001; - private static final int CLOSE_PROTOCOL_ERROR = 1002; - private static final int REFUSE = 1003; -/* private static final int UNUSED = 1004; */ -/* private static final int NOCODE = 1005; */ - private static final int ABNORMAL_CLOSE = 1006; - private static final int NO_UTF8 = 1007; - private static final int POLICY_VALIDATION = 1008; - private static final int TOOBIG = 1009; - private static final int EXTENSION = 1010; - private static final int UNEXPECTED_CONDITION = 1011; - private static final int TLS_ERROR = 1015; - } diff --git a/lib/src/main/java/io/ably/lib/types/AblyException.java b/lib/src/main/java/io/ably/lib/types/AblyException.java index 60b0b2c95..d7a531d97 100644 --- a/lib/src/main/java/io/ably/lib/types/AblyException.java +++ b/lib/src/main/java/io/ably/lib/types/AblyException.java @@ -1,5 +1,6 @@ package io.ably.lib.types; +import io.ably.lib.network.FailedConnectionException; import java.net.ConnectException; import java.net.NoRouteToHostException; import java.net.SocketTimeoutException; @@ -50,6 +51,8 @@ public static AblyException fromThrowable(Throwable t) { return (AblyException)t; if(t instanceof ConnectException || t instanceof SocketTimeoutException || t instanceof UnknownHostException || t instanceof NoRouteToHostException) return new HostFailedException(t, ErrorInfo.fromThrowable(t)); + if (t instanceof FailedConnectionException) + return new HostFailedException(t.getCause(), ErrorInfo.fromThrowable(t.getCause())); return new AblyException(t, ErrorInfo.fromThrowable(t)); } @@ -61,4 +64,4 @@ public static class HostFailedException extends AblyException { super(throwable, reason); } } -} \ No newline at end of file +} diff --git a/lib/src/main/java/io/ably/lib/types/Annotation.java b/lib/src/main/java/io/ably/lib/types/Annotation.java new file mode 100644 index 000000000..ce57e1590 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/Annotation.java @@ -0,0 +1,248 @@ +package io.ably.lib.types; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import io.ably.lib.util.Log; +import io.ably.lib.util.Serialisation; +import org.msgpack.core.MessageFormat; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.IOException; +import java.lang.reflect.Type; + +public class Annotation extends BaseMessage { + + private static final String TAG = Annotation.class.getName(); + + private static final String ACTION = "action"; + private static final String SERIAL = "serial"; + private static final String MESSAGE_SERIAL = "messageSerial"; + private static final String TYPE = "type"; + private static final String NAME = "name"; + private static final String COUNT = "count"; + private static final String EXTRAS = "extras"; + + /** + * (TAN2b) The action, whether this is an annotation being added or removed, + * one of the AnnotationAction enum values. + */ + public AnnotationAction action; + + /** + * (TAN2i) This annotation's unique serial (lexicographically totally ordered). + */ + public String serial; + + /** + * (TAN2j) The serial of the message (of type `MESSAGE_CREATE`) that this annotation is annotating. + */ + public String messageSerial; + + /** + * (TAN2k) The type of annotation it is, typically some identifier together with an aggregation method; + * for example: "emoji:distinct.v1". Handled opaquely by the SDK and validated serverside. | + */ + public String type; + + /** + * (TAN2d) The name of this annotation. This is the field that most annotation aggregations will operate on. + * For example, using "distinct.v1" aggregation (specified in the type), the message summary will show a list + * of clients who have published an annotation with each distinct annotation.name. + */ + public String name; + + /** + * (TAN2e) An optional count, only relevant to certain aggregation methods, + * see aggregation methods documentation for more info. + */ + public Integer count; + + /** + * (TAN2l) A JSON object for metadata and/or ancillary payloads. + */ + public MessageExtras extras; + + public static Annotation fromMsgpack(MessageUnpacker unpacker) throws IOException { + return (new Annotation()).readMsgpack(unpacker); + } + + void writeMsgpack(MessagePacker packer) throws IOException { + int fieldCount = super.countFields(); + if (action != null) ++fieldCount; + if (serial != null) ++fieldCount; + if (messageSerial != null) ++fieldCount; + if (type != null) ++fieldCount; + if (name != null) ++fieldCount; + if (count != null) ++fieldCount; + if (extras != null) ++fieldCount; + + packer.packMapHeader(fieldCount); + super.writeFields(packer); + + if (action != null) { + packer.packString(ACTION); + packer.packInt(action.ordinal()); + } + + if (serial != null) { + packer.packString(SERIAL); + packer.packString(serial); + } + + if (messageSerial != null) { + packer.packString(MESSAGE_SERIAL); + packer.packString(messageSerial); + } + + if (type != null) { + packer.packString(TYPE); + packer.packString(type); + } + + if (name != null) { + packer.packString(NAME); + packer.packString(name); + } + + if (count != null) { + packer.packString(COUNT); + packer.packInt(count); + } + + if (extras != null) { + packer.packString(EXTRAS); + extras.write(packer); + } + } + + Annotation readMsgpack(MessageUnpacker unpacker) throws IOException { + int fieldCount = unpacker.unpackMapHeader(); + for (int i = 0; i < fieldCount; i++) { + String fieldName = unpacker.unpackString().intern(); + MessageFormat fieldFormat = unpacker.getNextFormat(); + if (fieldFormat.equals(MessageFormat.NIL)) { + unpacker.unpackNil(); + continue; + } + + if (super.readField(unpacker, fieldName, fieldFormat)) { + continue; + } + if (fieldName.equals(ACTION)) { + action = AnnotationAction.tryFindByOrdinal(unpacker.unpackInt()); + } else if (fieldName.equals(SERIAL)) { + serial = unpacker.unpackString(); + } else if (fieldName.equals(MESSAGE_SERIAL)) { + messageSerial = unpacker.unpackString(); + } else if (fieldName.equals(TYPE)) { + type = unpacker.unpackString(); + } else if (fieldName.equals(NAME)) { + name = unpacker.unpackString(); + } else if (fieldName.equals(COUNT)) { + count = unpacker.unpackInt(); + } else if (fieldName.equals(EXTRAS)) { + extras = MessageExtras.read(unpacker); + } else { + Log.v(TAG, "Unexpected field: " + fieldName); + unpacker.skipValue(); + } + } + return this; + } + + @Override + protected void read(final JsonObject map) throws MessageDecodeException { + super.read(map); + + Integer actionOrdinal = readInt(map, ACTION); + action = actionOrdinal == null ? null : AnnotationAction.tryFindByOrdinal(actionOrdinal); + serial = readString(map, SERIAL); + messageSerial = readString(map, MESSAGE_SERIAL); + + type = readString(map, TYPE); + name = readString(map, NAME); + count = readInt(map, COUNT); + + final JsonElement extrasElement = map.get(EXTRAS); + if (extrasElement != null) { + if (!extrasElement.isJsonObject()) { + throw MessageDecodeException.fromDescription("Message extras is of type \"" + extrasElement.getClass() + "\" when expected a JSON object."); + } + extras = MessageExtras.read((JsonObject) extrasElement); + } + } + + public static class Serializer implements JsonSerializer, JsonDeserializer { + @Override + public JsonElement serialize(Annotation annotation, Type typeOfMessage, JsonSerializationContext ctx) { + final JsonObject json = BaseMessage.toJsonObject(annotation); + if (annotation.action != null) { + json.addProperty(ACTION, annotation.action.ordinal()); + } + + if (annotation.serial != null) { + json.addProperty(SERIAL, annotation.serial); + } + + if (annotation.messageSerial != null) { + json.addProperty(MESSAGE_SERIAL, annotation.messageSerial); + } + + if (annotation.type != null) { + json.addProperty(TYPE, annotation.type); + } + + if (annotation.name != null) { + json.addProperty(NAME, annotation.name); + } + + if (annotation.count != null) { + json.addProperty(COUNT, annotation.count); + } + + if (annotation.extras != null) { + json.add(EXTRAS, Serialisation.gson.toJsonTree(annotation.extras)); + } + + return json; + } + + @Override + public Annotation deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!json.isJsonObject()) { + throw new JsonParseException("Expected an object but got \"" + json.getClass() + "\"."); + } + + final Annotation annotation = new Annotation(); + + try { + annotation.read((JsonObject) json); + } catch (MessageDecodeException e) { + Log.e(TAG, e.getMessage(), e); + throw new JsonParseException("Failed to deserialize Message from JSON.", e); + } + + return annotation; + } + } + + public static class ActionSerializer implements JsonSerializer, JsonDeserializer { + @Override + public AnnotationAction deserialize(JsonElement json, Type t, JsonDeserializationContext ctx) + throws JsonParseException { + return AnnotationAction.tryFindByOrdinal(json.getAsInt()); + } + + @Override + public JsonElement serialize(AnnotationAction action, Type t, JsonSerializationContext ctx) { + return new JsonPrimitive(action.ordinal()); + } + } +} diff --git a/lib/src/main/java/io/ably/lib/types/AnnotationAction.java b/lib/src/main/java/io/ably/lib/types/AnnotationAction.java new file mode 100644 index 000000000..732cde594 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/AnnotationAction.java @@ -0,0 +1,19 @@ +package io.ably.lib.types; + +/** + * Enumerates the possible values of the {@link Annotation#action} field of an {@link Annotation} + */ +public enum AnnotationAction { + /** + * (TAN2b) A created annotation + */ + ANNOTATION_CREATE, + /** + * (TAN2b) A deleted annotation + */ + ANNOTATION_DELETE; + + static AnnotationAction tryFindByOrdinal(int ordinal) { + return values().length <= ordinal ? null: values()[ordinal]; + } +} diff --git a/lib/src/main/java/io/ably/lib/types/AnnotationSerializer.java b/lib/src/main/java/io/ably/lib/types/AnnotationSerializer.java new file mode 100644 index 000000000..88d7c59ff --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/AnnotationSerializer.java @@ -0,0 +1,103 @@ +package io.ably.lib.types; + +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpUtils; +import io.ably.lib.util.Log; +import io.ably.lib.util.Serialisation; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class AnnotationSerializer { + + private static final String TAG = AnnotationSerializer.class.getName(); + + public static void writeMsgpackArray(Annotation[] annotations, MessagePacker packer) { + try { + int count = annotations.length; + packer.packArrayHeader(count); + for (Annotation annotation : annotations) { + annotation.writeMsgpack(packer); + } + } catch (IOException e) { + throw new RuntimeException(e.getMessage(), e); + } + } + + public static Annotation[] readMsgpackArray(MessageUnpacker unpacker) throws IOException { + int count = unpacker.unpackArrayHeader(); + Annotation[] result = new Annotation[count]; + for (int i = 0; i < count; i++) + result[i] = Annotation.fromMsgpack(unpacker); + return result; + } + + public static HttpCore.RequestBody asMsgpackRequest(Annotation[] annotations) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + MessagePacker packer = Serialisation.msgpackPackerConfig.newPacker(out); + int count = annotations.length; + packer.packArrayHeader(count); + for (Annotation annotation : annotations) annotation.writeMsgpack(packer); + packer.flush(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + return new HttpUtils.ByteArrayRequestBody(out.toByteArray(), "application/x-msgpack"); + } + + public static HttpCore.RequestBody asJsonRequest(Annotation[] annotations) { + return new HttpUtils.JsonRequestBody(Serialisation.gson.toJson(annotations)); + } + + public static HttpCore.BodyHandler getAnnotationResponseHandler(ChannelOptions channelOptions) { + return new AnnotationBodyHandler(channelOptions); + } + + public static Annotation[] readMsgpack(byte[] packed) throws AblyException { + try { + MessageUnpacker unpacker = Serialisation.msgpackUnpackerConfig.newUnpacker(packed); + return readMsgpackArray(unpacker); + } catch (IOException ioe) { + throw AblyException.fromThrowable(ioe); + } + } + + public static Annotation[] readMessagesFromJson(byte[] packed) throws MessageDecodeException { + return Serialisation.gson.fromJson(new String(packed), Annotation[].class); + } + + private static class AnnotationBodyHandler implements HttpCore.BodyHandler { + + private final ChannelOptions channelOptions; + + AnnotationBodyHandler(ChannelOptions channelOptions) { + this.channelOptions = channelOptions; + } + + @Override + public Annotation[] handleResponseBody(String contentType, byte[] body) throws AblyException { + try { + Annotation[] annotations = null; + if ("application/json".equals(contentType)) + annotations = readMessagesFromJson(body); + else if ("application/x-msgpack".equals(contentType)) + annotations = readMsgpack(body); + if (annotations != null) { + for (Annotation annotation : annotations) { + try { + if (annotation.data != null) annotation.decode(channelOptions); + } catch (MessageDecodeException e) { + Log.e(TAG, e.errorInfo.message); + } + } + } + return annotations; + } catch (MessageDecodeException e) { + throw AblyException.fromThrowable(e); + } + } + } +} diff --git a/lib/src/main/java/io/ably/lib/types/AsyncPaginatedResult.java b/lib/src/main/java/io/ably/lib/types/AsyncPaginatedResult.java index abd05397f..dd33f27a7 100644 --- a/lib/src/main/java/io/ably/lib/types/AsyncPaginatedResult.java +++ b/lib/src/main/java/io/ably/lib/types/AsyncPaginatedResult.java @@ -10,18 +10,42 @@ public interface AsyncPaginatedResult { /** - * Get the contents as an array of component type + * Contains the current page of results; for example, an array of {@link Message} or {@link PresenceMessage} + * objects for a channel history request. + *

+ * Spec: TG3 */ T[] items(); /** - * Obtain params required to perform the given relative query + * Returns a new PaginatedResult for the first page of results. + *

+ * Spec: TG5 */ void first(Callback> callback); + /** + * Returns a new PaginatedResult for the current page of results. + *

+ * Spec: TG5 + */ void current(Callback> callback); + /** + * Returns a new PaginatedResult loaded with the next page of results. + * If there are no further pages, then null is returned. + *

+ * Spec: TG4 + * @return A page of results for message and presence history, stats, and REST presence requests. + */ void next(Callback> callback); boolean hasFirst(); boolean hasCurrent(); + + /** + * Returns true if there are more pages available by calling next and returns false if this page is the last page available. + *

+ * Spec: TG6 + * @return Whether or not there are more pages of results. + */ boolean hasNext(); } diff --git a/lib/src/main/java/io/ably/lib/types/BaseMessage.java b/lib/src/main/java/io/ably/lib/types/BaseMessage.java index b5aa6c1cb..44b91d7e2 100644 --- a/lib/src/main/java/io/ably/lib/types/BaseMessage.java +++ b/lib/src/main/java/io/ably/lib/types/BaseMessage.java @@ -8,7 +8,9 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonPrimitive; import io.ably.lib.util.Base64Coder; -import io.ably.lib.util.Crypto.ChannelCipher; +import io.ably.lib.util.Crypto; +import io.ably.lib.util.Crypto.EncryptingChannelCipher; +import io.ably.lib.util.Crypto.DecryptingChannelCipher; import io.ably.lib.util.Log; import io.ably.lib.util.Serialisation; import org.msgpack.core.MessageFormat; @@ -23,32 +25,46 @@ public class BaseMessage implements Cloneable { /** - * A unique id for this message + * A Unique ID assigned by Ably to this message. + *

+ * Spec: TM2a */ public String id; /** - * The timestamp for this message + * Timestamp of when the message was received by Ably, as milliseconds since the Unix epoch. + *

+ * Spec: TM2f */ public long timestamp; /** - * The id of the publisher of this message + * The client ID of the publisher of this message. + *

+ * Spec: RSL1g1, TM2b */ public String clientId; /** - * The connection id of the publisher of this message + * The connection ID of the publisher of this message. + *

+ * Spec: TM2c */ public String connectionId; /** - * Any transformation applied to the data for this message + * This is typically empty, as all messages received from Ably are automatically decoded client-side using this value. + * However, if the message encoding cannot be processed, this attribute contains the remaining transformations + * not applied to the data payload. + *

+ * Spec: TM2e */ public String encoding; /** - * The message payload. + * The message payload, if provided. + *

+ * Spec: TM2d */ public Object data; @@ -131,7 +147,8 @@ public void decode(ChannelOptions opts, DecodingContext context) throws Message case "cipher": if(opts != null && opts.encrypted) { try { - data = opts.getCipher().decrypt((byte[]) data); + DecryptingChannelCipher cipher = Crypto.createChannelDecipher(opts.getCipherParamsOrDefault()); + data = cipher.decrypt((byte[]) data); } catch(AblyException e) { throw MessageDecodeException.fromDescription(e.errorInfo.message); } @@ -179,7 +196,7 @@ public void encode(ChannelOptions opts) throws AblyException { } } if (opts != null && opts.encrypted) { - ChannelCipher cipher = opts.getCipher(); + EncryptingChannelCipher cipher = Crypto.createChannelEncipher(opts.getCipherParamsOrDefault()); data = cipher.encrypt((byte[]) data); encoding = ((encoding == null) ? "" : encoding + "/") + "cipher+" + cipher.getAlgorithm(); } @@ -261,6 +278,20 @@ protected Long readLong(final JsonObject map, final String key) { return element.getAsLong(); } + /** + * Read an optional numerical value. + * @return The value, or null if the key was not present in the map. + * @throws ClassCastException if an element exists for that key and that element is not a {@link JsonPrimitive} + * or is not a valid int value. + */ + protected Integer readInt(final JsonObject map, final String key) { + final JsonElement element = map.get(key); + if (null == element || element instanceof JsonNull) { + return null; + } + return element.getAsInt(); + } + /* Msgpack processing */ boolean readField(MessageUnpacker unpacker, String fieldName, MessageFormat fieldType) throws IOException { boolean result = true; diff --git a/lib/src/main/java/io/ably/lib/types/Capability.java b/lib/src/main/java/io/ably/lib/types/Capability.java index fabb3e186..369f1cd74 100644 --- a/lib/src/main/java/io/ably/lib/types/Capability.java +++ b/lib/src/main/java/io/ably/lib/types/Capability.java @@ -19,21 +19,19 @@ public class Capability { /** - * Convenience method to canonicalise a JSON capability expression + * Convenience method to canonicalise a JSON capability expression. * * @param capability a capability string, which is the JSON text for the capability * @return a capability string which has been canonicalised * @throws AblyException if there is an error processing the given string * (if for example it is not valid JSON) */ - public static final String c14n(String capability) throws AblyException { - if(capability == null || capability.isEmpty()) return ""; + public static String c14n(String capability) throws AblyException { + if (capability == null || capability.isEmpty()) return null; try { JsonObject json = (JsonObject)gsonParser.parse(capability); return (new Capability(json)).toString(); - } catch(ClassCastException e) { - throw AblyException.fromThrowable(e); - } catch(JsonParseException e) { + } catch(ClassCastException | JsonParseException e) { throw AblyException.fromThrowable(e); } } @@ -61,37 +59,15 @@ private Capability(JsonObject json) { * it is wholly replaced by the given set of operations. * * @param resource the resource string - * @param ops a String[] of the operations permitted for this resource; - * the array does not need to be sorted + * @param ops a String varargs of the operations permitted for this resource; + * the arguments do not need to be sorted */ - public void addResource(String resource, String[] ops) { + public void addResource(String resource, String... ops) { JsonArray jsonOps = (JsonArray)gson.toJsonTree(ops); json.add(resource, jsonOps); dirty = true; } - /** - * Add a resource to an existing Capability instance with the - * given single operation. If the resource already exists, - * it is wholly replaced by the given set of operations. - * - * @param resource the resource string - * @param op a single operation String to be permitted for this resource; - */ - public void addResource(String resource, String op) { - addResource(resource, new String[]{op}); - } - - /** - * Add a resource to an existing Capability instance with an - * empty set of operations. If the resource already exists, - * the effect is to reset its set of operations to empty. - * - * @param resource the resource string - */ - public void addResource(String resource) { - addResource(resource, new String[0]); - } /** * Remove a resource from an existing Capability instance * diff --git a/lib/src/main/java/io/ably/lib/types/ChannelMode.java b/lib/src/main/java/io/ably/lib/types/ChannelMode.java index 5c251468b..f20636933 100644 --- a/lib/src/main/java/io/ably/lib/types/ChannelMode.java +++ b/lib/src/main/java/io/ably/lib/types/ChannelMode.java @@ -5,11 +5,46 @@ import io.ably.lib.types.ProtocolMessage.Flag; +/** + * Describes the possible flags used to configure client capabilities, using {@link ChannelOptions}. + */ public enum ChannelMode { + /** + * The client can enter the presence set. + */ presence(Flag.presence), + /** + * The client can publish messages. + */ publish(Flag.publish), + /** + * The client can subscribe to messages. + */ subscribe(Flag.subscribe), - presence_subscribe(Flag.presence_subscribe); + /** + * The client can receive presence messages. + */ + presence_subscribe(Flag.presence_subscribe), + + /** + * The client can publish object messages. + */ + object_publish(Flag.object_publish), + + /** + * The client can subscribe to object messages. + */ + object_subscribe(Flag.object_subscribe), + + /** + * The client can publish annotation messages. + */ + annotation_publish(Flag.annotation_publish), + + /** + * The client can subscribe to annotation messages. + */ + annotation_subscribe(Flag.annotation_subscribe); private final int mask; diff --git a/lib/src/main/java/io/ably/lib/types/ChannelOptions.java b/lib/src/main/java/io/ably/lib/types/ChannelOptions.java index 2757d064a..bda871c9b 100644 --- a/lib/src/main/java/io/ably/lib/types/ChannelOptions.java +++ b/lib/src/main/java/io/ably/lib/types/ChannelOptions.java @@ -4,20 +4,34 @@ import io.ably.lib.util.Base64Coder; import io.ably.lib.util.Crypto; -import io.ably.lib.util.Crypto.ChannelCipher; +import io.ably.lib.util.Crypto.CipherParams; +/** + * Passes additional properties to a {@link io.ably.lib.rest.Channel} or {@link io.ably.lib.realtime.Channel} object, + * such as encryption, {@link ChannelMode} and channel parameters. + */ public class ChannelOptions { + /** + * Channel Parameters + * that configure the behavior of the channel. + *

+ * Spec: TB2c + */ public Map params; - - public ChannelMode[] modes; /** - * Cipher in use. + * An array of {@link ChannelMode} objects. + *

+ * Spec: TB2d */ - private ChannelCipher cipher; + public ChannelMode[] modes; /** - * Parameters for the cipher. + * Requests encryption for this channel when not null, + * and specifies encryption-related parameters (such as algorithm, chaining mode, key length and key). + * See an example. + *

+ * Spec: RSL5a, TB2b */ public Object cipherParams; @@ -25,15 +39,26 @@ public class ChannelOptions { * Whether or not this ChannelOptions is encrypted. */ public boolean encrypted; - + + /** + *

+ * Determines whether calling {@link io.ably.lib.realtime.Channel#subscribe Channel.subscribe} or + * {@link io.ably.lib.realtime.Presence#subscribe Presence.subscribe} method + * should trigger an implicit attach. + *

+ *

Defaults to {@code true}.

+ *

Spec: TB4, RTL7g, RTL7h, RTP6d, RTP6e

+ */ + public boolean attachOnSubscribe = true; + public boolean hasModes() { return null != modes && 0 != modes.length; } - + public boolean hasParams() { return null != params && !params.isEmpty(); } - + public int getModeFlags() { int flags = 0; for (final ChannelMode mode : modes) { @@ -41,18 +66,6 @@ public int getModeFlags() { } return flags; } - - public ChannelCipher getCipher() throws AblyException { - if(!this.encrypted) { - return null; - } - if(this.cipher != null) { - return this.cipher; - } else { - this.cipher = Crypto.getCipher(this); - return this.cipher; - } - } /** * Deprecated. Use withCipherKey(byte[]) instead.

@@ -79,26 +92,40 @@ public static ChannelOptions fromCipherKey(String base64Key) throws AblyExceptio } /** - * Create ChannelOptions with the given cipher key. - * @param key Byte array cipher key. - * @return Created ChannelOptions. + * Constructor withCipherKey, that takes a key only. + *

+ * Spec: TB3 + * @param key A private key used to encrypt and decrypt payloads. + * @return A ChannelOptions object. * @throws AblyException If something goes wrong. */ public static ChannelOptions withCipherKey(byte[] key) throws AblyException { ChannelOptions options = new ChannelOptions(); options.encrypted = true; options.cipherParams = Crypto.getDefaultParams(key); - options.cipher = Crypto.getCipher(options); return options; } /** - * Create ChannelOptions with the given cipher key. - * @param base64Key The cipher key as a base64-encoded String, - * @return Created ChannelOptions. + * Constructor withCipherKey, that takes a key only. + *

+ * Spec: TB3 + * @param base64Key A private key used to encrypt and decrypt payloads. + * @return A ChannelOptions object. * @throws AblyException If something goes wrong. */ public static ChannelOptions withCipherKey(String base64Key) throws AblyException { return withCipherKey(Base64Coder.decode(base64Key)); } + + /** + * Internal; returns cipher params or generate default + */ + public synchronized CipherParams getCipherParamsOrDefault() throws AblyException { + CipherParams params = Crypto.checkCipherParams(this.cipherParams); + if (this.cipherParams == null) { + this.cipherParams = params; + } + return params; + } } diff --git a/lib/src/main/java/io/ably/lib/types/ChannelProperties.java b/lib/src/main/java/io/ably/lib/types/ChannelProperties.java index cdd03603c..482528d18 100644 --- a/lib/src/main/java/io/ably/lib/types/ChannelProperties.java +++ b/lib/src/main/java/io/ably/lib/types/ChannelProperties.java @@ -1,15 +1,28 @@ package io.ably.lib.types; /** - * (RTL15) Channel#properties attribute is a ChannelProperties object representing properties of the channel state + * Describes the properties of the channel state. + *

+ * Spec: CP2 + *

*/ public class ChannelProperties { /** - * A message identifier indicating the time of attachment to the channel; - * used when recovering a message history to mesh exactly with messages - * received on this channel subsequent to attachment. + * Starts unset when a channel is instantiated, then updated with the channelSerial + * from each {@link io.ably.lib.realtime.ChannelState#attached} event that matches the channel. + * Used as the value for {@link io.ably.lib.realtime.Channel#history}. + *

+ * Spec: CP2a */ public String attachSerial; + /** + * ChannelSerial contains the channelSerial from latest ProtocolMessage of action type + * Message/PresenceMessage received on the channel. + *

+ * Spec: CP2b, RTL15b + */ + public String channelSerial; + public ChannelProperties() {} } 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 8444964b3..3d63be81a 100644 --- a/lib/src/main/java/io/ably/lib/types/ClientOptions.java +++ b/lib/src/main/java/io/ably/lib/types/ClientOptions.java @@ -1,5 +1,6 @@ package io.ably.lib.types; +import io.ably.lib.push.Storage; import io.ably.lib.rest.Auth.AuthOptions; import io.ably.lib.rest.Auth.TokenParams; import io.ably.lib.transport.Defaults; @@ -9,19 +10,23 @@ import java.util.Map; /** - * Options: Ably library options for REST and Realtime APIs + * Passes additional client-specific properties to the {@link io.ably.lib.rest.AblyRest} or the {@link io.ably.lib.realtime.AblyRealtime}. + *

+ * Extends an {@link AuthOptions} object. + *

+ * Spec: TO3j */ public class ClientOptions extends AuthOptions { /** - * Default constructor + * Creates a ClientOptions instance used to configure Rest and Realtime clients */ public ClientOptions() {} /** - * Construct an options with a single key string. The key string is obtained - * from the application dashboard. - * @param key the key string + * Creates a ClientOptions instance used to configure Rest and Realtime clients + * + * @param key the key obtained from the application dashboard. * @throws AblyException if the key is not in a valid format */ public ClientOptions(String key) throws AblyException { @@ -30,29 +35,35 @@ public ClientOptions(String key) throws AblyException { } /** - * The id of the client represented by this instance. The clientId is relevant - * to presence operations, where the clientId is the principal identifier of the - * client in presence update messages. The clientId is also relevant to - * authentication; a token issued for a specific client may be used to authenticate - * the bearer of that token to the service. + * A client ID, used for identifying this client when publishing messages or for presence purposes. + * The clientId can be any non-empty string, except it cannot contain a *. + * This option is primarily intended to be used in situations where the library is instantiated with a key. + * Note that a clientId may also be implicit in a token used to instantiate the library. + * An error will be raised if a clientId specified here conflicts with the clientId implicit in the token. + *

+ * Spec: RSC17, RSA4, RSA15, TO3a */ public String clientId; /** - * Log level; controls the level of verbosity of log messages from the library. + * Controls the verbosity of the logs output from the library. Levels include verbose, debug, info, warn and error. + *

+ * Spec: TO3b */ public int logLevel; /** - * Log handler: allows the client to intercept log messages and handle them in a - * client-specific way. + * Controls the log output of the library. This is a function to handle each line of log output. + *

+ * Spec: TO3c */ public LogHandler logHandler; - /** - * Encrypted transport: if true, TLS will be used for all connections (whether REST/HTTP - * or Realtime WebSocket or Comet connections). + * When false, the client will use an insecure connection. + * The default is true, meaning a TLS connection will be used to connect to Ably. + *

+ * Spec: RSC18, TO3d */ public boolean tls = true; @@ -62,54 +73,82 @@ public ClientOptions(String key) throws AblyException { public Map headers; /** - * For development environments only; allows a non-default Ably host to be specified. + * Enables a non-default Ably host to be specified. For development environments only. + * The default value is rest.ably.io. + *

+ * Spec: RSC12, TO3k2 */ public String restHost; /** - * For development environments only; allows a non-default Ably host to be specified for - * websocket connections. + * Enables a non-default Ably host to be specified for realtime connections. + * For development environments only. The default value is realtime.ably.io. + *

+ * Spec: RTC1d, TO3k3 */ public String realtimeHost; /** - * For development environments only; allows a non-default Ably port to be specified. + * Enables a non-default Ably port to be specified. For development environments only. The default value is 80. + *

+ * Spec: TO3k4 */ public int port; /** - * For development environments only; allows a non-default Ably TLS port to be specified. + * Enables a non-default Ably TLS port to be specified. For development environments only. + * The default value is 443. + *

+ * Spec: TO3k5 */ public int tlsPort; /** - * If false, suppresses the automatic initiation of a connection when the library is instanced. + * When true, the client connects to Ably as soon as it is instantiated. + * You can set this to false and explicitly connect to Ably using the + * {@link io.ably.lib.realtime.Connection#connect} method. The default is true. + *

+ * Spec: RTC1b, TO3e */ public boolean autoConnect = true; /** - * If false, forces the library to use the JSON encoding for REST and Realtime operations, - * instead of the default binary msgpack encoding. + * When true, the more efficient MsgPack binary encoding is used. When false, JSON text encoding is used. + * The default is true. + *

+ * Spec: TO3f */ public boolean useBinaryProtocol = true; /** - * If false, suppresses the default queueing of messages when connection states that - * anticipate imminent connection (connecting and disconnected). Instead, publish and - * presence state changes will fail immediately if not in the connected state. + * If false, this disables the default behavior whereby the library queues messages + * on a connection in the disconnected or connecting states. + * The default behavior enables applications to submit messages immediately upon + * instantiating the library without having to wait for the connection to be established. + * Applications may use this option to disable queueing if they wish to have + * application-level control over the queueing. The default is true. + *

+ * Spec: RTP16b, TO3g */ public boolean queueMessages = true; /** - * If false, suppresses messages originating from this connection being echoed back - * on the same connection. + * If false, prevents messages originating from this connection being echoed back on the same connection. The default is true. + *

+ * Spec: RTC1a, TO3h */ public boolean echoMessages = true; /** - * A connection recovery string, specified by a client when initialising the library - * with the intention of inheriting the state of an earlier connection. See the Ably - * Realtime API documentation for further information on connection state recovery. + * Enables a connection to inherit the state of a previous connection that may have existed under a + * different instance of the Realtime library. This might typically be used by clients of the browser + * library to ensure connection state can be preserved when the user refreshes the page. + * A recovery key string can be explicitly provided, or alternatively if a callback function is provided, + * the client library will automatically persist the recovery key between page reloads and call the callback + * when the connection is recoverable. The callback is then responsible for confirming whether the connection + * should be recovered or not. See connection state recovery for further information. + *

+ * Spec: RTC1c, TO3i, RTN16i */ public String recover; @@ -119,69 +158,133 @@ public ClientOptions(String key) throws AblyException { public ProxyOptions proxy; /** - * For development environments only; allows a non-default Ably environment - * to be used such as 'sandbox'. - * Spec: TO3k1 + * Enables a custom environment to be used with the Ably service. + *

+ * Spec: RSC15b, TO3k1 */ public String environment; /** - * Spec: TO3n + * When true, enables idempotent publishing by assigning a unique message ID client-side, + * allowing the Ably servers to discard automatic publish retries following a failure such as a network fault. + * The default is true. + *

+ * Spec: RSL1k1, RTL6a1, TO3n */ - public boolean idempotentRestPublishing = (Defaults.ABLY_VERSION_NUMBER >= 1.2); + public boolean idempotentRestPublishing = true; /** - * Spec: TO313 + * Timeout for opening a connection to Ably to initiate an HTTP request. + * The default is 4 seconds. + *

+ * Spec: TO3l3 */ public int httpOpenTimeout = Defaults.TIMEOUT_HTTP_OPEN; /** - * Spec: TO314 + * Timeout for a client performing a complete HTTP request to Ably, including the connection phase. + * The default is 10 seconds. + *

+ * Spec: TO3l4 */ public int httpRequestTimeout = Defaults.TIMEOUT_HTTP_REQUEST; /** - * Max number of fallback hosts to use as a fallback when an HTTP request to - * the primary host is unreachable or indicates that it is unserviceable + * Denotes elapsed time in which fallback host retries for HTTP requests will be attempted. + * Default is 15 seconds. + * Spec: TO3l6 + */ + public int httpMaxRetryDuration = Defaults.httpMaxRetryDuration; + + /** + * The maximum number of fallback hosts to use as a fallback when an HTTP request to the primary host + * is unreachable or indicates that it is unserviceable. + * The default value is 3. + *

+ * Spec: TO3l5 */ public int httpMaxRetryCount = Defaults.HTTP_MAX_RETRY_COUNT; /** - * Spec: DF1b + * Timeout for the wait of acknowledgement for operations performed via a realtime connection, + * before the client library considers a request failed and triggers a failure condition. + * Operations include establishing a connection with Ably, or sending a HEARTBEAT, CONNECT, ATTACH, DETACH or CLOSE request. + * It is the equivalent of httpRequestTimeout but for realtime operations, rather than REST. + * The default is 10 seconds. + *

+ * Spec: TO3l11 */ public long realtimeRequestTimeout = Defaults.realtimeRequestTimeout; /** - * Spec: TO3k6,RSC15a,RSC15b,RTN17b list of custom fallback hosts. + * When the connection enters the disconnected state, after this timeout, + * if the state is still disconnected, the client library will attempt to reconnect automatically. + * The default is 15 seconds (TO3l1). + *

+ * Spec: TO3l1 + */ + public long disconnectedRetryTimeout = Defaults.TIMEOUT_DISCONNECT; + + /** + * An array of fallback hosts to be used in the case of an error necessitating the use of an alternative host. + * If you have been provided a set of custom fallback hosts by Ably, please specify them here. + *

+ * Spec: RSC15b, RSC15a, TO3k6 */ public String[] fallbackHosts; /** - * Spec: TO3k7 Set to use default fallbackHosts even when overriding - * environment or restHost/realtimeHost + * This is a timeout when the connection enters the suspendedState. + * Client will try to connect indefinitely till state changes to connected. + * The default is 30 seconds. + *

+ * Spec: RTN14d, TO3l2 + */ + public long suspendedRetryTimeout = Defaults.suspendedRetryTimeout; + + /** + * An array of fallback hosts to be used in the case of an error necessitating the use of an alternative host. + * If you have been provided a set of custom fallback hosts by Ably, please specify them here. + *

+ * Spec: RSC15b, RSC15a, TO3k6 */ @Deprecated public boolean fallbackHostsUseDefault; /** + * The maximum time before HTTP requests are retried against the default endpoint. + * The default is 600 seconds. + *

* Spec: TO3l10 */ public long fallbackRetryTimeout = Defaults.fallbackRetryTimeout; + /** - * When a TokenParams object is provided, it will override - * the client library defaults described in TokenParams + * When a {@link TokenParams} object is provided, it overrides the client library + * defaults when issuing new Ably Tokens or Ably {@link io.ably.lib.rest.Auth.TokenRequest}. + *

* Spec: TO3j11 */ public TokenParams defaultTokenParams = new TokenParams(); /** - * Channel reattach timeout - * Spec: RTL13b + * When a channel becomes {@link io.ably.lib.realtime.ChannelState#suspended} + * following a server initiated {@link io.ably.lib.realtime.ChannelState#detached}, + * after this delay, if the channel is still {@link io.ably.lib.realtime.ChannelState#suspended} + * and the connection is {@link io.ably.lib.realtime.ConnectionState#connected}, + * the client library will attempt to re-attach the channel automatically. + * The default is 15 seconds. + *

+ * Spec: RTL13b, TO3l7 */ public int channelRetryTimeout = Defaults.TIMEOUT_CHANNEL_RETRY; /** - * Additional parameters to be sent in the querystring when initiating a realtime connection + * A set of key-value pairs that can be used to pass in arbitrary connection parameters, + * such as heartbeatInterval + * or remainPresentFor. + *

+ * Spec: RTC1f */ public Param[] transportParams; @@ -195,4 +298,95 @@ public ClientOptions(String key) throws AblyException { * before responding. */ public boolean pushFullWait = false; + + /** + * Custom Local Device storage. In the case nothing is provided then a default implementation + * using SharedPreferences is used. + */ + public Storage localStorage = null; + + /** + * When true, every REST request to Ably should include a random string in the request_id query string parameter. + * The random string should be a url-safe base64-encoding sequence of at least 9 bytes, obtained from a source of randomness. + * This request ID must remain the same if a request is retried to a fallback host. + * Any log messages associated with the request should include the request ID. + * If the request fails, the request ID must be included in the {@link ErrorInfo} returned to the user. + * The default is false. + *

+ * Spec: TO3p + */ + public boolean addRequestIds = false; + + /** + * A set of additional entries for the Ably agent header. Each entry can be a key string or set of key-value pairs. + *

+ * Spec: RSC7d6 + */ + public Map agents; + + /** + * Internal method + * + * @return copy of client options + */ + public ClientOptions copy() { + ClientOptions copied = new ClientOptions(); + copied.clientId = clientId; + copied.logLevel = logLevel; + copied.logHandler = logHandler; + copied.tls = tls; + copied.restHost = restHost; + copied.realtimeHost = realtimeHost; + copied.port = port; + copied.tlsPort = tlsPort; + copied.autoConnect = autoConnect; + copied.useBinaryProtocol = useBinaryProtocol; + copied.queueMessages = queueMessages; + copied.echoMessages = echoMessages; + copied.recover = recover; + copied.proxy = proxy; + copied.environment = environment; + copied.idempotentRestPublishing = idempotentRestPublishing; + copied.httpOpenTimeout = httpOpenTimeout; + copied.httpRequestTimeout = httpRequestTimeout; + copied.httpMaxRetryDuration = httpMaxRetryDuration; + copied.httpMaxRetryCount = httpMaxRetryCount; + copied.realtimeRequestTimeout = realtimeRequestTimeout; + copied.disconnectedRetryTimeout = disconnectedRetryTimeout; + copied.suspendedRetryTimeout = suspendedRetryTimeout; + copied.fallbackHostsUseDefault = fallbackHostsUseDefault; + copied.fallbackRetryTimeout = fallbackRetryTimeout; + copied.defaultTokenParams = defaultTokenParams; + copied.channelRetryTimeout = channelRetryTimeout; + copied.asyncHttpThreadpoolSize = asyncHttpThreadpoolSize; + copied.pushFullWait = pushFullWait; + copied.localStorage = localStorage; + copied.addRequestIds = addRequestIds; + copied.authCallback = authCallback; + copied.authUrl = authUrl; + copied.authMethod = authMethod; + copied.key = key; + copied.token = token; + copied.tokenDetails = tokenDetails; + copied.authHeaders = authHeaders; + copied.authParams = authParams; + copied.queryTime = queryTime; + copied.useTokenAuth = useTokenAuth; + return copied; + } + + /** + * Internal method + *

+ * clears all auth options + */ + public void clearAuthOptions() { + key = null; + token = null; + tokenDetails = null; + authHeaders = null; + authParams = null; + queryTime = false; + useTokenAuth = false; + } } diff --git a/lib/src/main/java/io/ably/lib/types/ConnectionDetails.java b/lib/src/main/java/io/ably/lib/types/ConnectionDetails.java index 5abe0718a..0977a2350 100644 --- a/lib/src/main/java/io/ably/lib/types/ConnectionDetails.java +++ b/lib/src/main/java/io/ably/lib/types/ConnectionDetails.java @@ -8,15 +8,70 @@ import org.msgpack.core.MessageFormat; import org.msgpack.core.MessageUnpacker; +/** + * Contains any constraints a client should adhere to and provides additional metadata about a {@link io.ably.lib.realtime.Connection}, + * such as if a request to {@link io.ably.lib.realtime.Channel#publish} a message that exceeds the maximum message size should + * be rejected immediately without communicating with Ably. + */ public class ConnectionDetails { + /** + * Contains the client ID assigned to the token. + * If clientId is null or omitted, then the client is prohibited from assuming a clientId in any operations, + * however if clientId is a wildcard string *, then the client is permitted to assume any clientId. + * Any other string value for clientId implies that the clientId is both enforced and assumed for all operations from this client. + *

+ * Spec: RSA12a, CD2a + */ public String clientId; + /** + * The connection secret key string that is used to resume a connection and its state. + *

+ * Spec: RTN15e, CD2b + */ public String connectionKey; + /** + * A unique identifier for the front-end server that the client has connected to. + * This server ID is only used for the purposes of debugging. + *

+ * Spec: CD2g + */ public String serverId; - public Long maxMessageSize; + /** + * The maximum message size is an attribute of an Ably account and enforced by Ably servers. + * maxMessageSize indicates the maximum message size allowed by the Ably account this connection is using. + *

+ * Spec: CD2c + */ + public int maxMessageSize; + /** + * The maximum allowable number of requests per second from a client or Ably. + * In the case of a realtime connection, this restriction applies to the number of messages sent, + * whereas in the case of REST, it is the total number of REST requests per second. + *

+ * Spec: CD2e + */ public Long maxInboundRate; public Long maxOutboundRate; + + /** + * Overrides the default maxFrameSize. + *

+ * Spec: CD2d + */ public Long maxFrameSize; + /** + * The maximum length of time in milliseconds that the server will allow no activity to occur in the server to client direction. + * After such a period of inactivity, the server will send a HEARTBEAT or transport-level ping to the client. + * If the value is 0, the server will allow arbitrarily-long levels of inactivity. + *

+ * Spec: CD2h + */ public Long maxIdleInterval; + /** + * The duration that Ably will persist the connection state for when a Realtime client is abruptly disconnected. + *

+ * Spec: CD2f, RTN14e, DF1a + */ public Long connectionStateTtl; ConnectionDetails() { @@ -42,7 +97,7 @@ ConnectionDetails readMsgpack(MessageUnpacker unpacker) throws IOException { serverId = unpacker.unpackString(); break; case "maxMessageSize": - maxMessageSize = unpacker.unpackLong(); + maxMessageSize = unpacker.unpackInt(); break; case "maxInboundRate": maxInboundRate = unpacker.unpackLong(); diff --git a/lib/src/main/java/io/ably/lib/types/DeltaExtras.java b/lib/src/main/java/io/ably/lib/types/DeltaExtras.java index afcd8b095..7388755cc 100644 --- a/lib/src/main/java/io/ably/lib/types/DeltaExtras.java +++ b/lib/src/main/java/io/ably/lib/types/DeltaExtras.java @@ -16,7 +16,13 @@ public final class DeltaExtras { private static final String FROM = "from"; private static final String FORMAT = "format"; + /** + * The delta compression format. Only vcdiff is supported. + */ private final String format; + /** + * The ID of the message the delta was generated from. + */ private final String from; private DeltaExtras(final String format, final String from) { diff --git a/lib/src/main/java/io/ably/lib/types/ErrorInfo.java b/lib/src/main/java/io/ably/lib/types/ErrorInfo.java index 6fa3d72b0..949091def 100644 --- a/lib/src/main/java/io/ably/lib/types/ErrorInfo.java +++ b/lib/src/main/java/io/ably/lib/types/ErrorInfo.java @@ -11,28 +11,36 @@ import java.net.UnknownHostException; /** - * An exception type encapsulating error information containing - * an Ably-specific error code and generic status code. + * A generic Ably error object that contains an Ably-specific status code, and a generic status code. + * Errors returned from the Ably server are compatible with the ErrorInfo structure and should result in errors that inherit from ErrorInfo. */ public class ErrorInfo { /** - * Ably error code (see ably-common/protocol/errors.json) + * Ably error code. + *

+ * Spec: TI1 */ public int code; /** - * HTTP Status Code corresponding to this error, where applicable + * HTTP Status Code corresponding to this error, where applicable. + *

+ * Spec: TI1 */ public int statusCode; /** - * Additional message information, where available + * Additional message information, where available. + *

+ * Spec: TI1 */ public String message; /** - * Link to specification detail for this error code, where available. Spec TI4. + * This is included for REST responses to provide a URL for additional help on the error code. + *

+ * Spec: TI4 */ public String href; @@ -43,8 +51,8 @@ public ErrorInfo() {} /** * Construct an ErrorInfo from message and code - * @param message - * @param code + * @param message Additional message information, where available. + * @param code Ably error code. */ public ErrorInfo(String message, int code) { this.code = code; @@ -52,10 +60,10 @@ public ErrorInfo(String message, int code) { } /** - * Generic constructor - * @param message - * @param statusCode - * @param code + * Construct an ErrorInfo from message, statusCode, and code + * @param message Additional message information, where available. + * @param statusCode HTTP Status Code corresponding to this error, where applicable. + * @param code Ably error code. */ public ErrorInfo(String message, int statusCode, int code) { this(message, code); @@ -66,7 +74,7 @@ public ErrorInfo(String message, int statusCode, int code) { } public String toString() { - StringBuilder result = new StringBuilder("[ErrorInfo"); + StringBuilder result = new StringBuilder("{ErrorInfo"); result.append(" message=").append(logMessage()); if(code > 0) { result.append(" code=").append(code); @@ -77,7 +85,7 @@ public String toString() { if(href != null) { result.append(" href=").append(href); } - result.append(']'); + result.append('}'); return result.toString(); } diff --git a/lib/src/main/java/io/ably/lib/types/HttpPaginatedResponse.java b/lib/src/main/java/io/ably/lib/types/HttpPaginatedResponse.java index 20cbbe5ee..47ce89476 100644 --- a/lib/src/main/java/io/ably/lib/types/HttpPaginatedResponse.java +++ b/lib/src/main/java/io/ably/lib/types/HttpPaginatedResponse.java @@ -3,19 +3,46 @@ import com.google.gson.JsonElement; /** - * A type that represents a page of results from a paginated http query. - * The response is accompanied by response details and metadata that - * indicates the relative queries available. + * A superset of {@link PaginatedResult} which represents a page of results plus metadata indicating the relative queries available to it. + * HttpPaginatedResponse additionally carries information about the response to an HTTP request. */ public abstract class HttpPaginatedResponse { + /** + * Whether statusCode indicates success. This is equivalent to 200 <= statusCode < 300. + *

+ * Spec: HP5 + */ public boolean success; + /** + * The HTTP status code of the response. + *

+ * Spec: HP4 + */ public int statusCode; + /** + * The error code if the X-Ably-Errorcode HTTP header is sent in the response. + *

+ * Spec: HP6 + */ public int errorCode; + /** + * The error message if the X-Ably-Errormessage HTTP header is sent in the response. + *

+ * Spec: HP7 + */ public String errorMessage; + /** + * The headers of the response. + *

+ * Spec: HP8 + */ public Param[] headers; /** - * Get the contents as an array of component type + * Contains a page of results; for example, + * an array of {@link Message} or {@link PresenceMessage} objects for a channel history request. + *

+ * Spec: HP3 */ public abstract JsonElement[] items(); diff --git a/lib/src/main/java/io/ably/lib/types/Message.java b/lib/src/main/java/io/ably/lib/types/Message.java index e8ab42942..06e9c17c1 100644 --- a/lib/src/main/java/io/ably/lib/types/Message.java +++ b/lib/src/main/java/io/ably/lib/types/Message.java @@ -3,6 +3,9 @@ import java.io.IOException; import java.lang.reflect.Type; import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + import com.google.gson.JsonArray; import com.google.gson.JsonDeserializer; import com.google.gson.JsonDeserializationContext; @@ -19,29 +22,171 @@ import io.ably.lib.util.Log; /** - * A class representing an individual message to be sent or received - * via the Ably Realtime service. + * Contains an individual message that is sent to, or received from, Ably. */ public class Message extends BaseMessage { /** - * The event name, if available. + * The event name. + *

+ * Spec: TM2g */ public String name; /** - * Extras, if available. + * A MessageExtras object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. + * Valid payloads include {@link DeltaExtras}, {@link JsonObject}. + *

+ * Spec: TM2i */ public MessageExtras extras; /** * Key needed only in case one client is publishing this message on behalf of another client. + * The connectionKey will never be populated for messages received. + *

+ * Spec: TM2h */ public String connectionKey; + /** + * (TM2k) serial string – an opaque string that uniquely identifies the message. If a message received from Ably + * (whether over realtime or REST, eg history) with an action of MESSAGE_CREATE does not contain a serial, + * the SDK must set it equal to its version. + */ + public String serial; + + /** + * (TM2p) version string – an opaque string that uniquely identifies the message, and is different for different versions. + * (May not be populated depending on app & channel namespace settings) + */ + public String version; + + /** + * (TM2j) action enum + */ + public MessageAction action; + + /** + * (TM2o) createdAt time in milliseconds since epoch. If a message received from Ably + * (whether over realtime or REST, eg history) with an action of MESSAGE_CREATE does not contain a createdAt, + * the SDK must set it equal to the TM2f timestamp. + */ + public Long createdAt; + + /** + * (TM2l) ref string – an opaque string that uniquely identifies some referenced message. + */ + public String refSerial; + + /** + * (TM2m) refType string – an opaque string that identifies the type of this reference. + */ + public String refType; + + /** + * (TM2n) operation object – data object that may contain the `optional` attributes. + */ + public Operation operation; + + /** + * (TM2q) A summary of all the annotations that have been made to the message, whose keys are the `type` fields + * from any annotations that it includes. Will always be populated for a message with action {@code MESSAGE_SUMMARY}, + * and may be populated for any other type (in particular a message retrieved from + * REST history will have its latest summary included). + */ + public Summary summary; + + public static class Operation { + public String clientId; + public String description; + public Map metadata; + + void write(MessagePacker packer) throws IOException { + int fieldCount = 0; + if (clientId != null) fieldCount++; + if (description != null) fieldCount++; + if (metadata != null) fieldCount++; + + packer.packMapHeader(fieldCount); + + if (clientId != null) { + packer.packString("clientId"); + packer.packString(clientId); + } + if (description != null) { + packer.packString("description"); + packer.packString(description); + } + if (metadata != null) { + packer.packString("metadata"); + packer.packMapHeader(metadata.size()); + for (Map.Entry entry : metadata.entrySet()) { + packer.packString(entry.getKey()); + packer.packString(entry.getValue()); + } + } + } + + protected static Operation read(final MessageUnpacker unpacker) throws IOException { + Operation operation = new Operation(); + int fieldCount = unpacker.unpackMapHeader(); + for (int i = 0; i < fieldCount; i++) { + String fieldName = unpacker.unpackString().intern(); + switch (fieldName) { + case "clientId": + operation.clientId = unpacker.unpackString(); + break; + case "description": + operation.description = unpacker.unpackString(); + break; + case "metadata": + int mapSize = unpacker.unpackMapHeader(); + operation.metadata = new HashMap<>(mapSize); + for (int j = 0; j < mapSize; j++) { + String key = unpacker.unpackString(); + String value = unpacker.unpackString(); + operation.metadata.put(key, value); + } + break; + default: + unpacker.skipValue(); + break; + } + } + return operation; + } + + protected static Operation read(final JsonObject jsonObject) throws MessageDecodeException { + Operation operation = new Operation(); + if (jsonObject.has("clientId")) { + operation.clientId = jsonObject.get("clientId").getAsString(); + } + if (jsonObject.has("description")) { + operation.description = jsonObject.get("description").getAsString(); + } + if (jsonObject.has("metadata")) { + JsonObject metadataObject = jsonObject.getAsJsonObject("metadata"); + operation.metadata = new HashMap<>(); + for (Map.Entry entry : metadataObject.entrySet()) { + operation.metadata.put(entry.getKey(), entry.getValue().getAsString()); + } + } + return operation; + } + } + private static final String NAME = "name"; private static final String EXTRAS = "extras"; private static final String CONNECTION_KEY = "connectionKey"; + private static final String SERIAL = "serial"; + private static final String VERSION = "version"; + private static final String ACTION = "action"; + private static final String CREATED_AT = "createdAt"; + private static final String REF_SERIAL = "refSerial"; + private static final String REF_TYPE = "refType"; + private static final String OPERATION = "operation"; + private static final String SUMMARY = "summary"; /** * Default constructor @@ -50,44 +195,52 @@ public Message() { } /** - * Construct a message from event name and data. + * Construct a Message object with an event name and payload. + *

+ * Spec: TM2 * - * @param name the event name - * @param data the message payload + * @param name The event name. + * @param data The message payload. */ public Message(String name, Object data) { this(name, data, null, null); } /** - * Construct a message from name, data, and client id. + * Construct a Message object with an event name, payload, and a unique client ID. + *

+ * Spec: TM2 * - * @param name the event name - * @param data the message payload - * @param clientId the client identifier + * @param name The event name. + * @param data The message payload. + * @param clientId The client ID of the publisher of this message. */ public Message(String name, Object data, String clientId) { this(name, data, clientId, null); } /** - * Construct a message from name, data, and extras. + * Construct a Message object with an event name, payload, and a extras. + *

+ * Spec: TM2 * - * @param name the event name - * @param data the message payload - * @param extras extra information to be sent with this message + * @param name The event name. + * @param data The message payload. + * @param extras Extra information to be sent with this message. */ public Message(String name, Object data, MessageExtras extras) { this(name, data, null, extras); } /** - * Construct a message from name, data, client id, and extras. + * Construct a Message object with an event name, payload, extras, and a unique client ID. + *

+ * Spec: TM2 * - * @param name the event name - * @param data the message payload - * @param clientId the client identifier - * @param extras extra information to be sent with this message + * @param name The event name. + * @param data The message payload. + * @param clientId The client ID of the publisher of this message. + * @param extras Extra information to be sent with this message. */ public Message(String name, Object data, String clientId, MessageExtras extras) { this.name = name; @@ -101,11 +254,11 @@ public Message(String name, Object data, String clientId, MessageExtras extras) * @return string */ public String toString() { - StringBuilder result = new StringBuilder("[Message"); + StringBuilder result = new StringBuilder("{Message"); super.getDetails(result); if(name != null) result.append(" name=").append(name); - result.append(']'); + result.append('}'); return result.toString(); } @@ -113,6 +266,16 @@ void writeMsgpack(MessagePacker packer) throws IOException { int fieldCount = super.countFields(); if(name != null) ++fieldCount; if(extras != null) ++fieldCount; + if(connectionKey != null) ++fieldCount; + if(serial != null) ++fieldCount; + if(version != null) ++fieldCount; + if(action != null) ++fieldCount; + if(createdAt != null) ++fieldCount; + if(refSerial != null) ++fieldCount; + if(refType != null) ++fieldCount; + if(operation != null) ++fieldCount; + if(summary != null) ++fieldCount; + packer.packMapHeader(fieldCount); super.writeFields(packer); if(name != null) { @@ -123,6 +286,42 @@ void writeMsgpack(MessagePacker packer) throws IOException { packer.packString(EXTRAS); extras.write(packer); } + if(connectionKey != null) { + packer.packString(CONNECTION_KEY); + packer.packString(connectionKey); + } + if(serial != null) { + packer.packString(SERIAL); + packer.packString(serial); + } + if(version != null) { + packer.packString(VERSION); + packer.packString(version); + } + if(action != null) { + packer.packString(ACTION); + packer.packInt(action.ordinal()); + } + if(createdAt != null) { + packer.packString(CREATED_AT); + packer.packLong(createdAt); + } + if(refSerial != null) { + packer.packString(REF_SERIAL); + packer.packString(refSerial); + } + if(refType != null) { + packer.packString(REF_TYPE); + packer.packString(refType); + } + if(operation != null) { + packer.packString(OPERATION); + operation.write(packer); + } + if(summary != null) { + packer.packString(SUMMARY); + summary.write(packer); + } } Message readMsgpack(MessageUnpacker unpacker) throws IOException { @@ -142,7 +341,26 @@ Message readMsgpack(MessageUnpacker unpacker) throws IOException { name = unpacker.unpackString(); } else if (fieldName.equals(EXTRAS)) { extras = MessageExtras.read(unpacker); - } else { + } else if (fieldName.equals(CONNECTION_KEY)) { + connectionKey = unpacker.unpackString(); + } else if (fieldName.equals(SERIAL)) { + serial = unpacker.unpackString(); + } else if (fieldName.equals(VERSION)) { + version = unpacker.unpackString(); + } else if (fieldName.equals(ACTION)) { + action = MessageAction.tryFindByOrdinal(unpacker.unpackInt()); + } else if (fieldName.equals(CREATED_AT)) { + createdAt = unpacker.unpackLong(); + } else if (fieldName.equals(REF_SERIAL)) { + refSerial = unpacker.unpackString(); + } else if (fieldName.equals(REF_TYPE)) { + refType = unpacker.unpackString(); + } else if (fieldName.equals(OPERATION)) { + operation = Operation.read(unpacker); + } else if (fieldName.equals(SUMMARY)) { + summary = Summary.read(unpacker); + } + else { Log.v(TAG, "Unexpected field: " + fieldName); unpacker.skipValue(); } @@ -151,11 +369,16 @@ Message readMsgpack(MessageUnpacker unpacker) throws IOException { } /** - * A specification for a collection of messages to be sent using the batch API - * @author paddy + * Sets the channel names and message contents to {@link io.ably.lib.realtime.AblyRealtime#publishBatch}. */ public static class Batch { + /** + * An array of channel names to publish messages to. + */ public String[] channels; + /** + * An array of {@link Message} objects to publish. + */ public Message[] messages; public Batch(String channel, Message[] messages) { @@ -191,11 +414,13 @@ static Message fromMsgpack(MessageUnpacker unpacker) throws IOException { } /** - * Refer Spec TM3
- * An alternative constructor that take an Message-JSON object and a channelOptions (optional), and return a Message - * @param messageJson - * @param channelOptions - * @return + * A static factory method to create a Message object from a deserialized Message-like object encoded using Ably's wire protocol. + *

+ * Spec: TM3 + * @param messageJson A Message-like deserialized object. + * @param channelOptions A {@link ChannelOptions} object. + * If you have an encrypted channel, use this to allow the library to decrypt the data. + * @return A Message object. * @throws MessageDecodeException */ public static Message fromEncoded(JsonObject messageJson, ChannelOptions channelOptions) throws MessageDecodeException { @@ -210,11 +435,13 @@ public static Message fromEncoded(JsonObject messageJson, ChannelOptions channel } /** - * Refer Spec TM3
- * An alternative constructor that takes a Stringified Message-JSON and a channelOptions (optional), and return a Message - * @param messageJson - * @param channelOptions - * @return + * A static factory method to create a Message object from a deserialized Message-like object encoded using Ably's wire protocol. + *

+ * Spec: TM3 + * @param messageJson A Message-like deserialized object. + * @param channelOptions A {@link ChannelOptions} object. + * If you have an encrypted channel, use this to allow the library to decrypt the data. + * @return A Message object. * @throws MessageDecodeException */ public static Message fromEncoded(String messageJson, ChannelOptions channelOptions) throws MessageDecodeException { @@ -228,11 +455,14 @@ public static Message fromEncoded(String messageJson, ChannelOptions channelOpti } /** - * Refer Spec TM3
- * An alternative constructor that takes a Messages JsonArray and a channelOptions (optional), and return array of Messages. - * @param messageArray - * @param channelOptions - * @return + * A static factory method to create an array of Message objects from an array of deserialized + * Message-like object encoded using Ably's wire protocol. + *

+ * Spec: TM3 + * @param messageArray An array of Message-like deserialized objects. + * @param channelOptions A {@link ChannelOptions} object. + * If you have an encrypted channel, use this to allow the library to decrypt the data. + * @return An array of {@link Message} objects. * @throws MessageDecodeException */ public static Message[] fromEncodedArray(JsonArray messageArray, ChannelOptions channelOptions) throws MessageDecodeException { @@ -247,16 +477,20 @@ public static Message[] fromEncodedArray(JsonArray messageArray, ChannelOptions } return messages; } catch(Exception e) { - e.printStackTrace(); + Log.e(Message.class.getName(), e.getMessage(), e); throw MessageDecodeException.fromDescription(e.getMessage()); } } /** - * - * @param messagesArray - * @param channelOptions - * @return + * A static factory method to create an array of Message objects from an array of deserialized + * Message-like object encoded using Ably's wire protocol. + *

+ * Spec: TM3 + * @param messagesArray An array of Message-like deserialized objects. + * @param channelOptions A {@link ChannelOptions} object. + * If you have an encrypted channel, use this to allow the library to decrypt the data. + * @return An array of {@link Message} objects. * @throws MessageDecodeException */ public static Message[] fromEncodedArray(String messagesArray, ChannelOptions channelOptions) throws MessageDecodeException { @@ -264,7 +498,7 @@ public static Message[] fromEncodedArray(String messagesArray, ChannelOptions ch JsonArray jsonArray = Serialisation.gson.fromJson(messagesArray, JsonArray.class); return fromEncodedArray(jsonArray, channelOptions); } catch(Exception e) { - e.printStackTrace(); + Log.e(Message.class.getName(), e.getMessage(), e); throw MessageDecodeException.fromDescription(e.getMessage()); } } @@ -282,6 +516,31 @@ protected void read(final JsonObject map) throws MessageDecodeException { } extras = MessageExtras.read((JsonObject) extrasElement); } + connectionKey = readString(map, CONNECTION_KEY); + + serial = readString(map, SERIAL); + version = readString(map, VERSION); + Integer actionOrdinal = readInt(map, ACTION); + action = actionOrdinal == null ? null : MessageAction.tryFindByOrdinal(actionOrdinal); + createdAt = readLong(map, CREATED_AT); + refSerial = readString(map, REF_SERIAL); + refType = readString(map, REF_TYPE); + + final JsonElement operationElement = map.get(OPERATION); + if (null != operationElement) { + if (!operationElement.isJsonObject()) { + throw MessageDecodeException.fromDescription("Message operation is of type \"" + operationElement.getClass() + "\" when expected a JSON object."); + } + operation = Operation.read(operationElement.getAsJsonObject()); + } + + final JsonElement summaryElement = map.get(SUMMARY); + if (summaryElement != null) { + if (!summaryElement.isJsonObject()) { + throw MessageDecodeException.fromDescription("Message summary is of type \"" + summaryElement.getClass() + "\" when expected a JSON object."); + } + summary = Summary.read(summaryElement.getAsJsonObject()); + } } public static class Serializer implements JsonSerializer, JsonDeserializer { @@ -297,6 +556,30 @@ public JsonElement serialize(Message message, Type typeOfMessage, JsonSerializat if (message.connectionKey != null) { json.addProperty(CONNECTION_KEY, message.connectionKey); } + if (message.serial != null) { + json.addProperty(SERIAL, message.serial); + } + if (message.version != null) { + json.addProperty(VERSION, message.version); + } + if (message.action != null) { + json.addProperty(ACTION, message.action.ordinal()); + } + if (message.createdAt != null) { + json.addProperty(CREATED_AT, message.createdAt); + } + if (message.refSerial != null) { + json.addProperty(REF_SERIAL, message.refSerial); + } + if (message.refType != null) { + json.addProperty(REF_TYPE, message.refType); + } + if (message.operation != null) { + json.add(OPERATION, Serialisation.gson.toJsonTree(message.operation)); + } + if (message.summary != null) { + json.add(SUMMARY, message.summary.toJsonTree()); + } return json; } @@ -310,7 +593,7 @@ public Message deserialize(JsonElement json, Type typeOfT, JsonDeserializationCo try { message.read((JsonObject)json); } catch (MessageDecodeException e) { - e.printStackTrace(); + Log.e(Message.class.getName(), e.getMessage(), e); throw new JsonParseException("Failed to deserialize Message from JSON.", e); } return message; diff --git a/lib/src/main/java/io/ably/lib/types/MessageAction.java b/lib/src/main/java/io/ably/lib/types/MessageAction.java new file mode 100644 index 000000000..d80f3624f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/MessageAction.java @@ -0,0 +1,13 @@ +package io.ably.lib.types; + +public enum MessageAction { + MESSAGE_CREATE, // 0 + MESSAGE_UPDATE, // 1 + MESSAGE_DELETE, // 2 + META, // 3 + MESSAGE_SUMMARY; // 4 + + static MessageAction tryFindByOrdinal(int ordinal) { + return values().length <= ordinal ? null: values()[ordinal]; + } +} diff --git a/lib/src/main/java/io/ably/lib/types/MessageDecodeException.java b/lib/src/main/java/io/ably/lib/types/MessageDecodeException.java index 6d89d63a8..3c4043201 100644 --- a/lib/src/main/java/io/ably/lib/types/MessageDecodeException.java +++ b/lib/src/main/java/io/ably/lib/types/MessageDecodeException.java @@ -13,7 +13,7 @@ private MessageDecodeException(Throwable e, ErrorInfo errorInfo) { public static MessageDecodeException fromDescription(String description) { return new MessageDecodeException( new Exception(description), - new ErrorInfo(description, 91200)); + new ErrorInfo(description, 40013)); } public static MessageDecodeException fromThrowableAndErrorInfo(Throwable e, ErrorInfo errorInfo) { diff --git a/lib/src/main/java/io/ably/lib/types/MessageExtras.java b/lib/src/main/java/io/ably/lib/types/MessageExtras.java index 5a8e7d576..d3da9a92d 100644 --- a/lib/src/main/java/io/ably/lib/types/MessageExtras.java +++ b/lib/src/main/java/io/ably/lib/types/MessageExtras.java @@ -28,7 +28,7 @@ public final class MessageExtras { /** * Creates a MessageExtras instance to be sent as extra with a Message to Ably's servers. * - * @see Channel-based push notification example + * @see Channel-based push notification example * * @since 1.2.1 */ diff --git a/lib/src/main/java/io/ably/lib/types/NonRetriableTokenException.java b/lib/src/main/java/io/ably/lib/types/NonRetriableTokenException.java new file mode 100644 index 000000000..14a9e3c7f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/NonRetriableTokenException.java @@ -0,0 +1,8 @@ +package io.ably.lib.types; + +/** + * Implement this marker interface in your exception class if the token auth operation should not be retried. + */ +public interface NonRetriableTokenException { + +} diff --git a/lib/src/main/java/io/ably/lib/types/PaginatedResult.java b/lib/src/main/java/io/ably/lib/types/PaginatedResult.java index 76e69d3f5..e606d2712 100644 --- a/lib/src/main/java/io/ably/lib/types/PaginatedResult.java +++ b/lib/src/main/java/io/ably/lib/types/PaginatedResult.java @@ -1,29 +1,59 @@ package io.ably.lib.types; /** - * A type that represents a page of results from a paginated query. - * The response is accompanied by metadata that indicates the relative - * queries available. + * Contains a page of results for message or presence history, stats, or REST presence requests. + * A PaginatedResult response from a REST API paginated query is also accompanied by metadata that indicates + * the relative queries available to the PaginatedResult object. * * @param */ public interface PaginatedResult { /** - * Get the contents as an array of component type + * Contains the current page of results; for example, an array of {@link Message} or {@link PresenceMessage} + * objects for a channel history request. + *

+ * Spec: TG3 */ T[] items(); /** - * Perform the given relative query + * Returns a new PaginatedResult for the first page of results. + *

+ * Spec: TG5 */ PaginatedResult first() throws AblyException; + /** + * Returns a new PaginatedResult for the current page of results. + *

+ * Spec: TG5 + */ PaginatedResult current() throws AblyException; + /** + * Returns a new PaginatedResult loaded with the next page of results. + * If there are no further pages, then null is returned. + *

+ * Spec: TG4 + * @return A page of results for message and presence history, stats, and REST presence requests. + */ PaginatedResult next() throws AblyException; boolean hasFirst(); boolean hasCurrent(); + + /** + * Returns true if there are more pages available by calling next and returns false if this page is the last page available. + *

+ * Spec: TG6 + * @return Whether or not there are more pages of results. + */ boolean hasNext(); + /** + * Returns true if this page is the last page and returns false if there are more pages available by calling next available. + *

+ * Spec: TG7 + * @return Whether or not this is the last page of results. + */ boolean isLast(); } diff --git a/lib/src/main/java/io/ably/lib/types/Param.java b/lib/src/main/java/io/ably/lib/types/Param.java index aca9ffc85..8a2e85365 100644 --- a/lib/src/main/java/io/ably/lib/types/Param.java +++ b/lib/src/main/java/io/ably/lib/types/Param.java @@ -10,6 +10,10 @@ public class Param { public String key; public String value; + public static Param[] array(final Param val) { + return new Param[] { val }; + } + public static Param[] push(Param[] params, Param val) { if (params == null) { return new Param[] { val }; diff --git a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java index 51523a31e..d3073a879 100644 --- a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java +++ b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java @@ -24,19 +24,55 @@ public class PresenceMessage extends BaseMessage implements Cloneable { /** - * Presence Action: the event signified by a PresenceMessage + * Describes the possible actions members in the presence set can emit. */ public enum Action { + /** + * A member is not present in the channel. + *

+ * Spec: TP2 + */ absent, + /** + * When subscribing to presence events on a channel that already has members present, + * this event is emitted for every member already present on the channel before the subscribe listener was registered. + *

+ * Spec: TP2 + */ present, + /** + * A new member has entered the channel. + *

+ * Spec: TP2 + */ enter, + /** + * A member who was present has now left the channel. + * This may be a result of an explicit request to leave or implicitly when detaching from the channel. + * Alternatively, if a member's connection is abruptly disconnected and they do not resume their connection within a minute, + * Ably treats this as a leave event as the client is no longer present. + *

+ * Spec: TP2 + */ leave, + /** + * An already present member has updated their member data. + * Being notified of member data updates can be very useful, for example, + * it can be used to update the status of a user when they are typing a message. + *

+ * Spec: TP2 + */ update; public int getValue() { return ordinal(); } public static Action findByValue(int value) { return values()[value]; } } + /** + * The type of {@link PresenceMessage.Action} the PresenceMessage is for. + *

+ * Spec: TP3b + */ public Action action; /** @@ -70,10 +106,10 @@ public PresenceMessage(Action action, String clientId, Object data) { * @return string */ public String toString() { - StringBuilder result = new StringBuilder("[PresenceMessage"); + StringBuilder result = new StringBuilder("{PresenceMessage"); super.getDetails(result); result.append(" action=").append(action.name()); - result.append(']'); + result.append('}'); return result.toString(); } @@ -122,11 +158,16 @@ static PresenceMessage fromMsgpack(MessageUnpacker unpacker) throws IOException } /** - * Refer Spec TP4
- * An alternative constructor that take an PresenceMessage-JSON object and a channelOptions (optional), and return a PresenceMessage - * @param messageJsonObject - * @param channelOptions - * @return + * Decodes and decrypts a deserialized PresenceMessage-like object using the cipher in {@link ChannelOptions}. + * Any residual transforms that cannot be decoded or decrypted will be in the encoding property. + * Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) + * to avoid having to parse the encoding string. + *

+ * Spec: TP4 + * + * @param messageJsonObject The deserialized PresenceMessage-like object to decode and decrypt. + * @param channelOptions A {@link ChannelOptions} object containing the cipher. + * @return A PresenceMessage object. * @throws MessageDecodeException */ public static PresenceMessage fromEncoded(JsonObject messageJsonObject, ChannelOptions channelOptions) throws MessageDecodeException { @@ -144,11 +185,16 @@ public static PresenceMessage fromEncoded(JsonObject messageJsonObject, ChannelO } /** - * Refer Spec TP4
- * An alternative constructor that takes a Stringified PresenceMessage-JSON and a channelOptions (optional), and return a PresenceMessage - * @param messageJson - * @param channelOptions - * @return + * Decodes and decrypts a deserialized PresenceMessage-like object using the cipher in {@link ChannelOptions}. + * Any residual transforms that cannot be decoded or decrypted will be in the encoding property. + * Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) + * to avoid having to parse the encoding string. + *

+ * Spec: TP4 + * + * @param messageJson The deserialized PresenceMessage-like object to decode and decrypt. + * @param channelOptions A {@link ChannelOptions} object containing the cipher. + * @return A PresenceMessage object. * @throws MessageDecodeException */ public static PresenceMessage fromEncoded(String messageJson, ChannelOptions channelOptions) throws MessageDecodeException { @@ -162,11 +208,16 @@ public static PresenceMessage fromEncoded(String messageJson, ChannelOptions cha } /** - * Refer Spec TP4
- * An alternative constructor that takes a PresenceMessage JsonArray and a channelOptions (optional), and return array of PresenceMessages. - * @param presenceMsgArray - * @param channelOptions - * @return + * Decodes and decrypts an array of deserialized PresenceMessage-like object using the cipher in {@link ChannelOptions}. + * Any residual transforms that cannot be decoded or decrypted will be in the encoding property. + * Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) + * to avoid having to parse the encoding string. + *

+ * Spec: TP4 + * + * @param presenceMsgArray An array of deserialized PresenceMessage-like objects to decode and decrypt. + * @param channelOptions A {@link ChannelOptions} object containing the cipher. + * @return An array of PresenceMessage object. * @throws MessageDecodeException */ public static PresenceMessage[] fromEncodedArray(JsonArray presenceMsgArray, ChannelOptions channelOptions) throws MessageDecodeException { @@ -187,11 +238,16 @@ public static PresenceMessage[] fromEncodedArray(JsonArray presenceMsgArray, Cha } /** - * Refer Spec TP4
- * An alternative constructor that takes a Stringified PresenceMessages Array and a channelOptions (optional), and return array of PresenceMessages. - * @param presenceMsgArray - * @param channelOptions - * @return + * Decodes and decrypts an array of deserialized PresenceMessage-like object using the cipher in {@link ChannelOptions}. + * Any residual transforms that cannot be decoded or decrypted will be in the encoding property. + * Intended for users receiving messages from a source other than a REST or Realtime channel (for example a queue) + * to avoid having to parse the encoding string. + *

+ * Spec: TP4 + * + * @param presenceMsgArray An array of deserialized PresenceMessage-like objects to decode and decrypt. + * @param channelOptions A {@link ChannelOptions} object containing the cipher. + * @return An array of PresenceMessage object. * @throws MessageDecodeException */ public static PresenceMessage[] fromEncodedArray(String presenceMsgArray, ChannelOptions channelOptions) throws MessageDecodeException { @@ -222,8 +278,11 @@ public JsonElement serialize(PresenceMessage message, Type typeOfMessage, JsonSe } /** - * Get the member key for the PresenceMessage. - * @return + * Combines clientId and connectionId to ensure that multiple connected clients with an identical clientId are uniquely identifiable. + * A string function that returns the combined clientId and connectionId. + *

+ * Spec: TP3h + * @return A combination of clientId and connectionId. */ public String memberKey() { return connectionId + ':' + clientId; diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java index 9a513dff6..0548e4c64 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java @@ -4,6 +4,11 @@ import java.lang.reflect.Type; import java.util.Map; +import com.google.gson.annotations.JsonAdapter; +import io.ably.lib.objects.ObjectsSerializer; +import io.ably.lib.objects.ObjectsHelper; +import io.ably.lib.objects.ObjectsJsonSerializer; +import org.jetbrains.annotations.Nullable; import org.msgpack.core.MessageFormat; import org.msgpack.core.MessagePacker; import org.msgpack.core.MessageUnpacker; @@ -28,24 +33,28 @@ */ public class ProtocolMessage { public enum Action { - heartbeat, - ack, - nack, - connect, - connected, - disconnect, - disconnected, - close, - closed, - error, - attach, - attached, - detach, - detached, - presence, - message, - sync, - auth; + heartbeat, // 0 + ack, // 1 + nack, // 2 + connect, // 3 + connected, // 4 + disconnect, // 5 + disconnected, // 6 + close, // 7 + closed, // 8 + error, // 9 + attach, // 10 + attached, // 11 + detach, // 12 + detached, // 13 + presence, // 14 + message, // 15 + sync, // 16 + auth, // 17 + activate, // 18 + object, // 19 + object_sync, // 20 + annotation; // 21 public int getValue() { return ordinal(); } public static Action findByValue(int value) { return values()[value]; } @@ -57,12 +66,21 @@ public enum Flag { has_backlog(1), resumed(2), attach_resume(5), - + /* Has object flag */ + has_objects(7), /* Channel mode flags */ presence(16), publish(17), subscribe(18), - presence_subscribe(19); + presence_subscribe(19), + // 20 reserved (TR3v) + /* Annotation flags */ + annotation_publish(21), // (TR3w) + annotation_subscribe(22), // (TR3x) + // 23 reserved (TR3v) + /* Object flags */ + object_subscribe(24), // (TR3y) + object_publish(25); // (TR3z) private final int mask; @@ -75,8 +93,12 @@ public int getMask() { } } + /** + * (RTN7a) + */ public static boolean ackRequired(ProtocolMessage msg) { - return (msg.action == Action.message || msg.action == Action.presence); + return (msg.action == Action.message || msg.action == Action.presence + || msg.action == Action.object || msg.action == Action.annotation); } public ProtocolMessage() {} @@ -98,7 +120,6 @@ public ProtocolMessage(Action action, String channel) { public String channel; public String channelSerial; public String connectionId; - public Long connectionSerial; public Long msgSerial; public long timestamp; public Message[] messages; @@ -106,6 +127,15 @@ public ProtocolMessage(Action action, String channel) { public ConnectionDetails connectionDetails; public AuthDetails auth; public Map params; + public Annotation[] annotations; + /** + * This will be null if we skipped decoding this property due to user not requesting Objects functionality + * JsonAdapter annotation supports java version (1.8) mentioned in build.gradle + * This is targeted and specific to the state field, so won't affect other fields + */ + @Nullable + @JsonAdapter(ObjectsJsonSerializer.class) + public Object[] state; public boolean hasFlag(final Flag flag) { return (flags & flag.getMask()) == flag.getMask(); @@ -129,6 +159,8 @@ void writeMsgpack(MessagePacker packer) throws IOException { if(flags != 0) ++fieldCount; if(params != null) ++fieldCount; if(channelSerial != null) ++fieldCount; + if(annotations != null) ++fieldCount; + if(state != null && ObjectsHelper.getSerializer() != null) ++fieldCount; packer.packMapHeader(fieldCount); packer.packString("action"); packer.packInt(action.getValue()); @@ -164,6 +196,19 @@ void writeMsgpack(MessagePacker packer) throws IOException { packer.packString("channelSerial"); packer.packString(channelSerial); } + if(annotations != null) { + packer.packString("annotations"); + AnnotationSerializer.writeMsgpackArray(annotations, packer); + } + if(state != null) { + ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer(); + if (objectsSerializer != null) { + packer.packString("state"); + objectsSerializer.writeMsgpackArray(state, packer); + } else { + Log.w(TAG, "Skipping 'state' field msgpack serialization because ObjectsSerializer not found"); + } + } } ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { @@ -198,9 +243,6 @@ ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { case "connectionId": connectionId = unpacker.unpackString(); break; - case "connectionSerial": - connectionSerial = Long.valueOf(unpacker.unpackLong()); - break; case "msgSerial": msgSerial = Long.valueOf(unpacker.unpackLong()); break; @@ -226,6 +268,18 @@ ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { case "params": params = MessageSerializer.readStringMap(unpacker); break; + case "annotations": + annotations = AnnotationSerializer.readMsgpackArray(unpacker); + break; + case "state": + ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer(); + if (objectsSerializer != null) { + state = objectsSerializer.readMsgpackArray(unpacker); + } else { + Log.w(TAG, "Skipping 'state' field msgpack deserialization because ObjectsSerializer not found"); + unpacker.skipValue(); + } + break; default: Log.v(TAG, "Unexpected field: " + fieldName); unpacker.skipValue(); @@ -251,10 +305,26 @@ public JsonElement serialize(Action action, Type t, JsonSerializationContext ctx } } + /** + * Contains the token string used to authenticate a client with Ably. + */ public static class AuthDetails { + /** + * The authentication token string. + *

+ * Spec: AD2 + */ public String accessToken; + /** + * Default constructor + */ private AuthDetails() { } + + /** + * Creates AuthDetails object with provided authentication token string. + * @param s Authentication token string. + */ public AuthDetails(String s) { accessToken = s; } AuthDetails readMsgpack(MessageUnpacker unpacker) throws IOException { diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java b/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java index 97e5fc80b..e33bfe186 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java @@ -14,7 +14,7 @@ public class ProtocolSerializer { /**************************************** * Msgpack decode ****************************************/ - + public static ProtocolMessage readMsgpack(byte[] packed) throws AblyException { try { MessageUnpacker unpacker = Serialisation.msgpackUnpackerConfig.newUnpacker(packed); @@ -27,22 +27,23 @@ public static ProtocolMessage readMsgpack(byte[] packed) throws AblyException { /**************************************** * Msgpack encode ****************************************/ - - public static byte[] writeMsgpack(ProtocolMessage message) { + + public static byte[] writeMsgpack(ProtocolMessage message) throws AblyException { ByteArrayOutputStream out = new ByteArrayOutputStream(); MessagePacker packer = Serialisation.msgpackPackerConfig.newPacker(out); try { message.writeMsgpack(packer); - packer.flush(); return out.toByteArray(); - } catch(IOException e) { return null; } + } catch (IOException ioe) { + throw AblyException.fromThrowable(ioe); + } } /**************************************** * JSON decode ****************************************/ - + public static ProtocolMessage fromJSON(String packed) throws AblyException { return Serialisation.gson.fromJson(packed, ProtocolMessage.class); } @@ -50,7 +51,7 @@ public static ProtocolMessage fromJSON(String packed) throws AblyException { /**************************************** * JSON encode ****************************************/ - + public static byte[] writeJSON(ProtocolMessage message) throws AblyException { return Serialisation.gson.toJson(message).getBytes(Charset.forName("UTF-8")); } diff --git a/lib/src/main/java/io/ably/lib/types/PublishResponse.java b/lib/src/main/java/io/ably/lib/types/PublishResponse.java index 7d720d2df..b0a80e6ed 100644 --- a/lib/src/main/java/io/ably/lib/types/PublishResponse.java +++ b/lib/src/main/java/io/ably/lib/types/PublishResponse.java @@ -9,14 +9,28 @@ import java.io.IOException; -/**************************************** - * PublishResponse - ****************************************/ - +/** + * Contains the responses from a {@link PublishResponse} {@link PublishResponse#publish} request. + */ public class PublishResponse { + /** + * Describes the reason for which a message, or messages failed to publish to a channel as an {@link ErrorInfo} object. + *

+ * Spec: BPB2c + */ public ErrorInfo error; + /** + * The channel name a message was successfully published to, or the channel name for which an error was returned. + *

+ * Spec: BPB2a + */ @SerializedName("channel") public String channelId; + /** + * The unique ID for a successfully published message. + *

+ * Spec: BPB2b + */ public String messageId; private static PublishResponse[] fromJSONArray(byte[] json) { @@ -71,8 +85,24 @@ public static HttpCore.BodyHandler getBulkPublishResponseHandle return (statusCode < 300) ? bulkResponseBodyHandler : batchErrorBodyHandler; } + /** + * Contains the results of a {@link PublishResponse} request. + */ private static class BatchErrorResponse { + /** + * Describes the reason for which a batch operation failed, or states that the batch operation was only + * partially successful as an {@link ErrorInfo} object. + * Will be null if the operation was successful. + *

+ * Spec: BPA2b + */ public ErrorInfo error; + /** + * An array of [BatchPublishResponse]{@link PublishResponse} objects that contain details of successful + * and partially successful batch operations. + *

+ * Spec: BPA2a + */ public PublishResponse[] batchResponse; static BatchErrorResponse readJSON(byte[] json) { diff --git a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java new file mode 100644 index 000000000..c110c9af3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java @@ -0,0 +1,48 @@ +package io.ably.lib.types; + +import com.google.gson.JsonSyntaxException; + +import java.util.HashMap; +import java.util.Map; + +import io.ably.lib.util.Log; +import io.ably.lib.util.Serialisation; + +public class RecoveryKeyContext { + private static final String TAG = "RecoveryKeyContext"; + + private final String connectionKey; + private final long msgSerial; + private final Map channelSerials = new HashMap<>(); + + public RecoveryKeyContext(String connectionKey, long msgSerial, Map channelSerials) { + this.connectionKey = connectionKey; + this.msgSerial = msgSerial; + this.channelSerials.putAll(channelSerials); + } + + public String getConnectionKey() { + return connectionKey; + } + + public long getMsgSerial() { + return msgSerial; + } + + public Map getChannelSerials() { + return channelSerials; + } + + public String encode() { + return Serialisation.gson.toJson(this); + } + + public static RecoveryKeyContext decode(String json) { + try { + return Serialisation.gson.fromJson(json, RecoveryKeyContext.class); + } catch (JsonSyntaxException e) { + Log.e(TAG, "Cannot create recovery key from json: " + e.getMessage()); + return null; + } + } +} diff --git a/lib/src/main/java/io/ably/lib/types/Stats.java b/lib/src/main/java/io/ably/lib/types/Stats.java index 9d7a127ce..ad65f24af 100644 --- a/lib/src/main/java/io/ably/lib/types/Stats.java +++ b/lib/src/main/java/io/ably/lib/types/Stats.java @@ -6,32 +6,51 @@ import java.util.Map; /** - * A class encapsulating a Stats datapoint. - * Ably usage information, across an account or an individual app, - * is available as Stats records on a timeline with different granularities. - * This class defines the Stats type and its subtypes, giving a structured - * representation of service usage for a specific scope and time interval. - * This class also contains utility methods to convert from the different - * formats used for REST responses. + * Contains application statistics for a specified time interval and time period. */ public class Stats { /** - * A breakdown of summary stats data for different (tls vs non-tls) - * connection types. + * Contains a breakdown of summary stats data for different (TLS vs non-TLS) connection types. */ public static class ConnectionTypes { + /** + * A {@link Stats.ResourceCount} object containing a breakdown of usage by scope over TLS connections (both TLS and non-TLS). + *

+ * Spec: TS4c + */ public ResourceCount all; + /** + * A {@link Stats.ResourceCount} object containing a breakdown of usage by scope over non-TLS connections. + *

+ * Spec: TS4b + */ public ResourceCount plain; + /** + * A {@link Stats.ResourceCount} object containing a breakdown of usage by scope over TLS connections. + *

+ * Spec: TS4a + */ public ResourceCount tls; } /** - * A datapoint for message volume (number of messages plus aggregate data size) + * Contains the aggregate counts for messages and data transferred. */ public static class MessageCount { + /** + * The count of all messages. + *

+ * Spec: TS5a + */ public double count; + /** + * The total number of bytes transferred for all messages. + *

+ * Spec: TS5b + */ public double data; + public double uncompressedData; } @@ -40,42 +59,120 @@ public static class MessageCategory extends MessageCount { } /** - * A breakdown of summary stats data for different (message vs presence) - * message types. + * Contains a breakdown of summary stats data for different (channel vs presence) message types. */ public static class MessageTypes { + /** + * A {@link Stats.MessageCount} object containing the count and byte value of messages and presence messages. + *

+ * Spec: TS6c + */ public MessageCategory all; + /** + * A {@link Stats.MessageCount} object containing the count and byte value of messages. + *

+ * Spec: TS6a + */ public MessageCategory messages; + /** + * A {@link Stats.MessageCount} object containing the count and byte value of presence messages. + *

+ * Spec: TS6b + */ public MessageCategory presence; } /** - * A breakdown of summary stats data for traffic over various transport types. + * Contains a breakdown of summary stats data for traffic over various transport types. */ public static class MessageTraffic { + /** + * A {@link Stats.MessageTypes} object containing a breakdown of usage by message type + * for all messages (includes realtime, rest and webhook messages). + *

+ * Spec: TS7d + */ public MessageTypes all; + /** + * A {@link Stats.MessageTypes} object containing a breakdown of usage by message type + * for messages transferred over a realtime transport such as WebSocket. + *

+ * Spec: TS7a + */ public MessageTypes realtime; + /** + * A {@link Stats.MessageTypes} object containing a breakdown of usage by message type + * for messages transferred over a rest transport such as WebSocket. + *

+ * Spec: TS7b + */ public MessageTypes rest; + /** + * A {@link Stats.MessageTypes} object containing a breakdown of usage by message type + * for messages delivered using webhooks. + *

+ * Spec: TS7c + */ public MessageTypes webhook; } /** - * Aggregate data for numbers of requests in a specific scope. + * Contains the aggregate counts for requests made. */ public static class RequestCount { + /** + * The number of requests that succeeded. + *

+ * Spec: TS8a + */ public double succeeded; + /** + * The number of requests that failed. + *

+ * Spec: TS8b + */ public double failed; + /** + * The number of requests that were refused, typically as a result of permissions or a limit being exceeded. + *

+ * Spec: TS8c + */ public double refused; } /** - * Aggregate data for usage of a resource in a specific scope. + * Contains the aggregate data for usage of a resource in a specific scope. */ public static class ResourceCount { + /** + * The total number of resources opened of this type. + *

+ * Spec: TS9a + */ public double opened; + /** + * The peak number of resources of this type used for this period. + *

+ * Spec: TS9b + */ public double peak; + /** + * The average number of resources of this type used for this period. + *

+ * Spec: TS9c + */ public double mean; + /** + * The minimum total resources of this type used for this period. + *

+ * Spec: TS9d + */ public double min; + /** + * The number of resource requests refused within this period. + *

+ * Spec: TS9e + */ public double refused; } @@ -89,10 +186,49 @@ public static class ProcessedMessages { public Map delta; } + /** + * Details the stats on push notifications. + */ + public static class PushedMessages { + /** + * Total number of push messages. + *

+ * Spec: TS10a + */ + public int messages; + /** + * The count of push notifications. + *

+ * Spec: TS10c + */ + public Map notifications; + /** + * Total number of direct publishes. + *

+ * Spec: TS10b + */ + public int directPublishes; + } + + /** + * Describes the interval unit over which statistics are gathered. + */ public enum Granularity { + /** + * Interval unit over which statistics are gathered as minutes. + */ minute, + /** + * Interval unit over which statistics are gathered as hours. + */ hour, + /** + * Interval unit over which statistics are gathered as days. + */ day, + /** + * Interval unit over which statistics are gathered as months. + */ month } @@ -115,15 +251,79 @@ public static long fromIntervalId(String intervalId) { } catch (ParseException e) { return 0; } } + /** + * The UTC time at which the time period covered begins. + * If unit is set to minute this will be in the format YYYY-mm-dd:HH:MM, if hour it will be YYYY-mm-dd:HH, + * if day it will be YYYY-mm-dd:00 and if month it will be YYYY-mm-01:00. + *

+ * Spec: TS12a + */ public String intervalId; + /** + * The length of the interval the stats span. Values will be a {@link Granularity}. + *

+ * Spec: TS12c + */ public String unit; + + public int count; + public String inProgress; + + /** + * A {@link Stats.MessageTypes} object containing the aggregate count of all message stats. + *

+ * Spec: TS12e + */ public MessageTypes all; + /** + * A {@link Stats.MessageTypes} object containing the aggregate count of inbound message stats. + *

+ * Spec: TS12f + */ public MessageTraffic inbound; + /** + * A {@link Stats.MessageTypes} object containing the aggregate count of outbound message stats. + *

+ * Spec: TS12g + */ public MessageTraffic outbound; + /** + * A {@link Stats.MessageTypes} object containing the aggregate count of persisted message stats. + *

+ * Spec: TS12h + */ public MessageTypes persisted; + /** + * A {@link Stats.ConnectionTypes} object containing a breakdown of connection related stats, such as min, mean and peak connections. + *

+ * Spec: TS12i + */ public ConnectionTypes connections; + /** + * A {@link Stats.ResourceCount} object containing a breakdown of channels. + *

+ * Spec: TS12j + */ public ResourceCount channels; + /** + * A {@link Stats.RequestCount} object containing a breakdown of API Requests. + *

+ * Spec: TS12k + */ public RequestCount apiRequests; + /** + * A {@link Stats.RequestCount} object containing a breakdown of Ably Token requests. + *

+ * Spec: TS12l + */ public RequestCount tokenRequests; + public ProcessedMessages processed; + + /** + * A {@link Stats.PushedMessages} object containing a breakdown of stats on push notifications. + *

+ * Spec: TS12m + */ + public PushedMessages push; } diff --git a/lib/src/main/java/io/ably/lib/types/Summary.java b/lib/src/main/java/io/ably/lib/types/Summary.java new file mode 100644 index 000000000..292fe67b6 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/Summary.java @@ -0,0 +1,142 @@ +package io.ably.lib.types; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import io.ably.lib.util.Log; +import io.ably.lib.util.Serialisation; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A summary of all the annotations that have been made to the message. Will always be + * populated for a message.summary, and may be populated for any other type (in + * particular a message retrieved from REST history will have its latest summary + * included). + * The keys of the map are the annotation types. The exact structure of the value of + * each key depends on the aggregation part of the annotation type, e.g. for a type of + * reaction:distinct.v1, the value will be a DistinctValues object. New aggregation + * methods might be added serverside, hence the 'unknown' part of the sum type. + */ +public class Summary { + + private static final String TAG = Summary.class.getName(); + + /** + * (TM2q1) The sdk MUST be able to cope with structures and aggregation types that have it does not yet know about + * or have explicit support for, hence the loose (JsonObject) type. + */ + private final Map typeToSummaryJson; + + public Summary(Map typeToSummaryJson) { + this.typeToSummaryJson = typeToSummaryJson; + } + + public static Map asSummaryDistinctV1(JsonObject jsonObject) { + Map summary = new HashMap<>(); + for (Map.Entry entry : jsonObject.entrySet()) { + String key = entry.getKey(); + summary.put(key, asSummaryFlagV1(entry.getValue().getAsJsonObject())); + } + return summary; + } + + public static Map asSummaryUniqueV1(JsonObject jsonObject) { + return asSummaryDistinctV1(jsonObject); + } + + public static Map asSummaryMultipleV1(JsonObject jsonObject) { + Map summary = new HashMap<>(); + for (Map.Entry entry : jsonObject.entrySet()) { + String key = entry.getKey(); + JsonObject value = entry.getValue().getAsJsonObject(); + int total = value.get("total").getAsInt(); + Map clientIds = new HashMap<>(); + for (Map.Entry clientEntry: value.get("clientIds").getAsJsonObject().entrySet()) { + clientIds.put(clientEntry.getKey(), clientEntry.getValue().getAsInt()); + } + summary.put(key, new SummaryClientIdCounts(total, clientIds)); + } + return summary; + } + + public static SummaryClientIdList asSummaryFlagV1(JsonObject jsonObject) { + int total = jsonObject.get("total").getAsInt(); + List clientIds = Serialisation.gson.fromJson(jsonObject.get("clientIds"), List.class); + return new SummaryClientIdList(total, clientIds); + } + + public static SummaryTotal asSummaryTotalV1(JsonObject jsonObject) { + int total = jsonObject.get("total").getAsInt(); + return new SummaryTotal(total); + } + + static Summary read(MessageUnpacker unpacker) { + try { + return read(Serialisation.msgpackToGson(unpacker.unpackValue())); + } catch (Exception e) { + Log.e(TAG, "Failed to read summary from MessagePack", e); + return null; + } + } + + static Summary read(JsonElement json) { + if (!json.isJsonObject()) { + throw new JsonParseException("Expected an object but got \"" + json.getClass() + "\"."); + } + Map typeToSummaryJson = new HashMap<>(); + for (Map.Entry entry : json.getAsJsonObject().entrySet()) { + if (!entry.getValue().isJsonObject()) { + throw new JsonParseException("Expected an object but got \"" + json.getClass() + "\"."); + } + typeToSummaryJson.put(entry.getKey(), entry.getValue().getAsJsonObject()); + } + return new Summary(typeToSummaryJson); + } + + /** + * Retrieves the JSON representation associated with a specified annotation type. + * + * @param annotationType the type of annotation to retrieve its JSON representation + * @return a JsonObject containing the JSON representation of the specified annotation type, + * or null if no representation exists for the given type + */ + public JsonObject get(String annotationType) { + return typeToSummaryJson.get(annotationType); + } + + void write(MessagePacker packer) { + Serialisation.gsonToMsgpack(toJsonTree(), packer); + } + + JsonElement toJsonTree() { + return Serialisation.gson.toJsonTree(this); + } + + public static class Serializer implements JsonSerializer

, JsonDeserializer { + + @Override + public JsonElement serialize(Summary summary, Type typeOfMessage, JsonSerializationContext ctx) { + JsonObject json = new JsonObject(); + for (Map.Entry entry : summary.typeToSummaryJson.entrySet()) { + json.add(entry.getKey(), entry.getValue()); + } + return json; + } + + @Override + public Summary deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return read(json); + } + + } +} diff --git a/lib/src/main/java/io/ably/lib/types/SummaryClientIdCounts.java b/lib/src/main/java/io/ably/lib/types/SummaryClientIdCounts.java new file mode 100644 index 000000000..d99996749 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/SummaryClientIdCounts.java @@ -0,0 +1,13 @@ +package io.ably.lib.types; + +import java.util.Map; + +public class SummaryClientIdCounts { + public final int total; // TM7d1a + public final Map clientIds; // TM7d1b + + public SummaryClientIdCounts(int total, Map clientIds) { + this.total = total; + this.clientIds = clientIds; + } +} diff --git a/lib/src/main/java/io/ably/lib/types/SummaryClientIdList.java b/lib/src/main/java/io/ably/lib/types/SummaryClientIdList.java new file mode 100644 index 000000000..2c9db8a08 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/SummaryClientIdList.java @@ -0,0 +1,13 @@ +package io.ably.lib.types; + +import java.util.List; + +public class SummaryClientIdList { + public final int total; // TM7c1a + public final List clientIds; // TM7c1b + + public SummaryClientIdList(int total, List clientIds) { + this.total = total; + this.clientIds = clientIds; + } +} diff --git a/lib/src/main/java/io/ably/lib/types/SummaryTotal.java b/lib/src/main/java/io/ably/lib/types/SummaryTotal.java new file mode 100644 index 000000000..f7d4b0724 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/types/SummaryTotal.java @@ -0,0 +1,9 @@ +package io.ably.lib.types; + +public class SummaryTotal { + public final int total; // TM7e1a + + SummaryTotal(int total) { + this.total = total; + } +} diff --git a/lib/src/main/java/io/ably/lib/util/AgentHeaderCreator.java b/lib/src/main/java/io/ably/lib/util/AgentHeaderCreator.java new file mode 100644 index 000000000..8999e94f5 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/AgentHeaderCreator.java @@ -0,0 +1,47 @@ +package io.ably.lib.util; + +import io.ably.lib.transport.Defaults; + +import java.util.Map; + +public class AgentHeaderCreator { + /** + * Separates agent entries from each other. + */ + private static final String AGENT_ENTRY_SEPARATOR = " "; + + /** + * Separates agent name from agent version. + */ + public static final String AGENT_DIVIDER = "/"; + + public static String create(Map additionalAgents, PlatformAgentProvider platformAgentProvider) { + StringBuilder agentStringBuilder = new StringBuilder(); + agentStringBuilder.append(Defaults.ABLY_AGENT_VERSION); + if (additionalAgents != null && !additionalAgents.isEmpty()) { + agentStringBuilder.append(AGENT_ENTRY_SEPARATOR); + agentStringBuilder.append(getAdditionalAgentEntries(additionalAgents)); + } + String platformAgent = platformAgentProvider.createPlatformAgent(); + if (platformAgent != null) { + agentStringBuilder.append(AGENT_ENTRY_SEPARATOR); + agentStringBuilder.append(platformAgent); + } + + return agentStringBuilder.toString(); + } + + private static String getAdditionalAgentEntries(Map additionalAgents) { + StringBuilder additionalAgentsBuilder = new StringBuilder(); + for (String additionalAgentName : additionalAgents.keySet()) { + String additionalAgentVersion = additionalAgents.get(additionalAgentName); + additionalAgentsBuilder.append(additionalAgentName); + if (additionalAgentVersion != null) { + additionalAgentsBuilder.append(AGENT_DIVIDER); + additionalAgentsBuilder.append(additionalAgentVersion); + } + additionalAgentsBuilder.append(AGENT_ENTRY_SEPARATOR); + } + return additionalAgentsBuilder.toString().trim(); + } +} diff --git a/lib/src/main/java/io/ably/lib/util/ClientOptionsUtils.java b/lib/src/main/java/io/ably/lib/util/ClientOptionsUtils.java new file mode 100644 index 000000000..1fd3d02ce --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/ClientOptionsUtils.java @@ -0,0 +1,37 @@ +package io.ably.lib.util; + +import io.ably.lib.network.ProxyAuthType; +import io.ably.lib.network.ProxyConfig; +import io.ably.lib.types.ClientOptions; + +import java.util.Arrays; + +public class ClientOptionsUtils { + + public static ProxyConfig convertToProxyConfig(ClientOptions clientOptions) { + if (clientOptions.proxy == null) return null; + + ProxyConfig.ProxyConfigBuilder builder = ProxyConfig.builder(); + + builder + .host(clientOptions.proxy.host) + .port(clientOptions.proxy.port) + .username(clientOptions.proxy.username) + .password(clientOptions.proxy.password); + + if (clientOptions.proxy.nonProxyHosts != null) { + builder.nonProxyHosts(Arrays.asList(clientOptions.proxy.nonProxyHosts)); + } + + switch (clientOptions.proxy.prefAuthType) { + case BASIC: + builder.authType(ProxyAuthType.BASIC); + break; + case DIGEST: + builder.authType(ProxyAuthType.DIGEST); + break; + } + + return builder.build(); + } +} diff --git a/lib/src/main/java/io/ably/lib/util/Crypto.java b/lib/src/main/java/io/ably/lib/util/Crypto.java index b20915b02..083de03ea 100644 --- a/lib/src/main/java/io/ably/lib/util/Crypto.java +++ b/lib/src/main/java/io/ably/lib/util/Crypto.java @@ -4,6 +4,8 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.ConcurrentModificationException; +import java.util.Locale; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; @@ -14,25 +16,11 @@ import javax.crypto.spec.SecretKeySpec; import io.ably.lib.types.AblyException; -import io.ably.lib.types.ChannelOptions; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Param; /** - * Utility classes and interfaces for message payload encryption. - * - * This class supports AES/CBC/PKCS5 with a default key length of 256 bits - * but supporting other key lengths. Other algorithms and chaining modes are - * not supported directly, but supportable by extending/implementing the base - * classes and interfaces here. - * - * Secure random data for creation of Initialisation Vectors (IVs) and keys - * is obtained from the default system SecureRandom. Future extensions of this - * class might make the SecureRandom pluggable or at least seedable with - * client-provided entropy. - * - * Each message payload is encrypted with an IV in CBC mode, and the IV is - * concatenated with the resulting raw ciphertext to construct the "ciphertext" - * data passed to the recipient. + * Contains the properties required to configure the encryption of {@link io.ably.lib.types.Message} payloads. */ public class Crypto { @@ -41,17 +29,20 @@ public class Crypto { public static final int DEFAULT_BLOCKLENGTH = 16; // bytes /** - * A class encapsulating the client-specifiable parameters for - * the cipher. - * - * algorithm is the name of the algorithm in the default system provider, - * or the lower-cased version of it; eg "aes" or "AES". - * - * Clients may instance a CipherParams directly and populate it, or may - * query the implementation to obtain a default system CipherParams. + * Sets the properties to configure encryption for a {@link io.ably.lib.rest.Channel} or {@link io.ably.lib.realtime.Channel} object. */ public static class CipherParams { + /** + * The algorithm to use for encryption. Only AES is supported and is the default value. + *

+ * Spec: TZ2a + */ private final String algorithm; + /** + * The length of the key in bits; for example 128 or 256. + *

+ * Spec: TZ2b + */ private final int keyLength; private final SecretKeySpec keySpec; private final IvParameterSpec ivSpec; @@ -59,7 +50,7 @@ public static class CipherParams { CipherParams(String algorithm, byte[] key, byte[] iv) throws NoSuchAlgorithmException { this.algorithm = (null == algorithm) ? DEFAULT_ALGORITHM : algorithm; keyLength = key.length * 8; - keySpec = new SecretKeySpec(key, this.algorithm.toUpperCase()); + keySpec = new SecretKeySpec(key, this.algorithm.toUpperCase(Locale.ROOT)); ivSpec = new IvParameterSpec(iv); } @@ -83,26 +74,19 @@ String getAlgorithm() { } /** - * Obtain a default CipherParams. This uses default algorithm, mode and - * padding and key length. A key and IV are generated using the default - * system SecureRandom; the key may be obtained from the returned CipherParams - * for out-of-band distribution to other clients. - * @return the CipherParams + *

+ * Spec: RSE1 + * @return A {@link CipherParams} object, using the default values for all fields. */ public static CipherParams getDefaultParams() { return getParams(DEFAULT_ALGORITHM, DEFAULT_KEYLENGTH); } /** - * Obtain a default CipherParams. This uses default algorithm, mode and - * padding and initialises a key based on the given key data. The cipher - * key length is derived from the length of the given key data. An IV is - * generated using the default system SecureRandom. - * - * Use this method of constructing CipherParams if initialising a Channel - * with a client-provided key, or to obtain a system-generated key of a - * non-default key length. - * @return the CipherParams + *

+ * Spec: RSE1 + * @param key client-provided key + * @return A {@link CipherParams} object, using the default values for any fields not supplied. */ public static CipherParams getDefaultParams(byte[] key) { try { @@ -111,25 +95,32 @@ public static CipherParams getDefaultParams(byte[] key) { } /** - * Package scoped method for unit testing purposes. + *

+ * Spec: RSE1 + * @param key client-provided key + * @param iv the buffer with the IV + * @return A {@link CipherParams} object, using the default values for any fields not supplied. */ static CipherParams getDefaultParams(byte[] key, byte[] iv) throws NoSuchAlgorithmException { return new CipherParams(DEFAULT_ALGORITHM, key, iv); } /** - * Obtain a default CipherParams using Base64-encoded key. Same as above, throws - * IllegalArgumentException if base64Key is invalid - * - * @param base64Key - * @return + *

+ * Spec: RSE1 + * @param base64Key Base64-encoded key + * @return A {@link CipherParams} object, using the default values for any fields not supplied. */ public static CipherParams getDefaultParams(String base64Key) { return getDefaultParams(Base64Coder.decode(base64Key)); } /** - * Package scoped method for unit testing purposes. + *

+ * Spec: RSE1 + * @param base64Key Base64-encoded key + * @param iv the buffer with the IV + * @return A {@link CipherParams} object, using the default values for any fields not supplied. */ static CipherParams getDefaultParams(String base64Key, byte[] iv) throws NoSuchAlgorithmException { return new CipherParams(null, Base64Coder.decode(base64Key), iv); @@ -138,7 +129,7 @@ static CipherParams getDefaultParams(String base64Key, byte[] iv) throws NoSuchA public static CipherParams getParams(String algorithm, int keyLength) { if(algorithm == null) algorithm = DEFAULT_ALGORITHM; try { - KeyGenerator keygen = KeyGenerator.getInstance(algorithm.toUpperCase()); + KeyGenerator keygen = KeyGenerator.getInstance(algorithm.toUpperCase(Locale.ROOT)); keygen.init(keyLength); byte[] key = keygen.generateKey().getEncoded(); return getParams(algorithm, key); @@ -156,108 +147,139 @@ public static CipherParams getParams(String algorithm, byte[] key, byte[] iv) th return new CipherParams(algorithm, key, iv); } + /** + * Generates a random key to be used in the encryption of the channel. + * If the language cryptographic randomness primitives are blocking or async, a callback is used. + * The callback returns a generated binary key. + *

+ * Spec: RSE2 + * @param keyLength The length of the key, in bits, to be generated. + * If not specified, this is equal to the default keyLength of the default algorithm: for AES this is 256 bits. + * @return The key as a binary, in a byte array. + */ public static byte[] generateRandomKey(int keyLength) { byte[] result = new byte[(keyLength + 7)/8]; secureRandom.nextBytes(result); return result; } + /** + * Generates a random key to be used in the encryption of the channel. + * If the language cryptographic randomness primitives are blocking or async, a callback is used. + * The callback returns a generated binary key. + *

+ * Spec: RSE2 + * @return The key as a binary, in a byte array. + */ public static byte[] generateRandomKey() { return generateRandomKey(DEFAULT_KEYLENGTH); } /** - * Interface for a ChannelCipher instance that may be associated with a Channel. - * + * Internal; a cipher used to encrypt plaintext to ciphertext, for a channel. */ - public interface ChannelCipher { + public interface EncryptingChannelCipher { + /** + * Enciphers plaintext. + * + * This method is not safe to be called from multiple threads at the same time, and it will throw a + * {@link ConcurrentModificationException} if that happens at runtime. + * + * @return ciphertext, being the result of encrypting plaintext. + * @throws ConcurrentModificationException If this method is called from more than one thread at a time. + */ byte[] encrypt(byte[] plaintext) throws AblyException; - byte[] decrypt(byte[] ciphertext) throws AblyException; + String getAlgorithm(); } /** - * Internal; get a ChannelCipher instance based on the given ChannelOptions - * @param opts - * @return - * @throws AblyException + * Internal; a cipher used to decrypt plaintext from ciphertext, for a channel. */ - public static ChannelCipher getCipher(final ChannelOptions opts) throws AblyException { - final Object opaqueCipherParams = opts.cipherParams; - final CipherParams cipherParams; - if(null == opaqueCipherParams) - cipherParams = Crypto.getDefaultParams(); - else if(opts.cipherParams instanceof CipherParams) - cipherParams = (CipherParams)opts.cipherParams; - else - throw AblyException.fromErrorInfo(new ErrorInfo("ChannelOptions not supported", 400, 40000)); + public interface DecryptingChannelCipher { + /** + * Deciphers ciphertext. + * + * This method is not safe to be called from multiple threads at the same time, and it will throw a + * {@link ConcurrentModificationException} if that happens at runtime. + * + * @return plaintext, being the result of decrypting ciphertext. + * @throws ConcurrentModificationException If this method is called from more than one thread at a time. + */ + byte[] decrypt(byte[] ciphertext) throws AblyException; + } + + /** + * Internal; get an encrypting cipher instance based on the given channel options. + */ + public static EncryptingChannelCipher createChannelEncipher(final CipherParams cipherParams) throws AblyException { + return new EncryptingCBCCipher(cipherParams); + } - return new CBCCipher(cipherParams); + /** + * Internal; get a decrypting cipher instance based on the given channel options. + */ + public static DecryptingChannelCipher createChannelDecipher(final CipherParams cipherParams) throws AblyException { + return new DecryptingCBCCipher(cipherParams); } /** - * Internal: a class that implements a CBC mode ChannelCipher. + * Internal; if `cipherParams` is null returns default params otherwise check if params valid and returns them + */ + public static CipherParams checkCipherParams(final Object cipherParams) throws AblyException { + if (null == cipherParams) { + return Crypto.getDefaultParams(); + } else if (cipherParams instanceof CipherParams) { + return (CipherParams) cipherParams; + } else { + throw AblyException.fromErrorInfo(new ErrorInfo("ChannelOptions not supported", 400, 40000)); + } + } + + /** + * Implements a CBC mode ChannelCipher. * A single block of secure random data is provided for an initial IV. * Consecutive messages are chained in a manner that allows each to be * emitted with an IV, allowing each to be deciphered independently, * whilst avoiding having to obtain further entropy for IVs, and reinit * the cipher, between successive messages. - * */ - private static class CBCCipher implements ChannelCipher { - private final SecretKeySpec keySpec; - private final Cipher encryptCipher; - private final Cipher decryptCipher; - private final String algorithm; - private final int blockLength; - private byte[] iv; - - private CBCCipher(CipherParams params) throws AblyException { + private static class CBCCipher { + protected final SecretKeySpec keySpec; + protected final IvParameterSpec ivSpec; + protected final Cipher cipher; + protected final int blockLength; + protected final String algorithm; + + protected CBCCipher(final CipherParams params) throws AblyException { final String cipherAlgorithm = params.getAlgorithm(); - String transformation = cipherAlgorithm.toUpperCase() + "/CBC/PKCS5Padding"; + String transformation = cipherAlgorithm.toUpperCase(Locale.ROOT) + "/CBC/PKCS5Padding"; try { algorithm = cipherAlgorithm + '-' + params.getKeyLength() + "-cbc"; keySpec = params.keySpec; - encryptCipher = Cipher.getInstance(transformation); - encryptCipher.init(Cipher.ENCRYPT_MODE, params.keySpec, params.ivSpec); - decryptCipher = Cipher.getInstance(transformation); - iv = params.ivSpec.getIV(); - blockLength = iv.length; + ivSpec = params.ivSpec; + blockLength = ivSpec.getIV().length; + cipher = Cipher.getInstance(transformation); } - catch (NoSuchAlgorithmException|NoSuchPaddingException|InvalidAlgorithmParameterException|InvalidKeyException e) { + catch (NoSuchAlgorithmException | NoSuchPaddingException e) { throw AblyException.fromThrowable(e); } } + } - @Override - public byte[] encrypt(byte[] plaintext) { - if(plaintext == null) return null; - int plaintextLength = plaintext.length; - int paddedLength = getPaddedLength(plaintextLength); - byte[] cipherIn = new byte[paddedLength]; - byte[] ciphertext = new byte[paddedLength + blockLength]; - int padding = paddedLength - plaintextLength; - System.arraycopy(plaintext, 0, cipherIn, 0, plaintextLength); - System.arraycopy(pkcs5Padding[padding], 0, cipherIn, plaintextLength, padding); - System.arraycopy(getIv(), 0, ciphertext, 0, blockLength); - byte[] cipherOut = encryptCipher.update(cipherIn); - System.arraycopy(cipherOut, 0, ciphertext, blockLength, paddedLength); - return ciphertext; - } + private static class EncryptingCBCCipher extends CBCCipher implements EncryptingChannelCipher { + private byte[] iv; + + EncryptingCBCCipher(final CipherParams params) throws AblyException { + super(params); - @Override - public byte[] decrypt(byte[] ciphertext) throws AblyException { - if(ciphertext == null) return null; - byte[] plaintext = null; try { - decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(ciphertext, 0, blockLength)); - plaintext = decryptCipher.doFinal(ciphertext, blockLength, ciphertext.length - blockLength); - } - catch (InvalidKeyException|InvalidAlgorithmParameterException|IllegalBlockSizeException|BadPaddingException e) { - Log.e(TAG, "decrypt()", e); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + } catch (InvalidAlgorithmParameterException | InvalidKeyException e) { throw AblyException.fromThrowable(e); } - return plaintext; + + iv = params.ivSpec.getIV(); } @Override @@ -266,36 +288,12 @@ public String getAlgorithm() { } /** - * Internal: get an IV for the next message. - * Returns either the IV that was used to initialise the ChannelCipher, - * or generates an IV based on the current cipher state. - */ - private byte[] getIv() { - if(iv == null) - return encryptCipher.update(emptyBlock); - - final byte[] result = iv; - iv = null; - return result; - } - - /** - * Internal: calculate the padded length of a given plaintext - * using PKCS5. - * @param plaintextLength - * @return - */ - private static int getPaddedLength(int plaintextLength) { - return (plaintextLength + DEFAULT_BLOCKLENGTH) & -DEFAULT_BLOCKLENGTH; - } - - /** - * Internal: a block containing zeros + * A block containing zeros. */ private static final byte[] emptyBlock = new byte[DEFAULT_BLOCKLENGTH]; /** - * Internal: obtain the pkcs5 padding string for a given padded length; + * The PKCS5 padding strings for given padded lengths. */ private static final byte[][] pkcs5Padding = new byte[][] { new byte[] {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16}, @@ -316,12 +314,78 @@ private static int getPaddedLength(int plaintextLength) { new byte[] {15,15,15,15,15,15,15,15,15,15,15,15,15,15,15}, new byte[] {16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16} }; + + /** + * Returns the padded length of a given plaintext, using PKCS5. + */ + private static int getPaddedLength(int plaintextLength) { + return (plaintextLength + DEFAULT_BLOCKLENGTH) & -DEFAULT_BLOCKLENGTH; + } + + /** + * Get an IV for the next message. + * Returns either the IV that was used to initialise the ChannelCipher, + * or generates an IV based on the current cipher state. + */ + private byte[] getNextIv() { + if (iv == null) + return cipher.update(emptyBlock); + + final byte[] result = iv; + iv = null; + return result; + } + + @Override + public byte[] encrypt(byte[] plaintext) { + if (plaintext == null) return null; + + final int plaintextLength = plaintext.length; + final int paddedLength = getPaddedLength(plaintextLength); + final byte[] cipherIn = new byte[paddedLength]; + final byte[] ciphertext = new byte[paddedLength + blockLength]; + final int padding = paddedLength - plaintextLength; + System.arraycopy(plaintext, 0, cipherIn, 0, plaintextLength); + System.arraycopy(pkcs5Padding[padding], 0, cipherIn, plaintextLength, padding); + System.arraycopy(getNextIv(), 0, ciphertext, 0, blockLength); + final byte[] cipherOut = cipher.update(cipherIn); + System.arraycopy(cipherOut, 0, ciphertext, blockLength, paddedLength); + return ciphertext; + } } - public static String getRandomMessageId() { + private static class DecryptingCBCCipher extends CBCCipher implements DecryptingChannelCipher { + DecryptingCBCCipher(final CipherParams params) throws AblyException { + super(params); + } + + @Override + public byte[] decrypt(byte[] ciphertext) throws AblyException { + if (ciphertext == null) return null; + + try { + cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(ciphertext, 0, blockLength)); + return cipher.doFinal(ciphertext, blockLength, ciphertext.length - blockLength); + } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) { + throw AblyException.fromThrowable(e); + } + } + } + + public static String getRandomId() { byte[] entropy = new byte[9]; secureRandom.nextBytes(entropy); - return Base64Coder.encode(entropy).toString(); + return Base64Coder.encodeToString(entropy); + } + + /** + * Returns a "request_id" query param, based on a sequence of 9 random bytes + * which have been base64 encoded. + * + * Spec: RSC7c + */ + public static Param generateRandomRequestId() { + return new Param("request_id", Crypto.getRandomId()); } /** diff --git a/lib/src/main/java/io/ably/lib/util/CurrentThreadExecutor.java b/lib/src/main/java/io/ably/lib/util/CurrentThreadExecutor.java index 26ecb2a31..8a5ba481d 100644 --- a/lib/src/main/java/io/ably/lib/util/CurrentThreadExecutor.java +++ b/lib/src/main/java/io/ably/lib/util/CurrentThreadExecutor.java @@ -1,12 +1,17 @@ package io.ably.lib.util; -import java.util.concurrent.Executor; +import io.ably.lib.http.CloseableExecutor; -public class CurrentThreadExecutor implements Executor { +public class CurrentThreadExecutor implements CloseableExecutor { public static CurrentThreadExecutor INSTANCE = new CurrentThreadExecutor(); @Override public void execute(Runnable runnable) { runnable.run(); } + + @Override + public void close() throws Exception { + // nothing to do + } } diff --git a/lib/src/main/java/io/ably/lib/util/EventEmitter.java b/lib/src/main/java/io/ably/lib/util/EventEmitter.java index 7e277e495..74d59b833 100644 --- a/lib/src/main/java/io/ably/lib/util/EventEmitter.java +++ b/lib/src/main/java/io/ably/lib/util/EventEmitter.java @@ -7,8 +7,8 @@ import java.util.Map; /** - * An interface exposing the ability to register listeners for a class of events - * @author paddy + * A generic interface for event registration and delivery used in a number of the types in the Realtime client library. + * For example, the {@link io.ably.lib.realtime.Connection} object emits events for connection state using the EventEmitter pattern. * * @param an Enum containing the event names that listeners may be registered for * @param the interface type of the listener @@ -16,7 +16,9 @@ public abstract class EventEmitter { /** - * Remove all registered listeners irrespective of type + * Deregisters all registrations, for all events and listeners. + *

+ * Spec: RTE5 */ public synchronized void off() { listeners.clear(); @@ -24,8 +26,18 @@ public synchronized void off() { } /** - * Register the given listener for all events - * @param listener + * Registers the provided listener all events. + * + * If on() is called more than once with the same listener, the listener + * is only added once. + * + * Note: This is in deviation from the spec (see below). + *

+ * Spec: RTE4 + * + * @param listener The event listener. + *

+ * This listener is invoked on a background thread. */ public synchronized void on(Listener listener) { if(!listeners.contains(listener)) @@ -33,16 +45,29 @@ public synchronized void on(Listener listener) { } /** - * Register the given listener for a single occurrence of any event - * @param listener + * Registers the provided listener for the first event that is emitted. + * If once() is called more than once with the same listener, + * the listener is added multiple times to its listener registry. + * Therefore, as an example, assuming the same listener is registered twice using once(), + * and an event is emitted once, the listener would be invoked twice. + * However, all subsequent events emitted would not invoke the listener as once() ensures that each registration is only invoked once. + *

+ * Spec: RTE4 + * + * @param listener The event listener. + *

+ * This listener is invoked on a background thread. */ public synchronized void once(Listener listener) { filters.put(listener, new Filter(null, listener, true)); } /** - * Remove a previously registered listener irrespective of type - * @param listener + * Deregisters the specified listener. + * Removes all registrations matching the given listener, regardless of whether they are associated with an event or not. + *

+ * Spec: RTE5 + * @param listener The event listener. */ public synchronized void off(Listener listener) { listeners.remove(listener); @@ -50,25 +75,48 @@ public synchronized void off(Listener listener) { } /** - * Register the given listener for a specific event - * @param listener + * Registers the provided listener for the specified event. + * + * If on() is called more than once with the same listener, even with + * a different event, the original listener is replaced. + * + * Note: This is in deviation from the spec (see below). + *

+ * Spec: RTE4 + * + * @param event The named event to listen for. + * @param listener The event listener. + *

+ * This listener is invoked on a background thread. */ public synchronized void on(Event event, Listener listener) { filters.put(listener, new Filter(event, listener, false)); } /** - * Register the given listener for a single occurrence of a specific event - * @param listener + * Registers the provided listener for the first occurrence of a single named event specified as the Event argument. + * If once() is called more than once with the same listener, the listener is added multiple times to its listener registry. + * Therefore, as an example, assuming the same listener is registered twice using once(), and an event is emitted once, + * the listener would be invoked twice. + * However, all subsequent events emitted would not invoke the listener as once() ensures that each registration is only invoked once. + *

+ * Spec: RTE4 + * + * @param listener The event listener. + * @param event The named event to listen for. + *

+ * This listener is invoked on a background thread. */ public synchronized void once(Event event, Listener listener) { filters.put(listener, new Filter(event, listener, true)); } /** - * Remove a previously registered event-specific listener - * @param listener - * @param event + * Removes all registrations that match both the specified listener and the specified event. + *

+ * Spec: RTE5 + * @param listener The event listener. + * @param event The named event. */ public synchronized void off(Event event, Listener listener) { Filter filter = filters.get(listener); @@ -77,8 +125,13 @@ public synchronized void off(Event event, Listener listener) { } /** - * Emit the given event (broadcasting to registered listeners) - * @param event the Event + * Emits an event, calling registered listeners with the given event name and any other given arguments. + * If an exception is raised in any of the listeners, + * the exception is caught by the EventEmitter and the exception is logged to the Ably logger. + *

+ * Spec: RTE5 + * + * @param event The named event. * @param args the arguments to pass to listeners */ public synchronized void emit(Event event, Object... args) { diff --git a/lib/src/main/java/io/ably/lib/util/InternalMap.java b/lib/src/main/java/io/ably/lib/util/InternalMap.java index 66d4c72c3..d732e9b40 100644 --- a/lib/src/main/java/io/ably/lib/util/InternalMap.java +++ b/lib/src/main/java/io/ably/lib/util/InternalMap.java @@ -1,16 +1,23 @@ package io.ably.lib.util; -import java.util.Map; import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import io.ably.lib.types.ReadOnlyMap; +/** + * A map implemented using a {@link ConcurrentHashMap}. This class is a base class for other classes + * which are designed to be internal to the library, specifically as regards access to the map + * field. + * + * This class exposes a {@link ReadOnlyMap} which is safe to be exposed in our public API. + * + * @param Key type. + * @param Value type. + */ public abstract class InternalMap implements ReadOnlyMap { - protected final Map map; - - public InternalMap(final Map map) { - this.map = map; - } + protected final ConcurrentMap map = new ConcurrentHashMap<>(); @Override public final boolean containsKey(final Object key) { diff --git a/lib/src/main/java/io/ably/lib/util/Multicaster.java b/lib/src/main/java/io/ably/lib/util/Multicaster.java index be9fb0464..9cd3f713f 100644 --- a/lib/src/main/java/io/ably/lib/util/Multicaster.java +++ b/lib/src/main/java/io/ably/lib/util/Multicaster.java @@ -1,19 +1,27 @@ package io.ably.lib.util; import java.util.ArrayList; -import java.util.Iterator; import java.util.List; +/** + * Collection of members who are listeners, with methods that are safe to be called from any thread. + * @param The type of elements being added to this multicaster - the listeners. + */ public abstract class Multicaster { - - protected final List members = new ArrayList(); + private final List members = new ArrayList<>(); public Multicaster(T... members) { for(T m : members) this.members.add(m); } - - public void add(T member) { members.add(member); } - public void remove(T member) { members.remove(member); } - public void clear() { members.clear(); } - public boolean isEmpty() { return members.isEmpty(); } - public int size() { return members.size(); } - public Iterator iterator() { return members.iterator(); } + + public synchronized void add(T member) { members.add(member); } + public synchronized void remove(T member) { members.remove(member); } + public synchronized void clear() { members.clear(); } + public synchronized boolean isEmpty() { return members.isEmpty(); } + public synchronized int size() { return members.size(); } + + /** + * Returns a snapshot of the members of this multicaster instance. + */ + protected synchronized List getMembers() { + return new ArrayList<>(members); + } } diff --git a/lib/src/main/java/io/ably/lib/util/ParamsUtils.java b/lib/src/main/java/io/ably/lib/util/ParamsUtils.java new file mode 100644 index 000000000..a89a74ecf --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/ParamsUtils.java @@ -0,0 +1,25 @@ +package io.ably.lib.util; + +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Param; + +public class ParamsUtils { + + /** + * Produce either new or extend provided array of parameters based on values in Client options + * + * @param params Array of already set parameters + * @param options Client options + * @return Array of parameters extended of parameters based on values in client options + */ + public static Param[] enrichParams(Param[] params, ClientOptions options) { + if (options.pushFullWait) { + params = Param.push(params, "fullWait", "true"); + } + if (options.addRequestIds) { // RSC7c + params = Param.set(params, Crypto.generateRandomRequestId()); + } + + return params; + } +} diff --git a/lib/src/main/java/io/ably/lib/util/PlatformAgentProvider.java b/lib/src/main/java/io/ably/lib/util/PlatformAgentProvider.java new file mode 100644 index 000000000..5a082040c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/PlatformAgentProvider.java @@ -0,0 +1,10 @@ +package io.ably.lib.util; + +public interface PlatformAgentProvider { + /** + * Creates the platform agent for agent header {@link AgentHeaderCreator}. + * + * @return Platform agent string or null if not available. + */ + String createPlatformAgent(); +} diff --git a/lib/src/main/java/io/ably/lib/util/ReconnectionStrategy.java b/lib/src/main/java/io/ably/lib/util/ReconnectionStrategy.java new file mode 100644 index 000000000..581b7c9e4 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/ReconnectionStrategy.java @@ -0,0 +1,38 @@ +package io.ably.lib.util; + +public class ReconnectionStrategy { + + /** + * Spec: RTB1a + * + * @param count The retry count + * @return The backoff coefficient + */ + private static float getBackoffCoefficient(int count) { + return Math.min((count + 2) / 3f, 2f); + } + + /** + * Spec: RTB1b + * + * @return The jitter coefficient + */ + private static double getJitterCoefficient() { + return 1 - Math.random() * 0.2; + } + + /** + * Spec: RTB1 + * + * @param initialTimeout The initial timeout value + * @param retryAttempt integer indicating retryAttempt + * @return RetryTimeout value for given timeout and retryAttempt. + * If x is the value returned then, + * Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout, + * Lower bound = 0.8 * Upper bound, + * Lower bound < x < Upper bound + */ + public static int getRetryTime(long initialTimeout, int retryAttempt) { + return Double.valueOf(initialTimeout * getJitterCoefficient() * getBackoffCoefficient(retryAttempt)).intValue(); + } +} diff --git a/lib/src/main/java/io/ably/lib/util/Serialisation.java b/lib/src/main/java/io/ably/lib/util/Serialisation.java index d2ae3baad..8397cd069 100644 --- a/lib/src/main/java/io/ably/lib/util/Serialisation.java +++ b/lib/src/main/java/io/ably/lib/util/Serialisation.java @@ -11,11 +11,14 @@ import io.ably.lib.http.HttpCore; import io.ably.lib.platform.Platform; import io.ably.lib.types.AblyException; +import io.ably.lib.types.Annotation; +import io.ably.lib.types.AnnotationAction; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.Message; import io.ably.lib.types.MessageExtras; import io.ably.lib.types.PresenceMessage; import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.types.Summary; import org.msgpack.core.MessagePack; import org.msgpack.core.MessagePack.PackerConfig; import org.msgpack.core.MessagePack.UnpackerConfig; @@ -48,6 +51,9 @@ public class Serialisation { gsonBuilder.registerTypeAdapter(PresenceMessage.class, new PresenceMessage.Serializer()); gsonBuilder.registerTypeAdapter(PresenceMessage.Action.class, new PresenceMessage.ActionSerializer()); gsonBuilder.registerTypeAdapter(ProtocolMessage.Action.class, new ProtocolMessage.ActionSerializer()); + gsonBuilder.registerTypeAdapter(Annotation.class, new Annotation.Serializer()); + gsonBuilder.registerTypeAdapter(AnnotationAction.class, new Annotation.ActionSerializer()); + gsonBuilder.registerTypeAdapter(Summary.class, new Summary.Serializer()); gson = gsonBuilder.create(); msgpackPackerConfig = Platform.name.equals("android") ? diff --git a/lib/src/main/java/io/ably/lib/util/StringUtils.java b/lib/src/main/java/io/ably/lib/util/StringUtils.java index 97f876b4a..d527fa105 100644 --- a/lib/src/main/java/io/ably/lib/util/StringUtils.java +++ b/lib/src/main/java/io/ably/lib/util/StringUtils.java @@ -4,6 +4,11 @@ import io.ably.lib.http.HttpCore; public class StringUtils { + + public static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } + public static Serialisation.FromJsonElement fromJsonElement = new Serialisation.FromJsonElement() { @Override public String fromJsonElement(JsonElement e) { diff --git a/lib/src/test/java/io/ably/lib/chat/ChatMessagesTest.java b/lib/src/test/java/io/ably/lib/chat/ChatMessagesTest.java new file mode 100644 index 000000000..940071a75 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/chat/ChatMessagesTest.java @@ -0,0 +1,523 @@ +package io.ably.lib.chat; + +import com.google.gson.JsonObject; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.test.common.Helpers; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Message; +import io.ably.lib.types.MessageAction; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ChatMessagesTest extends ParameterizedTest { + /** + * Test that a message sent via rest API is sent to a messages channel. + * It should be received by the client that is subscribed to the messages channel. + */ + @Test + public void test_room_message_is_published() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[7].keyStr); + opts.clientId = "sandbox-client"; + ably = new AblyRealtime(opts); + ChatRoom room = new ChatRoom(roomId, ably); + + /* create a channel and attach */ + final Channel channel = ably.channels.get(channelName); + channel.attach(); + (new Helpers.ChannelWaiter(channel)).waitFor(ChannelState.attached); + + /* subscribe to messages */ + List receivedMsg = new ArrayList<>(); + channel.subscribe(receivedMsg::add); + + // send message to room + ChatRoom.SendMessageParams params = new ChatRoom.SendMessageParams(); + params.text = "hello there"; + params.metadata = new JsonObject(); + JsonObject foo = new JsonObject(); + foo.addProperty("bar", 1); + params.metadata.add("foo", foo); + Map headers = new HashMap<>(); + headers.put("header1", "value1"); + headers.put("baz", "qux"); + params.headers = headers; + + JsonObject sendMessageResult = (JsonObject) room.sendMessage(params); + // check sendMessageResult has 2 fields and are not null + Assert.assertEquals(2, sendMessageResult.entrySet().size()); + String resultSerial = sendMessageResult.get("serial").getAsString(); + Assert.assertFalse(resultSerial.isEmpty()); + String resultCreatedAt = sendMessageResult.get("createdAt").getAsString(); + Assert.assertFalse(resultCreatedAt.isEmpty()); + + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + Assert.assertEquals(1, receivedMsg.size()); + Message message = receivedMsg.get(0); + + Assert.assertFalse("Message ID should not be empty", message.id.isEmpty()); + Assert.assertEquals("chat.message", message.name); + Assert.assertEquals("sandbox-client", message.clientId); + + JsonObject data = (JsonObject) message.data; + // has two fields "text" and "metadata" + Assert.assertEquals(2, data.entrySet().size()); + // Assert for received text + Assert.assertEquals("hello there", data.get("text").getAsString()); + // Assert on received metadata + JsonObject metadata = data.getAsJsonObject("metadata"); + Assert.assertTrue(metadata.has("foo")); + Assert.assertTrue(metadata.get("foo").isJsonObject()); + Assert.assertEquals(1, metadata.getAsJsonObject("foo").get("bar").getAsInt()); + + // Assert sent headers as a part of message.extras.headers + JsonObject extrasJson = message.extras.asJsonObject(); + Assert.assertTrue(extrasJson.has("headers")); + JsonObject headersJson = extrasJson.getAsJsonObject("headers"); + Assert.assertEquals(2, headersJson.entrySet().size()); + Assert.assertEquals("value1", headersJson.get("header1").getAsString()); + Assert.assertEquals("qux", headersJson.get("baz").getAsString()); + + Assert.assertEquals(resultCreatedAt, String.valueOf(message.timestamp)); + + Assert.assertEquals(resultCreatedAt, message.createdAt.toString()); + Assert.assertEquals(resultSerial, message.serial); + Assert.assertEquals(resultSerial, message.version); + + Assert.assertEquals(MessageAction.MESSAGE_CREATE, message.action); + Assert.assertEquals(resultCreatedAt, message.createdAt.toString()); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("init0: Unexpected exception instantiating library"); + } finally { + if(ably != null) + ably.close(); + } + } + + /** + * Test that a message updated via rest API is sent to a messages channel. + * It should be received by another client that is subscribed to the same messages channel. + * Make sure to use two clientIds: clientId1 and clientId2 + */ + @Test + public void test_room_message_is_updated() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams params = new ChatRoom.SendMessageParams(); + params.text = "hello there"; + JsonObject sendMessageResult = (JsonObject) room.sendMessage(params); + String originalSerial = sendMessageResult.get("serial").getAsString(); + String originalCreatedAt = sendMessageResult.get("createdAt").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Update the message + ChatRoom.UpdateMessageParams updateParams = new ChatRoom.UpdateMessageParams(); + // Update message context + updateParams.message = new ChatRoom.SendMessageParams(); + updateParams.message.text = "updated text"; + JsonObject metaData = new JsonObject(); + JsonObject foo = new JsonObject(); + foo.addProperty("bar", 1); + metaData.add("foo", foo); + updateParams.message.metadata = metaData; + // Update description + updateParams.description = "message updated by clientId1"; + + // Update metadata, add few random fields + Map operationMetadata = new HashMap<>(); + operationMetadata.put("foo", "bar"); + operationMetadata.put("naruto", "hero"); + updateParams.metadata = operationMetadata; + + JsonObject updateMessageResult = (JsonObject) room.updateMessage(originalSerial, updateParams); + String updateResultVersion = updateMessageResult.get("version").getAsString(); + String updateResultTimestamp = updateMessageResult.get("timestamp").getAsString(); + + // Wait for the updated message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Verify the updated message + Message updatedMessage = receivedMsg.get(1); + + Assert.assertEquals(MessageAction.MESSAGE_UPDATE, updatedMessage.action); + + Assert.assertFalse("Message ID should not be empty", updatedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", updatedMessage.name); + Assert.assertEquals("clientId1", updatedMessage.clientId); + + JsonObject data = (JsonObject) updatedMessage.data; + Assert.assertEquals(2, data.entrySet().size()); + Assert.assertEquals("updated text", data.get("text").getAsString()); + JsonObject metadata = data.getAsJsonObject("metadata"); + Assert.assertTrue(metadata.has("foo")); + Assert.assertTrue(metadata.get("foo").isJsonObject()); + Assert.assertEquals(1, metadata.getAsJsonObject("foo").get("bar").getAsInt()); + + Assert.assertEquals(originalSerial, updatedMessage.serial); + Assert.assertEquals(originalCreatedAt, updatedMessage.createdAt.toString()); + + Assert.assertEquals(updateResultVersion, updatedMessage.version); + Assert.assertEquals(updateResultTimestamp, String.valueOf(updatedMessage.timestamp)); + + // updatedMessage contains `operation` with fields as clientId, description, metadata, assert for these fields + Message.Operation operation = updatedMessage.operation; + Assert.assertEquals("clientId1", operation.clientId); + Assert.assertEquals("message updated by clientId1", operation.description); + Assert.assertEquals(2, operation.metadata.size()); + Assert.assertEquals("bar", operation.metadata.get("foo")); + Assert.assertEquals("hero", operation.metadata.get("naruto")); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } + + /** + * Test that a message deleted via rest API is sent to a messages channel. + * It should be received by another client that is subscribed to the same messages channel. + * Make sure to use two clientIds: clientId1 and clientId2 + */ + @Test + public void test_room_message_is_deleted() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams params = new ChatRoom.SendMessageParams(); + params.text = "hello there"; + JsonObject sendMessageResult = (JsonObject) room.sendMessage(params); + String originalSerial = sendMessageResult.get("serial").getAsString(); + String originalCreatedAt = sendMessageResult.get("createdAt").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Delete the message + ChatRoom.DeleteMessageParams deleteParams = new ChatRoom.DeleteMessageParams(); + deleteParams.description = "message deleted by clientId1"; + Map deleteMetadata = new HashMap<>(); + deleteMetadata.put("foo", "bar"); + deleteMetadata.put("naruto", "hero"); + deleteParams.metadata = deleteMetadata; + + JsonObject deleteMessageResult = (JsonObject) room.deleteMessage(originalSerial, deleteParams); + String deleteResultVersion = deleteMessageResult.get("version").getAsString(); + String deleteResultTimestamp = deleteMessageResult.get("timestamp").getAsString(); + + // Wait for the deleted message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Verify the deleted message + Message deletedMessage = receivedMsg.get(1); + + Assert.assertEquals(MessageAction.MESSAGE_DELETE, deletedMessage.action); + + Assert.assertFalse("Message ID should not be empty", deletedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", deletedMessage.name); + Assert.assertEquals("clientId1", deletedMessage.clientId); + + Assert.assertEquals(originalSerial, deletedMessage.serial); + Assert.assertEquals(originalCreatedAt, deletedMessage.createdAt.toString()); + + Assert.assertEquals(deleteResultVersion, deletedMessage.version); + Assert.assertEquals(deleteResultTimestamp, String.valueOf(deletedMessage.timestamp)); + + // deletedMessage contains `operation` with fields as clientId, reason + Message.Operation operation = deletedMessage.operation; + Assert.assertEquals("clientId1", operation.clientId); + Assert.assertEquals("message deleted by clientId1", operation.description); + // assert on metadata + Assert.assertEquals(2, operation.metadata.size()); + Assert.assertEquals("bar", operation.metadata.get("foo")); + Assert.assertEquals("hero", operation.metadata.get("naruto")); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } + + /** + * Test that message is created, updated and then deleted serially + */ + @Test + public void test_room_message_create_update_delete() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams sendParams = new ChatRoom.SendMessageParams(); + sendParams.text = "hello there"; + + JsonObject sendMessageResult = (JsonObject) room.sendMessage(sendParams); + String originalSerial = sendMessageResult.get("serial").getAsString(); + String originalCreatedAt = sendMessageResult.get("createdAt").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Update the message + ChatRoom.UpdateMessageParams updateParams = new ChatRoom.UpdateMessageParams(); + updateParams.message = new ChatRoom.SendMessageParams(); + updateParams.message.text = "updated text"; + + JsonObject updateMessageResult = (JsonObject) room.updateMessage(originalSerial, updateParams); + String updateResultVersion = updateMessageResult.get("version").getAsString(); + String updateResultTimestamp = updateMessageResult.get("timestamp").getAsString(); + + // Wait for the updated message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Delete the message + ChatRoom.DeleteMessageParams deleteParams = new ChatRoom.DeleteMessageParams(); + deleteParams.description = "message deleted by clientId1"; + + JsonObject deleteMessageResult = (JsonObject) room.deleteMessage(originalSerial, deleteParams); + String deleteResultVersion = deleteMessageResult.get("version").getAsString(); + String deleteResultTimestamp = deleteMessageResult.get("timestamp").getAsString(); + + // Wait for the deleted message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 3, 10_000); + Assert.assertNull(err); + + // Verify the created message + Message createdMessage = receivedMsg.get(0); + Assert.assertEquals(MessageAction.MESSAGE_CREATE, createdMessage.action); + Assert.assertFalse("Message ID should not be empty", createdMessage.id.isEmpty()); + Assert.assertEquals("chat.message", createdMessage.name); + Assert.assertEquals("clientId1", createdMessage.clientId); + JsonObject createdData = (JsonObject) createdMessage.data; + Assert.assertEquals("hello there", createdData.get("text").getAsString()); + + // Verify the updated message + Message updatedMessage = receivedMsg.get(1); + Assert.assertEquals(MessageAction.MESSAGE_UPDATE, updatedMessage.action); + Assert.assertFalse("Message ID should not be empty", updatedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", updatedMessage.name); + Assert.assertEquals("clientId1", updatedMessage.clientId); + JsonObject updatedData = (JsonObject) updatedMessage.data; + Assert.assertEquals("updated text", updatedData.get("text").getAsString()); + + Assert.assertEquals(updateResultVersion, updatedMessage.version); + Assert.assertEquals(updateResultTimestamp, String.valueOf(updatedMessage.timestamp)); + + // Verify the deleted message + Message deletedMessage = receivedMsg.get(2); + Assert.assertEquals(MessageAction.MESSAGE_DELETE, deletedMessage.action); + Assert.assertFalse("Message ID should not be empty", deletedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", deletedMessage.name); + Assert.assertEquals("clientId1", deletedMessage.clientId); + + Assert.assertEquals(deleteResultVersion, deletedMessage.version); + Assert.assertEquals(deleteResultTimestamp, String.valueOf(deletedMessage.timestamp)); + + // Check original serials + Assert.assertEquals(originalSerial, createdMessage.serial); + Assert.assertEquals(originalSerial, updatedMessage.serial); + Assert.assertEquals(originalSerial, deletedMessage.serial); + + // Check original message createdAt + Assert.assertEquals(originalCreatedAt, createdMessage.createdAt.toString()); + Assert.assertEquals(originalCreatedAt, updatedMessage.createdAt.toString()); + Assert.assertEquals(originalCreatedAt, deletedMessage.createdAt.toString()); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } + + /** + * Test that update/delete operations are allowed on a deleted message. + */ + @Test + public void test_operations_allowed_on_deleted_message() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams sendParams = new ChatRoom.SendMessageParams(); + sendParams.text = "hello there"; + + JsonObject sendMessageResult = (JsonObject) room.sendMessage(sendParams); + String originalSerial = sendMessageResult.get("serial").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Delete the message + ChatRoom.DeleteMessageParams deleteParams = new ChatRoom.DeleteMessageParams(); + deleteParams.description = "message deleted by clientId1"; + + room.deleteMessage(originalSerial, deleteParams); + + // Wait for the deleted message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Attempt to update the deleted message + ChatRoom.UpdateMessageParams updateParams = new ChatRoom.UpdateMessageParams(); + updateParams.message = new ChatRoom.SendMessageParams(); + updateParams.message.text = "updated text"; + room.updateMessage(originalSerial, updateParams); + + // wait for updated message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 3, 10_000); + Assert.assertNull(err); + + // Attempt to delete the already deleted message + room.deleteMessage(originalSerial, deleteParams); + // wait for delete message received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 4, 10_000); + Assert.assertNull(err); + + Assert.assertEquals(4, receivedMsg.size()); + for (Message msg : receivedMsg) { + Assert.assertEquals("Serial should match original serial", originalSerial, msg.serial); + } + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/chat/ChatRoom.java b/lib/src/test/java/io/ably/lib/chat/ChatRoom.java new file mode 100644 index 000000000..5c784a9c9 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/chat/ChatRoom.java @@ -0,0 +1,65 @@ +package io.ably.lib.chat; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpUtils; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.HttpPaginatedResponse; +import io.ably.lib.types.Param; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +public class ChatRoom { + private final AblyRest ablyRest; + private final String roomId; + private final Gson gson = new Gson(); + + protected ChatRoom(String roomId, AblyRest ablyRest) { + this.roomId = roomId; + this.ablyRest = ablyRest; + } + + public JsonElement sendMessage(SendMessageParams params) throws Exception { + return makeAuthorizedRequest("/chat/v2/rooms/" + roomId + "/messages", "POST", gson.toJsonTree(params)) + .orElseThrow(() -> AblyException.fromErrorInfo(new ErrorInfo("Failed to send message", 500))); + } + + public JsonElement updateMessage(String serial, UpdateMessageParams params) throws Exception { + return makeAuthorizedRequest("/chat/v2/rooms/" + roomId + "/messages/" + serial, "PUT", gson.toJsonTree(params)) + .orElseThrow(() -> AblyException.fromErrorInfo(new ErrorInfo("Failed to update message", 500))); + } + + public JsonElement deleteMessage(String serial, DeleteMessageParams params) throws Exception { + return makeAuthorizedRequest("/chat/v2/rooms/" + roomId + "/messages/" + serial + "/delete", "POST", gson.toJsonTree(params)) + .orElseThrow(() -> AblyException.fromErrorInfo(new ErrorInfo("Failed to delete message", 500))); + } + + public static class SendMessageParams { + public String text; + public JsonObject metadata; + public Map headers; + } + + public static class UpdateMessageParams { + public SendMessageParams message; + public String description; + public Map metadata; + } + + public static class DeleteMessageParams { + public String description; + public Map metadata; + } + + protected Optional makeAuthorizedRequest(String url, String method, JsonElement body) throws AblyException { + HttpCore.RequestBody httpRequestBody = HttpUtils.requestBodyFromGson(body, ablyRest.options.useBinaryProtocol); + HttpPaginatedResponse response = ablyRest.request(method, url, new Param[] { new Param("v", 3) }, httpRequestBody, null); + return Arrays.stream(response.items()).findFirst(); + } +} diff --git a/lib/src/test/java/io/ably/lib/http/HttpAuthTypeTest.java b/lib/src/test/java/io/ably/lib/http/HttpAuthTypeTest.java new file mode 100644 index 000000000..668406a7e --- /dev/null +++ b/lib/src/test/java/io/ably/lib/http/HttpAuthTypeTest.java @@ -0,0 +1,33 @@ +package io.ably.lib.http; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class HttpAuthTypeTest { + @Test + public void parseSuccess() { + // The expected form in `www-authenticate` HTTP header in server response. + // See: https://github.com/ably/ably-java/issues/711 + // + // The test for "basic" has been observed to fail under the following conditions: + // 1. Add `import java.util.Locale;` to this file. + // 2. Call `Locale.setDefault(new Locale("tr", "TR"));` as the first statement in this test method. + // 3. Use `toUpperCase()`, without `Locale.ROOT`, in the implementation of `HttpAuth.Type.parse(String)`. + // The observed failure is: + // java.lang.IllegalArgumentException: Failed to parse conformed form 'BASİC' of raw value 'basic'. + assertEquals(HttpAuth.Type.BASIC, HttpAuth.Type.parse("basic")); + assertEquals(HttpAuth.Type.DIGEST, HttpAuth.Type.parse("digest")); + assertEquals(HttpAuth.Type.X_ABLY_TOKEN, HttpAuth.Type.parse("x-ably-token")); + } + + @Test(expected = IllegalArgumentException.class) + public void parseFailure() { + HttpAuth.Type.parse("Früli"); + } + + @Test(expected = NullPointerException.class) + public void parseFailureNullValue() { + HttpAuth.Type.parse(null); + } +} diff --git a/lib/src/test/java/io/ably/lib/http/HttpHelpersTest.java b/lib/src/test/java/io/ably/lib/http/HttpHelpersTest.java new file mode 100644 index 000000000..039eb342a --- /dev/null +++ b/lib/src/test/java/io/ably/lib/http/HttpHelpersTest.java @@ -0,0 +1,61 @@ +package io.ably.lib.http; + +import io.ably.lib.types.AblyException; +import org.junit.Test; + +import java.net.URL; + +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.junit.Assert.assertEquals; + +public class HttpHelpersTest { + + @Test + public void getUrlString_validResponse_returnsString() throws Exception { + HttpCore mockHttpCore = mock(HttpCore.class); + HttpCore.Response mockResponse = new HttpCore.Response(); + mockResponse.body = "Test Response".getBytes(); + + when(mockHttpCore.httpExecuteWithRetry( + eq(new URL("http://example.com")), + eq("GET"), + eq(null), + eq(null), + any(HttpCore.ResponseHandler.class), + eq(false) + )).thenAnswer(invocation -> { + HttpCore.ResponseHandler responseHandler = invocation.getArgumentAt(4, HttpCore.ResponseHandler.class); + return responseHandler.handleResponse(mockResponse, null); + }); + + String result = HttpHelpers.getUrlString(mockHttpCore, "http://example.com"); + assertEquals("Test Response", result); + } + + @Test + public void getUrlString_emptyResponse_throwsAblyException() throws Exception { + HttpCore mockHttpCore = mock(HttpCore.class); + HttpCore.Response mockResponse = new HttpCore.Response(); + + when(mockHttpCore.httpExecuteWithRetry( + eq(new URL("http://example.com")), + eq("GET"), + eq(null), + eq(null), + any(HttpCore.ResponseHandler.class), + eq(false) + )).thenAnswer(invocation -> { + HttpCore.ResponseHandler responseHandler = invocation.getArgumentAt(4, HttpCore.ResponseHandler.class); + return responseHandler.handleResponse(mockResponse, null); + }); + + AblyException e = assertThrows(AblyException.class, () -> HttpHelpers.getUrlString(mockHttpCore, "http://example.com")); + assertEquals(500, e.errorInfo.statusCode); + assertEquals(50000, e.errorInfo.code); + assertEquals("Empty response body", e.errorInfo.message); + } +} diff --git a/lib/src/test/java/io/ably/lib/rest/DeviceDetailsTest.java b/lib/src/test/java/io/ably/lib/rest/DeviceDetailsTest.java new file mode 100644 index 000000000..da0f386b7 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/rest/DeviceDetailsTest.java @@ -0,0 +1,37 @@ +package io.ably.lib.rest; + +import io.ably.lib.util.JsonUtils; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class DeviceDetailsTest { + + @Test + public void shouldIgnoreUnrelatedRecipientFields() { + DeviceDetails details = DeviceDetails.fromJsonObject(JsonUtils.object() + .add("id", "testDeviceDetails") + .add("platform", "ios") + .add("formFactor", "phone") + .add("metadata", JsonUtils.object()) + .add("push", JsonUtils.object() + .add("recipient", JsonUtils.object() + .add("transportType", "apns") + .add("deviceToken", "foo") + .add("apnsDeviceTokens", JsonUtils.object().add("default", "foo")))) + .toJson()); + + DeviceDetails otherDetails = DeviceDetails.fromJsonObject(JsonUtils.object() + .add("id", "testDeviceDetails") + .add("platform", "ios") + .add("formFactor", "phone") + .add("metadata", JsonUtils.object()) + .add("push", JsonUtils.object() + .add("recipient", JsonUtils.object() + .add("transportType", "apns") + .add("deviceToken", "foo"))) + .toJson()); + + assertTrue("Should ignore `apnsDeviceTokens` field", details.equals(otherDetails)); + } +} diff --git a/lib/src/test/java/io/ably/lib/test/common/Helpers.java b/lib/src/test/java/io/ably/lib/test/common/Helpers.java index ff0bfba62..5b0f328c8 100644 --- a/lib/src/test/java/io/ably/lib/test/common/Helpers.java +++ b/lib/src/test/java/io/ably/lib/test/common/Helpers.java @@ -1,10 +1,29 @@ package io.ably.lib.test.common; -import java.net.HttpURLConnection; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.net.URL; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; +import java.util.stream.Collectors; import com.google.gson.Gson; import com.google.gson.JsonArray; @@ -15,8 +34,11 @@ import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; +import io.ably.lib.network.HttpRequest; +import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.Channel.MessageListener; +import io.ably.lib.realtime.ChannelEvent; import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.ChannelStateListener; import io.ably.lib.realtime.CompletionListener; @@ -37,11 +59,17 @@ import io.ably.lib.util.Base64Coder; import io.ably.lib.util.Log; import io.ably.lib.util.Serialisation; +import org.hamcrest.Matcher; import static junit.framework.Assert.assertTrue; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; public class Helpers { @@ -66,8 +94,8 @@ public static T expectedError(AblyFunction f, String expectedError, return result; } catch (AblyException e) { try { - assertNotNull(String.format("got error \"%s\", none expected", e.errorInfo.message), expectedError); - assertEquals(String.format("expected to match \"%s\", got \"%s\"", expectedError, e.errorInfo.message), true, Pattern.compile(expectedError).matcher(e.errorInfo.message).find()); + assertNotNull(String.format(Locale.ROOT, "got error \"%s\", none expected", e.errorInfo.message), expectedError); + assertEquals(String.format(Locale.ROOT, "expected to match \"%s\", got \"%s\"", expectedError, e.errorInfo.message), true, Pattern.compile(expectedError).matcher(e.errorInfo.message).find()); if (expectedCode > 0) { assertEquals(expectedCode, e.errorInfo.code); } @@ -83,17 +111,17 @@ public static T expectedError(AblyFunction f, String expectedError, } public static void assertInstanceOf(Class c, Object o) { - assertTrue(String.format("expected object of class %s to be instance of %s", o.getClass().getName(), c.getName()), c.isInstance(o)); + assertTrue(String.format(Locale.ROOT, "expected object of class %s to be instance of %s", o.getClass().getName(), c.getName()), c.isInstance(o)); } public static void assertSize(int expected, Collection c) { int size = c.size(); - assertEquals(String.format("expected collection to have size %d, got %d: %s", expected, size, c), expected, size); + assertEquals(String.format(Locale.ROOT, "expected collection to have size %d, got %d: %s", expected, size, c), expected, size); } public static void assertSize(int expected, T[] c) { int size = c.length; - assertEquals(String.format("expected array to have size %d, got %d: %s", expected, size, c), expected, size); + assertEquals(String.format(Locale.ROOT, "expected array to have size %d, got %d: %s", expected, size, c), expected, size); } public static HttpCore.Response httpResponseFromErrorInfo(final ErrorInfo errorInfo) { @@ -151,15 +179,38 @@ public void reset() { error = null; } - public synchronized ErrorInfo waitFor(int count) { + /** + * Wait for a specified amount of time, or until success occurs. + */ + public synchronized ErrorInfo waitFor(int count, long timeoutInMillis) { + long timeoutAt = System.currentTimeMillis() + timeoutInMillis; while(successCount timeoutAt) { + break; + } + + wait(); + } catch(InterruptedException ignored) {} success = successCount >= count; + if (error != null) { + assertNotNull(error.message); + } return error; } + /** + * Wait for a specified number of successes, with an arbitrarily long timeout. + */ + public synchronized ErrorInfo waitFor(int count) { + return waitFor(count, 600000); + } + + /** + * Wait for a single success with an arbitrarily long timeout. + */ public synchronized ErrorInfo waitFor() { - return waitFor(1); + return waitFor(1, 600000); } /** @@ -222,7 +273,7 @@ public MessageWaiter(Channel channel, String event) { */ public synchronized void waitFor(int count) { while(receivedMessages.size() < count) - try { wait(); } catch(InterruptedException e) {} + try { wait(); } catch(InterruptedException ignored) {} } /** @@ -233,7 +284,7 @@ public synchronized void waitFor(int count, long time) { long targetTime = System.currentTimeMillis() + time; long remaining = time; while(receivedMessages.size() < count && remaining > 0) { - try { wait(remaining); } catch(InterruptedException e) {} + try { wait(remaining); } catch(InterruptedException ignored) {} remaining = targetTime - System.currentTimeMillis(); } } @@ -360,6 +411,48 @@ public PresenceMessage contains(String clientId, String connectionId, PresenceMe } } + public static class MutableConnectionManager { + ConnectionManager connectionManager; + + public MutableConnectionManager(AblyRealtime ablyRealtime) { + this.connectionManager = ablyRealtime.connection.connectionManager; + } + + public void setField(String fieldName, long value) { + try { + Field connectionStateField = ConnectionManager.class.getDeclaredField(fieldName); + connectionStateField.setAccessible(true); + connectionStateField.setLong(connectionManager, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + fail("Failed updating " + fieldName + " with error " + e); + } + } + + public long getField(String fieldName) { + try { + Field connectionStateField = ConnectionManager.class.getDeclaredField(fieldName); + connectionStateField.setAccessible(true); + return connectionStateField.getLong(connectionManager); + } catch (NoSuchFieldException | IllegalAccessException e) { + fail("Failed accessing " + fieldName + " with error " + e); + } + return 0; + } + + /** + * Suppress automatic retries by the connection manager and disconnect + */ + public void disconnectAndSuppressRetries() { + try { + Method method = ConnectionManager.class.getDeclaredMethod("disconnectAndSuppressRetries"); + method.setAccessible(true); + method.invoke(connectionManager); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + fail("Unexpected exception in suppressing retries"); + } + } + } + /** * A class that listens for state change events on a connection. * @author paddy @@ -387,7 +480,7 @@ public synchronized ErrorInfo waitFor(ConnectionState state) { while (currentState() != state) { try { wait(); - } catch (InterruptedException e) { + } catch (InterruptedException ignored) { } } Log.d(TAG, "waitFor done: state=" + targetStateName + ")"); @@ -403,8 +496,8 @@ public synchronized void waitFor(ConnectionState state, int count) { Log.d(TAG, "waitFor(state=" + state.getConnectionEvent().name() + ", count=" + count + ")"); while(getStateCount(state) < count) - try { wait(); } catch(InterruptedException e) {} - Log.d(TAG, "waitFor done: state=" + latestChange.current.getConnectionEvent().name() + ", count=" + getStateCount(state) + ")"); + try { wait(); } catch(InterruptedException ignored) {} + Log.d(TAG, "waitFor done: state=" + lastStateChange().current.getConnectionEvent().name() + ", count=" + getStateCount(state) + ")"); } /** @@ -421,7 +514,7 @@ public synchronized boolean waitFor(ConnectionState state, int count, long time) long remaining = time; while(getStateCount(state) < count && remaining > 0) { Log.d(TAG, "waitFor(state=" + state.getConnectionEvent().name() + ", waiting for=" + remaining + ")"); - try { wait(remaining); } catch(InterruptedException e) {} + try { wait(remaining); } catch(InterruptedException ignored) {} remaining = targetTime - System.currentTimeMillis(); } int stateCount = getStateCount(state); @@ -462,7 +555,7 @@ public synchronized void reset() { @Override public void onConnectionStateChanged(ConnectionStateListener.ConnectionStateChange state) { synchronized(this) { - latestChange = state; + stateChanges.add(state); reason = state.reason; Counter counter = stateCounts.get(state.current); if(counter == null) stateCounts.put(state.current, (counter = new Counter())); counter.incr(); @@ -483,15 +576,23 @@ private synchronized int getStateCount(ConnectionState state) { } private synchronized ConnectionState currentState() { - return latestChange == null ? connection.state : latestChange.current; + ConnectionStateChange stateChange = lastStateChange(); + return stateChange == null ? connection.state : stateChange.current; + } + + public synchronized ConnectionStateChange lastStateChange() { + if (stateChanges.size() == 0) { + return null; + } + return stateChanges.get(stateChanges.size() -1); } /** * Internal */ - private Connection connection; + private final Connection connection; private ErrorInfo reason; - private ConnectionStateChange latestChange; + private final List stateChanges = new ArrayList<>(); private Map stateCounts; private static final String TAG = ConnectionWaiter.class.getName(); } @@ -516,14 +617,14 @@ public ConnectionManagerWaiter(ConnectionManager connectionManager) { */ public synchronized ErrorInfo waitFor(ConnectionState state) { while(connectionManager.getConnectionState().state != state) - try { wait(INTERVAL_POLLING); } catch(InterruptedException e) {} + try { wait(INTERVAL_POLLING); } catch(InterruptedException ignored) {} return connectionManager.getConnectionState().defaultErrorInfo; } /** * Internal */ - private ConnectionManager connectionManager; + private final ConnectionManager connectionManager; } /** @@ -536,7 +637,6 @@ public static class ChannelWaiter implements ChannelStateListener { /** * Public API - * @param channel */ public ChannelWaiter(Channel channel) { this.channel = channel; @@ -545,28 +645,80 @@ public ChannelWaiter(Channel channel) { /** * Wait for a given state to be reached. - * @param state */ - public synchronized ErrorInfo waitFor(ChannelState state) { - Log.d(TAG, "waitFor(" + state + ")"); - while(channel.state != state) - try { wait(); } catch(InterruptedException e) {} - Log.d(TAG, "waitFor done: " + channel.state + ", " + channel.reason + ")"); + public synchronized ErrorInfo waitFor(ChannelState ... states) { + for (ChannelState state : states) { + Log.d(TAG, "waitFor(" + state + ")"); + while(channel.state != state) + try { wait(); } catch(InterruptedException ignored) {} + Log.d(TAG, "waitFor done: " + channel.state + ", " + channel.reason + ")"); + } return channel.reason; } + /** + * Wait for a given ChannelEvent to be reached. + */ + public synchronized ChannelStateChange waitFor(ChannelEvent channelEvent) { + Log.d(TAG, "waitFor(" + channelEvent + ")"); + ChannelStateChange lastStateChange = getLastStateChange(); + while(lastStateChange.event != channelEvent) + try { wait(); } catch(InterruptedException ignored) {} + Log.d(TAG, "waitFor done: " + channel.state + ", " + channel.reason + ")"); + return lastStateChange; + } + /** * ChannelStateListener interface */ @Override - public void onChannelStateChanged(ChannelStateListener.ChannelStateChange stateChange) { - synchronized(this) { notify(); } + public void onChannelStateChanged(ChannelStateChange stateChange) { + synchronized(this) { + recordedStates.add(stateChange); + notify(); + } + } + + private final List recordedStates = Collections.synchronizedList(new ArrayList<>()); + + public List getRecordedStates() { + return recordedStates.stream().map(stateChange -> stateChange.current).collect(Collectors.toList()); + } + + public boolean hasFinalStates(ChannelState ... states) { + List rstates = getRecordedStates(); + List vettedList = rstates.subList(rstates.size() - states.length, rstates.size()); + return hasStates(vettedList, states); } + public boolean hasStates(ChannelState ... states) { + return hasStates(getRecordedStates(), states); + } + + private static boolean hasStates(List stateList, ChannelState ... states) { + boolean foundStates = false; + int statesCounter = 0; + for (ChannelState recordedState : stateList) { + if (states[statesCounter] != recordedState) { + statesCounter = 0; + } + if (states[statesCounter] == recordedState) { + statesCounter++; + } + if (statesCounter == states.length) { + foundStates = true; + } + } + return foundStates; + } + + public ChannelStateChange getLastStateChange() { + return recordedStates.get(recordedStates.size()-1); + } /** * Internal */ - private Channel channel; + private final Channel channel; } /** @@ -599,24 +751,56 @@ public static RawProtocolMonitor createMonitor(Action sendAction, Action recvAct * Wait for a given number of messages */ public void waitForRecv() { - waitForRecv(1); + waitForRecv(1, 6000000); } public void waitForSend() { - waitForSend(1); + waitForSend(1, 6000000); + } + public void waitForRecv(int count) { + waitForRecv(count, 6000000); + } + public void waitForSend(int count) { + waitForSend(count, 6000000); } /** * Wait for a given number of messages * @param count */ - public synchronized void waitForRecv(int count) { + public synchronized void waitForRecv(int count, long timeoutInMillis) { + long timeoutAt = System.currentTimeMillis() + timeoutInMillis; while(receivedMessages.size() < count) { - try { wait(); } catch(InterruptedException e) {} + synchronized (this) { + try { + if (System.currentTimeMillis() > timeoutAt || receivedMessages.size() >= count) { + break; + } + + wait(); + } catch(InterruptedException e) {} + } + } + + if (receivedMessages.size() < count) { + throw new AssertionError("Did not receive expected number of messages"); } } - public synchronized void waitForSend(int count) { + public synchronized void waitForSend(int count, long timeoutInMillis) { + long timeoutAt = System.currentTimeMillis() + timeoutInMillis; while(sentMessages.size() < count) { - try { wait(); } catch(InterruptedException e) {} + synchronized (this) { + try { + if (System.currentTimeMillis() > timeoutAt || sentMessages.size() >= count) { + break; + } + + wait(); + } catch(InterruptedException e) {} + } + } + + if (sentMessages.size() < count) { + throw new AssertionError("Did not send expected number of messages"); } } @@ -741,6 +925,14 @@ public static void assertMessagesEqual(BaseMessage expected, BaseMessage actual) } } + public static void assertTimeoutBetween(int timeout, Double min, Double max) { + assertThat(String.format("timeout %d should be between %f and %f", timeout, min, max ), (double) timeout, between(min, max)); + } + + public static Matcher between(Double min, Double max) { + return allOf(greaterThanOrEqualTo(min), lessThanOrEqualTo(max)); + } + public static class AsyncWaiter implements Callback { @Override public synchronized void onSuccess(T result) { @@ -780,7 +972,6 @@ public static boolean equalNullableStrings(String one, String two) { public static class RawHttpRequest { public String id; public URL url; - public HttpURLConnection conn; public String method; public String authHeader; public Map> requestHeaders; @@ -796,7 +987,7 @@ public static class RawHttpTracker extends LinkedHashMap private AsyncWaiter requestWaiter = null; @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, Map> requestHeaders, + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { /* duplicating if necessary, ensure lower-case versions of header names are present */ @@ -804,14 +995,13 @@ public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, Str if(requestHeaders != null) { normalisedHeaders.putAll(requestHeaders); for(String header : requestHeaders.keySet()) { - normalisedHeaders.put(header.toLowerCase(), requestHeaders.get(header)); + normalisedHeaders.put(header.toLowerCase(Locale.ROOT), requestHeaders.get(header)); } } RawHttpRequest req = new RawHttpRequest(); req.id = id; - req.url = conn.getURL(); - req.conn = conn; - req.method = method; + req.url = request.getUrl(); + req.method = request.getMethod(); req.authHeader = authHeader; req.requestHeaders = normalisedHeaders; req.requestBody = requestBody; @@ -851,7 +1041,7 @@ public void onRawHttpResponse(String id, String method, HttpCore.Response respon if(headers != null) { normalisedHeaders.putAll(headers); for(String header : headers.keySet()) { - normalisedHeaders.put(header.toLowerCase(), headers.get(header)); + normalisedHeaders.put(header.toLowerCase(Locale.ROOT), headers.get(header)); } response.headers = normalisedHeaders; } @@ -884,7 +1074,7 @@ public String getRequestParam(String id, String param) { String result = null; RawHttpRequest req = get(id); if(req != null) { - String query = req.conn.getURL().getQuery(); + String query = req.url.getQuery(); if(query != null && !query.isEmpty()) { result = HttpUtils.decodeParams(query).get(param).value; } @@ -896,7 +1086,7 @@ public List getRequestHeader(String id, String header) { List result = null; RawHttpRequest req = get(id); if(req != null) { - header = header.toLowerCase(); + header = header.toLowerCase(Locale.ROOT); if(header.equalsIgnoreCase("authorization")) { result = Collections.singletonList(req.authHeader); } else { @@ -910,7 +1100,7 @@ public List getResponseHeader(String id, String header) { List result = null; RawHttpRequest req = get(id); if(req != null) { - header = header.toLowerCase(); + header = header.toLowerCase(Locale.ROOT); Listheaders = req.response.headers.get(header); if(headers != null && headers.size() > 0) { result = headers; @@ -996,4 +1186,34 @@ public T apply(Arg arg) throws AblyException { public interface AblyFunction { Result apply(Arg arg) throws AblyException; } + + public interface ConditionFn { + O call(); + } + + public static class ConditionalWaiter { + public Exception wait(ConditionFn condition, int timeoutInMs) { + AtomicBoolean taskTimedOut = new AtomicBoolean(); + new Timer().schedule(new TimerTask() { + @Override + public void run() { + taskTimedOut.set(true); + } + }, timeoutInMs); + while (true) { + try { + Boolean result = condition.call(); + if (result) { + return null; + } + if (taskTimedOut.get()) { + throw new Exception("Timed out after " + timeoutInMs + "ms waiting for condition"); + } + Thread.sleep(200); + } catch (Exception e) { + return e; + } + } + } + } } diff --git a/lib/src/test/java/io/ably/lib/test/common/Setup.java b/lib/src/test/java/io/ably/lib/test/common/Setup.java index cc1d1ccf1..889aba74f 100644 --- a/lib/src/test/java/io/ably/lib/test/common/Setup.java +++ b/lib/src/test/java/io/ably/lib/test/common/Setup.java @@ -22,6 +22,28 @@ import io.ably.lib.debug.DebugOptions; public class Setup { + /** + * The `Setup` class can call `System.exit(int)`. + * The codes supplied to that method are defined by this enumeration. + */ + private enum TerminationReason { + UNABLE_TO_INSTANCE_REST(66601), + UNABLE_TO_READ_SPEC_FILE(66602), + UNABLE_TO_CREATE_TEST_APP(66603), + UNABLE_TO_DELETE_TEST_APP(66604); + + private final int code; + + TerminationReason(final int code) { + this.code = code; + } + + public void exit(final Throwable t) { + System.err.println(this + ": " + t); + t.printStackTrace(); + System.exit(code); + } + } public static Object loadJson(String resourceName, Class expectedType) throws IOException { try { @@ -46,6 +68,7 @@ public static class Namespace { public boolean persisted; public boolean pushEnabled; public int status; + public boolean mutableMessages; } public static class Connection { @@ -185,9 +208,7 @@ private static TestVars __getTestVars() { opts.tls = true; ably = new AblyRest(opts); } catch(AblyException e) { - System.err.println("Unable to instance AblyRest: " + e); - e.printStackTrace(); - System.exit(1); + TerminationReason.UNABLE_TO_INSTANCE_REST.exit(e); } } @@ -196,9 +217,7 @@ private static TestVars __getTestVars() { appSpec = (Setup.AppSpec)loadJson(specFile, Setup.AppSpec.class); appSpec.notes = "Test app; created by ably-java realtime tests; date = " + new Date().toString(); } catch(IOException ioe) { - System.err.println("Unable to read spec file: " + ioe); - ioe.printStackTrace(); - System.exit(1); + TerminationReason.UNABLE_TO_READ_SPEC_FILE.exit(ioe); } try { testVars = HttpHelpers.postSync(ably.http, "/apps", null, null, new HttpUtils.JsonRequestBody(appSpec), new HttpCore.ResponseHandler() { @@ -218,9 +237,7 @@ public TestVars handleResponse(HttpCore.Response response, ErrorInfo error) thro return result; }}, false); } catch (AblyException ae) { - System.err.println("Unable to create test app: " + ae); - ae.printStackTrace(); - System.exit(1); + TerminationReason.UNABLE_TO_CREATE_TEST_APP.exit(ae); } } return testVars; @@ -248,9 +265,7 @@ public void execute(HttpScheduler http, Callback callback) throws AblyExce } }).sync(); } catch (AblyException ae) { - System.err.println("Unable to delete test app: " + ae); - ae.printStackTrace(); - System.exit(1); + TerminationReason.UNABLE_TO_DELETE_TEST_APP.exit(ae); } testVars = null; } diff --git a/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java b/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java index fd4db4da2..eaaaead97 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java @@ -1,52 +1,55 @@ package io.ably.lib.test.realtime; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.InvocationTargetException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - import io.ably.lib.debug.DebugOptions; -import io.ably.lib.test.util.MockWebsocketFactory; -import io.ably.lib.transport.Hosts; -import io.ably.lib.util.Log; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; -import org.mockito.Mockito; - import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelEvent; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.realtime.ChannelStateListener; import io.ably.lib.realtime.Connection; import io.ably.lib.realtime.ConnectionEvent; import io.ably.lib.realtime.ConnectionState; import io.ably.lib.realtime.ConnectionStateListener; -import io.ably.lib.realtime.Channel; -import io.ably.lib.realtime.ChannelState; -import io.ably.lib.realtime.ChannelStateListener; -import io.ably.lib.realtime.ChannelEvent; import io.ably.lib.rest.Auth.AuthMethod; import io.ably.lib.test.common.Helpers; -import io.ably.lib.test.common.ParameterizedTest; -import io.ably.lib.test.common.Helpers.ConnectionWaiter; import io.ably.lib.test.common.Helpers.ChannelWaiter; +import io.ably.lib.test.common.Helpers.ConnectionWaiter; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.util.EmptyPlatformAgentProvider; +import io.ably.lib.test.util.MockWebsocketFactory; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.transport.Defaults; +import io.ably.lib.transport.Hosts; +import io.ably.lib.transport.ITransport; +import io.ably.lib.transport.WebSocketTransport; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.ProtocolMessage; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.mockito.Mockito; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * Created by gokhanbarisaker on 3/9/16. @@ -123,6 +126,7 @@ public void connectionmanager_fallback_none_customhost() throws AblyException { * * @throws AblyException */ + @Ignore("FIXME: fix exception") @Test public void connectionmanager_fallback_none_withoutconnection() throws AblyException { ClientOptions opts = createOptions(testVars.keys[0].keyStr); @@ -133,7 +137,7 @@ public void connectionmanager_fallback_none_withoutconnection() throws AblyExcep Connection connection = Mockito.mock(Connection.class); final ConnectionManager.Channels channels = Mockito.mock(ConnectionManager.Channels.class); - ConnectionManager connectionManager = new ConnectionManager(ably, connection, channels) { + ConnectionManager connectionManager = new ConnectionManager(ably, connection, channels, new EmptyPlatformAgentProvider(), null) { @Override protected boolean checkConnectivity() { return false; @@ -198,7 +202,7 @@ public boolean matches(String hostname) { } }); - try (final AblyRealtime ably = new AblyRealtime(opts)) { + try (AblyRealtime ably = new AblyRealtime(opts)) { ConnectionManager connectionManager = ably.connection.connectionManager; new Helpers.ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); @@ -246,7 +250,7 @@ public boolean matches(String hostname) { } }); - try (final AblyRealtime ably = new AblyRealtime(opts)) { + try (AblyRealtime ably = new AblyRealtime(opts)) { ConnectionManager connectionManager = ably.connection.connectionManager; System.out.println("waiting for disconnected"); @@ -298,7 +302,7 @@ public boolean matches(String hostname) { } }); - try (final AblyRealtime ably = new AblyRealtime(opts)) { + try (AblyRealtime ably = new AblyRealtime(opts)) { ConnectionManager connectionManager = ably.connection.connectionManager; System.out.println("waiting for connected"); @@ -318,6 +322,7 @@ public boolean matches(String hostname) { * Test that default fallback happens with a non-default host if * fallbackHostsUseDefault is set. */ + @Ignore("FIXME: fix exception") @Test public void connectionmanager_reconnect_default_fallback() throws AblyException { DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); @@ -347,7 +352,7 @@ public boolean matches(String hostname) { } }); - try (final AblyRealtime ably = new AblyRealtime(opts)) { + try (AblyRealtime ably = new AblyRealtime(opts)) { ConnectionManager connectionManager = ably.connection.connectionManager; System.out.println("waiting for connected"); @@ -384,13 +389,32 @@ public void onConnectionStateChanged(ConnectionStateChange state) { /* wait for cm thread to exit */ try { Thread.sleep(2000L); - } catch(InterruptedException e) {} + } catch(InterruptedException ignored) {} assertEquals("Verify closed state is reached", ConnectionState.closed, ably.connection.state); Thread.State cmThreadState = threadContainer[0].getState(); assertEquals("Verify cm thread has exited", cmThreadState, Thread.State.TERMINATED); } + /** + * (RTN12f) Close while in connecting state + */ + @Test + public void connectionmanager_close_while_connecting() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + final AblyRealtime ably = new AblyRealtime(opts); + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + ConnectionManager connectionManager = ably.connection.connectionManager; + ably.close(); + + connectionWaiter.waitFor(ConnectionState.closed); + assertEquals("Previous state was closing", ConnectionState.closing, connectionWaiter.lastStateChange().previous); + assertEquals(1 , connectionWaiter.getCount(ConnectionState.connecting)); + assertEquals(0 , connectionWaiter.getCount(ConnectionState.connected)); + assertEquals("Verify closed state is reached", ConnectionState.closed, ably.connection.state); + assertThat("fallback hasn't been invoked", connectionManager.getHost(), is(equalTo(opts.environment + "-realtime.ably.io"))); + } + /** * Connect, and then perform a close(); * verify that the closed state is reached, and immediately @@ -458,7 +482,7 @@ public void run() { connectionWaiter.waitFor(ConnectionState.connected); assertEquals("Verify connected state is reached", ConnectionState.connected, ably.connection.state); - assertTrue("Not expecting token auth", ably.auth.getAuthMethod() == AuthMethod.basic); + assertSame("Not expecting token auth", ably.auth.getAuthMethod(), AuthMethod.basic); ably.close(); connectionWaiter.waitFor(ConnectionState.closed); @@ -467,7 +491,7 @@ public void run() { /* wait for cm thread to exit */ try { Thread.sleep(2000L); - } catch(InterruptedException e) {} + } catch(InterruptedException ignored) {} Thread.State cmThreadState = threadContainer[0].getState(); assertEquals("Verify cm thread has exited", cmThreadState, Thread.State.TERMINATED); @@ -506,7 +530,7 @@ public void run() { /* wait for cm thread to exit */ try { Thread.sleep(2000L); - } catch(InterruptedException e) {} + } catch(InterruptedException ignored) {} Thread.State cmThreadState = threadContainer[0].getState(); assertEquals("Verify cm thread has exited", cmThreadState, Thread.State.TERMINATED); @@ -523,29 +547,46 @@ public void run() { @Test public void connection_details_has_ttl() throws AblyException { ClientOptions opts = createOptions(testVars.keys[0].keyStr); - try (final AblyRealtime ably = new AblyRealtime(opts)) { - final boolean[] callbackWasRun = new boolean[1]; - ably.connection.on(ConnectionEvent.connected, new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - synchronized(callbackWasRun) { - callbackWasRun[0] = true; - try { - Field field = ably.connection.connectionManager.getClass().getDeclaredField("connectionStateTtl"); - field.setAccessible(true); - assertEquals("Verify connectionStateTtl has the default value", field.get(ably.connection.connectionManager), 120000L); - } catch (NoSuchFieldException|IllegalAccessException e) { - fail("Unexpected exception in checking connectionStateTtl"); - } - callbackWasRun.notify(); - } - } - }); + opts.autoConnect = false; + try (AblyRealtime ably = new AblyRealtime(opts)) { + Helpers.MutableConnectionManager connectionManager = new Helpers.MutableConnectionManager(ably); - synchronized (callbackWasRun) { - try { callbackWasRun.wait(); } catch(InterruptedException ie) {} - assertTrue("Connected callback was not run", callbackWasRun[0]); - } + // connStateTtl set to default value + long connStateTtl = connectionManager.getField("connectionStateTtl"); + assertEquals(Defaults.connectionStateTtl, connStateTtl); + + connectionManager.setField("connectionStateTtl", 8000L); + long oldConnStateTtl = connectionManager.getField("connectionStateTtl"); + assertEquals(8000L, oldConnStateTtl); + + ably.connect(); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + long newConnStateTtl = connectionManager.getField("connectionStateTtl"); + // connStateTtl set by server to 120s + assertEquals(120000L, newConnStateTtl); + } + } + + /** + * RTN23 + */ + @Test + public void connection_is_closed_after_max_idle_interval() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.realtimeRequestTimeout = 2000; + try(AblyRealtime ably = new AblyRealtime(opts)) { + + // The original max idle interval we receive from the server is 15s. + // We should wait for this, plus a tiny bit extra (as we set the new idle interval to be very low + // after connecting) to make sure that the connection is disconnected + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + connectionWaiter.waitFor(ConnectionState.connected); + + // When we connect, we set the max idle interval to be very small + Helpers.MutableConnectionManager connectionManager = new Helpers.MutableConnectionManager(ably); + connectionManager.setField("maxIdleInterval", 500L); + + assertTrue(connectionWaiter.waitFor(ConnectionState.disconnected, 1, 25000)); } } @@ -557,47 +598,26 @@ public void onConnectionStateChanged(ConnectionStateChange state) { public void connection_has_new_id_when_reconnecting_after_statettl_plus_idleinterval_has_passed() throws AblyException { ClientOptions opts = createOptions(testVars.keys[0].keyStr); opts.realtimeRequestTimeout = 2000L; - try(final AblyRealtime ably = new AblyRealtime(opts)) { - final long newTtl = 1000L; - final long newIdleInterval = 1000L; + try(AblyRealtime ably = new AblyRealtime(opts)) { /* We want this greater than newTtl + newIdleInterval */ final long waitInDisconnectedState = 3000L; - ably.connection.on(ConnectionEvent.connected, new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - try { - Field connectionStateField = ably.connection.connectionManager.getClass().getDeclaredField("connectionStateTtl"); - connectionStateField.setAccessible(true); - connectionStateField.setLong(ably.connection.connectionManager, newTtl); - Field maxIdleField = ably.connection.connectionManager.getClass().getDeclaredField("maxIdleInterval"); - maxIdleField.setAccessible(true); - maxIdleField.setLong(ably.connection.connectionManager, newIdleInterval); - } catch (NoSuchFieldException | IllegalAccessException e) { - fail("Unexpected exception in checking connectionStateTtl"); - } - } - }); - ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); connectionWaiter.waitFor(ConnectionState.connected); final String firstConnectionId = ably.connection.id; - /* suppress automatic retries by the connection manager and disconnect */ - try { - Method method = ably.connection.connectionManager.getClass().getDeclaredMethod("disconnectAndSuppressRetries"); - method.setAccessible(true); - method.invoke(ably.connection.connectionManager); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - fail("Unexpected exception in suppressing retries"); - } + Helpers.MutableConnectionManager connectionManager = new Helpers.MutableConnectionManager(ably); + connectionManager.setField("connectionStateTtl", 1000L); + connectionManager.setField("maxIdleInterval", 1000L); + + connectionManager.disconnectAndSuppressRetries(); connectionWaiter.waitFor(ConnectionState.disconnected); assertEquals("Disconnected state was not reached", ConnectionState.disconnected, ably.connection.state); /* Wait for the connection to go stale, then reconnect */ try { Thread.sleep(waitInDisconnectedState); - } catch (InterruptedException e) { + } catch (InterruptedException ignored) { } ably.connection.connect(); connectionWaiter.waitFor(ConnectionState.connected); @@ -616,7 +636,7 @@ public void onConnectionStateChanged(ConnectionStateChange state) { @Test public void connection_has_same_id_when_reconnecting_before_statettl_plus_idleinterval_has_passed() throws AblyException { ClientOptions opts = createOptions(testVars.keys[0].keyStr); - try(final AblyRealtime ably = new AblyRealtime(opts)) { + try(AblyRealtime ably = new AblyRealtime(opts)) { ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); connectionWaiter.waitFor(ConnectionState.connected); String firstConnectionId = ably.connection.id; @@ -639,73 +659,43 @@ public void connection_has_same_id_when_reconnecting_before_statettl_plus_idlein @Test public void channels_are_reattached_after_reconnecting_when_statettl_plus_idleinterval_has_passed() throws AblyException { ClientOptions opts = createOptions(testVars.keys[0].keyStr); - try(final AblyRealtime ably = new AblyRealtime(opts)) { - final long newTtl = 1000L; - final long newIdleInterval = 1000L; + try(AblyRealtime ably = new AblyRealtime(opts)) { /* We want this greater than newTtl + newIdleInterval */ final long waitInDisconnectedState = 3000L; - final List attachedChannelHistory = new ArrayList(); - final List expectedAttachedChannelHistory = Arrays.asList("attaching", "attached", "attaching", "attached"); - final List suspendedChannelHistory = new ArrayList(); - final List expectedSuspendedChannelHistory = Arrays.asList("attaching", "attached"); - ably.connection.on(ConnectionEvent.connected, new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - try { - Field connectionStateField = ably.connection.connectionManager.getClass().getDeclaredField("connectionStateTtl"); - connectionStateField.setAccessible(true); - connectionStateField.setLong(ably.connection.connectionManager, newTtl); - Field maxIdleField = ably.connection.connectionManager.getClass().getDeclaredField("maxIdleInterval"); - maxIdleField.setAccessible(true); - maxIdleField.setLong(ably.connection.connectionManager, newIdleInterval); - } catch (NoSuchFieldException | IllegalAccessException e) { - fail("Unexpected exception in checking connectionStateTtl"); - } - } - }); + final ChannelState[] expectedAttachedChannelHistory = new ChannelState[]{ + ChannelState.attaching, ChannelState.attached, ChannelState.attaching, ChannelState.attached}; - ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + final ChannelState[] expectedSuspendedChannelHistory = new ChannelState[]{ + ChannelState.attaching, ChannelState.attached}; + + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); connectionWaiter.waitFor(ConnectionState.connected); final String firstConnectionId = ably.connection.id; + Helpers.MutableConnectionManager connectionManager = new Helpers.MutableConnectionManager(ably); + connectionManager.setField("connectionStateTtl", 1000L); + connectionManager.setField("maxIdleInterval", 1000L); + /* Prepare channels */ final Channel attachedChannel = ably.channels.get("test-reattach-after-ttl" + testParams.name); ChannelWaiter attachedChannelWaiter = new Helpers.ChannelWaiter(attachedChannel); - attachedChannel.on(new ChannelStateListener() { - @Override - public void onChannelStateChanged(ChannelStateChange stateChange) { - attachedChannelHistory.add(stateChange.current.name()); - } - }); + final Channel suspendedChannel = ably.channels.get("test-reattach-suspended-after-ttl" + testParams.name); suspendedChannel.state = ChannelState.suspended; ChannelWaiter suspendedChannelWaiter = new Helpers.ChannelWaiter(suspendedChannel); - suspendedChannel.on(new ChannelStateListener() { - @Override - public void onChannelStateChanged(ChannelStateChange stateChange) { - suspendedChannelHistory.add(stateChange.current.name()); - } - }); /* attach first channel and wait for it to be attached */ attachedChannel.attach(); attachedChannelWaiter.waitFor(ChannelState.attached); - /* suppress automatic retries by the connection manager and disconnect */ - try { - Method method = ably.connection.connectionManager.getClass().getDeclaredMethod("disconnectAndSuppressRetries"); - method.setAccessible(true); - method.invoke(ably.connection.connectionManager); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - fail("Unexpected exception in suppressing retries"); - } + connectionManager.disconnectAndSuppressRetries(); connectionWaiter.waitFor(ConnectionState.disconnected); assertEquals("Disconnected state was not reached", ConnectionState.disconnected, ably.connection.state); /* Wait for the connection to go stale, then reconnect */ try { Thread.sleep(waitInDisconnectedState); - } catch (InterruptedException e) { + } catch (InterruptedException ignored) { } ably.connection.connect(); connectionWaiter.waitFor(ConnectionState.connected); @@ -719,15 +709,166 @@ public void onChannelStateChanged(ChannelStateChange stateChange) { attachedChannel.once(ChannelEvent.attached, new ChannelStateListener() { @Override public void onChannelStateChanged(ChannelStateChange stateChange) { - assertEquals("Resumed is true and should be false", stateChange.resumed, false); + assertFalse("Resumed is true and should be false", stateChange.resumed); } }); /* Wait for both channels to reattach and verify state histories match the expected ones */ attachedChannelWaiter.waitFor(ChannelState.attached); suspendedChannelWaiter.waitFor(ChannelState.attached); - assertEquals("Attached channel histories do not match", attachedChannelHistory, expectedAttachedChannelHistory); - assertEquals("Suspended channel histories do not match", suspendedChannelHistory, expectedSuspendedChannelHistory); + assertTrue("Attached channel histories do not match", + attachedChannelWaiter.hasFinalStates(expectedAttachedChannelHistory)); + + assertTrue("Suspended channel histories do not match", + suspendedChannelWaiter.hasFinalStates(expectedSuspendedChannelHistory)); + } + } + + /** + *

+ * Verifies that the {@code ConnectionManager} enters the disconnected state and sets the suspend timer + * upon unavailable transport. + *

+ *

+ * Spec: RTN15g + *

+ */ + @Test + public void connection_manager_enters_disconnected_state_on_transport_failure() throws AblyException, NoSuchFieldException, IllegalAccessException, InterruptedException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + try(AblyRealtime ably = new AblyRealtime(opts)) { + ConnectionManager connectionManager = ably.connection.connectionManager; + connectionManager.connect(); + + new Helpers.ConnectionManagerWaiter(ably.connection.connectionManager).waitFor(ConnectionState.connected); + + // Here, we "fake" being online for 2 minutes - the suspendTime is set by onConnected and the default is 2 minutes + Field suspendTimeField = connectionManager.getClass().getDeclaredField("suspendTime"); + suspendTimeField.setAccessible(true); + suspendTimeField.set(connectionManager, System.currentTimeMillis() - 10); + + // We also have to grab the "real" transport to pass the superseded test + Field transportField = connectionManager.getClass().getDeclaredField("transport"); + transportField.setAccessible(true); + + connectionManager.onTransportUnavailable((ITransport) transportField.get(connectionManager), new ErrorInfo()); + new Helpers.ConnectionManagerWaiter(connectionManager).waitFor(ConnectionState.disconnected); + + assertTrue((long) suspendTimeField.get(connectionManager) >= System.currentTimeMillis()); + + connectionManager.close(); } } + + /** + *

+ * Verifies that the {@code ConnectionManager} enters the suspended state if the transport is unavailable and the + * timer has been exceeded. + *

+ *

+ * Spec: RTN15g, RTN14d + *

+ */ + @Test + public void connection_manager_enters_suspended_state_on_transport_failure_after_already_being_disconnected_for_2_minutes() throws AblyException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + try(AblyRealtime ably = new AblyRealtime(opts)) { + ConnectionManager connectionManager = ably.connection.connectionManager; + connectionManager.connect(); + new Helpers.ConnectionManagerWaiter(ably.connection.connectionManager).waitFor(ConnectionState.connected); + + // Here, we "fake" being disconnected beyond the suspend timer + Class connectionManagerClass = Class.forName("io.ably.lib.transport.ConnectionManager"); + Class disconnectedState = Class.forName("io.ably.lib.transport.ConnectionManager$Disconnected"); + Constructor disconnectedStateCtor = disconnectedState.getDeclaredConstructor(connectionManagerClass); + disconnectedStateCtor.setAccessible(true); + Field connectionStateField = connectionManager.getClass().getDeclaredField("currentState"); + connectionStateField.setAccessible(true); + connectionStateField.set(connectionManager, disconnectedStateCtor.newInstance(connectionManager)); + + Field suspendTimeField = connectionManager.getClass().getDeclaredField("suspendTime"); + suspendTimeField.setAccessible(true); + suspendTimeField.set(connectionManager, System.currentTimeMillis() - 5000); + + // We also have to grab the "real" transport to pass the superseded test + Field transportField = connectionManager.getClass().getDeclaredField("transport"); + transportField.setAccessible(true); + + connectionManager.onTransportUnavailable((ITransport) transportField.get(connectionManager), new ErrorInfo()); + + new Helpers.ConnectionManagerWaiter(connectionManager).waitFor(ConnectionState.suspended); + + connectionManager.close(); + } + } + + /** + *

+ * Verifies that the {@code ConnectionManager} sends a close protocol message when closed. + *

+ *

+ * Spec: RTN12 + *

+ */ + @Test + public void connection_manager_sends_close_message_on_closed() throws AblyException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, InterruptedException { + DebugOptions opts = createOptions(testVars.keys[0].keyStr); + opts.transportFactory = new ObservedWebsocketTransport.Factory(); + + // Connect + try(AblyRealtime ably = new AblyRealtime(opts)) { + ConnectionManager connectionManager = ably.connection.connectionManager; + connectionManager.connect(); + // Wait for connected status + while (connectionManager.getConnectionState().state != ConnectionState.connected) { + Thread.sleep(100); + } + + connectionManager.close(); + + long checkStartTime = System.currentTimeMillis(); + while (true) { + if (System.currentTimeMillis() > checkStartTime + 5000) { + fail("Protocol message not sent"); + } + + boolean found = false; + for (int i = 0; i < ObservedWebsocketTransport.messages.size(); i++) { + if (ObservedWebsocketTransport.messages.get(i).action.equals(ProtocolMessage.Action.close)) { + found = true; + break; + } + } + + if (found) { + break; + } + + Thread.sleep(100); + } + } + } +} + +// Create a transport we can observe and a factory for it +class ObservedWebsocketTransport extends WebSocketTransport +{ + public static ArrayList messages = new ArrayList<>(); + + public static class Factory implements ITransport.Factory { + @Override + public ObservedWebsocketTransport getTransport(TransportParams params, ConnectionManager connectionManager) { + return new ObservedWebsocketTransport(params, connectionManager); + } + } + + protected ObservedWebsocketTransport(TransportParams params, ConnectionManager connectionManager) { + super(params, connectionManager); + } + + @Override + public void send(ProtocolMessage msg) throws AblyException { + messages.add(msg); + super.send(msg); + } } diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAnnotationsTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAnnotationsTest.java new file mode 100644 index 000000000..cfc18bc20 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAnnotationsTest.java @@ -0,0 +1,184 @@ +package io.ably.lib.test.realtime; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.test.common.Helpers; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Annotation; +import io.ably.lib.types.AnnotationAction; +import io.ably.lib.types.ChannelMode; +import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Message; +import io.ably.lib.types.Param; +import io.ably.lib.types.PaginatedResult; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class RealtimeAnnotationsTest extends ParameterizedTest { + + @Rule + public Timeout testTimeout = Timeout.seconds(60); + + @Test + public void publish_and_subscribe_annotations() throws Exception { + + String channelName = "mutable:publish_subscribe_annotation"; + + TestChannel testChannel = new TestChannel(channelName); + + Channel channel = testChannel.realtimeChannel; + + final Message[] receivedMessage = new Message[1]; + channel.subscribe(message -> receivedMessage[0] = message); + + final Annotation[] receivedAnnotation = new Annotation[1]; + Helpers.CompletionWaiter waiter = new Helpers.CompletionWaiter(); + channel.annotations.subscribe(annotation -> { + receivedAnnotation[0] = annotation; + waiter.onSuccess(); + }); + + Helpers.MessageWaiter messageWaiter = new Helpers.MessageWaiter(channel); + channel.publish("message", "foobar"); + messageWaiter.waitFor(1); + + assertNotNull("Message should be received", receivedMessage[0]); + + Annotation emoji1Annotation = new Annotation(); + emoji1Annotation.type = "reaction:distinct.v1"; + emoji1Annotation.name = "👍"; + + channel.annotations.publish(receivedMessage[0].serial, emoji1Annotation); + waiter.waitFor(); + + assertNotNull("Annotation should be received", receivedAnnotation[0]); + assertEquals(AnnotationAction.ANNOTATION_CREATE, receivedAnnotation[0].action); + assertEquals(receivedMessage[0].serial, receivedAnnotation[0].messageSerial); + assertEquals("reaction:distinct.v1", receivedAnnotation[0].type); + assertEquals("👍", receivedAnnotation[0].name); + assertTrue(receivedAnnotation[0].serial.compareTo(receivedAnnotation[0].messageSerial) > 0); + + waiter.reset(); + + receivedAnnotation[0] = null; + Annotation emoji2Annotation = new Annotation(); + emoji2Annotation.type = "reaction:distinct.v1"; + emoji2Annotation.name = "😕"; + testChannel.restChannel.annotations.publish(receivedMessage[0].serial, emoji2Annotation); + + waiter.waitFor(); + + assertNotNull("Rest annotation should be received", receivedAnnotation[0]); + assertEquals(AnnotationAction.ANNOTATION_CREATE, receivedAnnotation[0].action); + assertEquals(receivedMessage[0].serial, receivedAnnotation[0].messageSerial); + assertEquals("reaction:distinct.v1", receivedAnnotation[0].type); + assertEquals("😕", receivedAnnotation[0].name); + assertTrue(receivedAnnotation[0].serial.compareTo(receivedAnnotation[0].messageSerial) > 0); + + testChannel.dispose(); + } + + @Test + public void get_all_annotations() throws Exception { + String channelName = "mutable:get_all_annotations_for_a_message"; + + TestChannel testChannel = new TestChannel(channelName); + Channel channel = testChannel.realtimeChannel; + + final Message[] receivedMessage = new Message[1]; + channel.subscribe(message -> receivedMessage[0] = message); + + Helpers.MessageWaiter messageWaiter = new Helpers.MessageWaiter(channel); + channel.publish("message", "foobar"); + messageWaiter.waitFor(1); + + Helpers.CompletionWaiter waiter = new Helpers.CompletionWaiter(); + channel.annotations.subscribe(annotation -> waiter.onSuccess()); + + String[] emojis = new String[]{"👍", "😕", "👎", "👍👍", "😕😕", "👎👎"}; + for (String emoji : emojis) { + Annotation annotation = new Annotation(); + annotation.type = "reaction:distinct.v1"; + annotation.name = emoji; + testChannel.restChannel.annotations.publish(receivedMessage[0].serial, annotation); + } + + waiter.waitFor(6); + + // There is a gap between receiving annotation messages and getting them in annotations + Thread.sleep(1_000); + + PaginatedResult result = channel.annotations.get(receivedMessage[0].serial); + assertEquals(6, result.items().length); + + assertEquals(AnnotationAction.ANNOTATION_CREATE, result.items()[0].action); + assertEquals(receivedMessage[0].serial, result.items()[0].messageSerial); + assertEquals("reaction:distinct.v1", result.items()[0].type); + assertEquals("👍", result.items()[0].name); + assertEquals("😕", result.items()[1].name); + assertEquals("👎", result.items()[2].name); + assertTrue(result.items()[1].serial.compareTo(result.items()[0].serial) > 0); + assertTrue(result.items()[2].serial.compareTo(result.items()[1].serial) > 0); + + result = channel.annotations.get(receivedMessage[0].serial, new Param[]{new Param("limit", "2")}); + assertEquals(2, result.items().length); + assertEquals("👍", result.items()[0].name); + assertEquals("😕", result.items()[1].name); + assertTrue(result.hasNext()); + + result = result.next(); + assertNotNull(result); + assertEquals(2, result.items().length); + assertEquals("👎", result.items()[0].name); + assertEquals("👍👍", result.items()[1].name); + assertTrue(result.hasNext()); + + result = result.next(); + assertNotNull(result); + assertEquals(2, result.items().length); + assertEquals("😕😕", result.items()[0].name); + assertEquals("👎👎", result.items()[1].name); + assertTrue(!result.hasNext()); + } + + + private class TestChannel { + TestChannel(String channelName) throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.clientId = UUID.randomUUID().toString(); + rest = new AblyRest(opts); + restChannel = rest.channels.get(channelName); + realtime = new AblyRealtime(opts); + ChannelOptions channelOptions = new ChannelOptions(); + channelOptions.modes = new ChannelMode[] { + ChannelMode.publish, ChannelMode.subscribe, ChannelMode.annotation_publish, ChannelMode.annotation_subscribe + }; + + realtimeChannel = realtime.channels.get(channelName, channelOptions); + realtimeChannel.attach(); + (new Helpers.ChannelWaiter(realtimeChannel)).waitFor(ChannelState.attached); + } + + void dispose() throws Exception { + realtime.close(); + rest.close(); + } + + AblyRest rest; + AblyRealtime realtime; + io.ably.lib.rest.Channel restChannel; + io.ably.lib.realtime.Channel realtimeChannel; + } + +} diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAuthTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAuthTest.java index d46357112..d46014e58 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAuthTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeAuthTest.java @@ -1,10 +1,11 @@ package io.ably.lib.test.realtime; -import io.ably.lib.realtime.*; -import io.ably.lib.test.common.Setup; -import io.ably.lib.util.Log; - import io.ably.lib.debug.DebugOptions; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.realtime.ConnectionState; +import io.ably.lib.realtime.ConnectionStateListener; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.rest.Auth.TokenDetails; @@ -13,22 +14,45 @@ import io.ably.lib.test.common.Helpers.CompletionSet; import io.ably.lib.test.common.Helpers.ConnectionWaiter; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.common.Setup; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.Message; +import io.ably.lib.types.NonRetriableTokenException; +import io.ably.lib.types.Param; import io.ably.lib.types.ProtocolMessage; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.Timeout; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.net.URLEncoder; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; public class RealtimeAuthTest extends ParameterizedTest { @Rule public Timeout testTimeout = Timeout.seconds(30); + /** + * Verifies an Exception is thrown, when client is initialized with an empty key + * + * @throws IllegalArgumentException + */ + @Test(expected = IllegalArgumentException.class) + public void auth_client_cannot_be_initialized_with_empty_key() throws AblyException { + new AblyRealtime(""); + } + /** * RSA12a: The clientId attribute of a TokenRequest or TokenDetails * used for authentication is null, or ConnectionDetails#clientId is null @@ -64,7 +88,41 @@ public void auth_client_match_tokendetails_null_clientId() { assertEquals("Verify connected state is reached", ConnectionState.connected, ablyRealtime.connection.state); /* check expected clientId */ - assertEquals("Auth#clientId is expected to be null", null, ablyRealtime.auth.clientId); + assertNull("Auth#clientId is expected to be null", ablyRealtime.auth.clientId); + + ablyRealtime.close(); + } catch (AblyException e) { + e.printStackTrace(); + fail(); + } + } + + /** + * Given authUrl in the form of query string,ensure that realtime will connect without any problem + */ + @Test + public void realtime_connection_with_auth_url_in_query_string_connects() { + try { + /* init ably for token */ + ClientOptions optsForToken = createOptions(testVars.keys[0].keyStr); + final AblyRest ablyForToken = new AblyRest(optsForToken); + + /* get token */ + Auth.TokenParams tokenParams = new Auth.TokenParams(); + Auth.TokenDetails tokenDetails = ablyForToken.auth.requestToken(tokenParams, null); + assertNotNull("Expected token value", tokenDetails.token); + + /* create ably realtime with tokenDetails and clientId */ + ClientOptions opts = createOptions(); + opts.authUrl = "https://echo.ably.io/?body="+ URLEncoder.encode(tokenDetails.token); + opts.useTokenAuth = true; + AblyRealtime ablyRealtime = new AblyRealtime(opts); + System.out.println("done create ably"); + + /* wait for connected state */ + Helpers.ConnectionWaiter connectionWaiter = new Helpers.ConnectionWaiter(ablyRealtime.connection); + connectionWaiter.waitFor(ConnectionState.connected); + assertEquals("Verify connected state is reached", ConnectionState.connected, ablyRealtime.connection.state); ablyRealtime.close(); } catch (AblyException e) { @@ -73,6 +131,188 @@ public void auth_client_match_tokendetails_null_clientId() { } } + /** + * RSA4d: If a request by a realtime client to an authUrl results in an HTTP 403 response, + * or any of an authUrl request, an authCallback, or a request to Ably to exchange + * a TokenRequest for a TokenDetails result in an ErrorInfo with statusCode 403, + * as part of an attempt by the realtime client to authenticate, then the client library + * should transition to the FAILED state, with an ErrorInfo (with code 80019, statusCode 403, + * and cause set to the underlying cause) emitted with the state change and set as the connection + * errorReason + * + * Verify that if server responses with 403 error code on authorization attempt, + * end connection state is failed. + * + * Spec: RSA4d, RSA4d1 + */ + @Test + public void auth_client_fails() { + AblyRealtime ablyRealtime = null; + try { + /* init ably for token */ + ClientOptions optsForToken = createOptions(testVars.keys[0].keyStr); + AblyRest ablyForToken = new AblyRest(optsForToken); + /* get token */ + TokenDetails tokenDetails = ablyForToken.auth.requestToken(null, null); + + /* create ably realtime with tokenDetails and auth url which returns 403 error code */ + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.autoConnect = false; + opts.tokenDetails = tokenDetails; + opts.useTokenAuth = true; + opts.authUrl = "https://echo.ably.io/respondwith"; + opts.authParams = new Param[]{ new Param("status", 403)}; + + ablyRealtime = new AblyRealtime(opts); + ablyRealtime.connection.connect(); + + /* wait for connected state */ + Helpers.ConnectionWaiter connectionWaiter = new Helpers.ConnectionWaiter(ablyRealtime.connection); + connectionWaiter.waitFor(ConnectionState.connected); + + try { + opts.tokenDetails = null; + /* try to authorize */ + ablyRealtime.auth.authorize(null, opts); + } catch (AblyException e) { + /* check expected error codes */ + assertEquals(403, e.errorInfo.statusCode); + assertEquals(80019, e.errorInfo.code); + } + + /* wait for failed state */ + connectionWaiter.waitFor(ConnectionState.failed); + ConnectionStateListener.ConnectionStateChange lastStateChange = connectionWaiter.lastStateChange(); + assertEquals(ConnectionState.failed, lastStateChange.current); + assertEquals(80019, lastStateChange.reason.code); + assertEquals(403, lastStateChange.reason.statusCode); + + assertEquals("Verify connected state has failed", ConnectionState.failed, ablyRealtime.connection.state); + assertEquals("Check correct cause error code", 403, ablyRealtime.connection.reason.statusCode); + assertEquals(80019, ablyRealtime.connection.reason.code); + + } catch (AblyException e) { + e.printStackTrace(); + fail(); + } finally { + assert ablyRealtime != null; + ablyRealtime.close(); + } + } + + /** + * Spec: RSA4d + */ + @Test + public void auth_client_fails_when_auth_token_fails_with_non_retriable_exception() { + try { + class NonRetriableRuntimeException extends RuntimeException implements NonRetriableTokenException { + NonRetriableRuntimeException(){ + super("Non retriable runtime exception"); + } + } + + Exception exception = new NonRetriableRuntimeException(); + final AblyRealtime ablyRealtime = createAblyRealtimeWithTokenAuthError(exception); + + ablyRealtime.connection.connect(); + + waitAndAssertConnectionState(ablyRealtime, ConnectionState.failed, 403, 80019); + } catch (AblyException e) { + e.printStackTrace(); + fail(); + } + } + + /** + * Spec: RSA4d + */ + @Test + public void auth_client_fails_when_auth_token_fails_with_ably_exception_with_status_code_403() { + try { + Exception exception = AblyException.fromErrorInfo(new ErrorInfo("A non retriable Ably exception", 403, 80040)); + final AblyRealtime ablyRealtime = createAblyRealtimeWithTokenAuthError(exception); + + ablyRealtime.connection.connect(); + + waitAndAssertConnectionState(ablyRealtime, ConnectionState.failed, 403, 80019); + } catch (AblyException e) { + e.printStackTrace(); + fail(); + } + } + + /** + * Spec: RSA4c + */ + @Test + public void auth_client_does_not_fail_when_auth_token_fails_with_an_ably_exception() { + try { + Exception exception = AblyException.fromErrorInfo(new ErrorInfo("An Ably exception", 401, 80040)); + final AblyRealtime ablyRealtime = createAblyRealtimeWithTokenAuthError(exception); + + ablyRealtime.connection.connect(); + + waitAndAssertConnectionState(ablyRealtime, ConnectionState.disconnected, 401, 80019); + } catch (AblyException e) { + e.printStackTrace(); + fail(); + } + } + + /** + * Spec: RSA4c + */ + @Test + public void auth_client_does_not_fail_when_auth_token_fails_with_a_runtime_exception() { + try { + Exception exception = new RuntimeException("A runtime exception"); + final AblyRealtime ablyRealtime = createAblyRealtimeWithTokenAuthError(exception); + + ablyRealtime.connection.connect(); + + waitAndAssertConnectionState(ablyRealtime, ConnectionState.disconnected, 401, 80019); + } catch (AblyException e) { + e.printStackTrace(); + fail(); + } + } + + /** + * Waits for the Ably connection to enter the [connectionState] and once it happens asserts that the connection state, + * status code and code have expected values. + */ + private void waitAndAssertConnectionState(AblyRealtime ablyRealtime,ConnectionState connectionState, int statusCode, int code){ + Helpers.ConnectionWaiter connectionWaiter = new Helpers.ConnectionWaiter(ablyRealtime.connection); + connectionWaiter.waitFor(connectionState); + + assertEquals("Verify connected state has changed", connectionState, ablyRealtime.connection.state); + assertEquals("Check correct cause error status code", statusCode, ablyRealtime.connection.reason.statusCode); + assertEquals("Check correct cause error code", code, ablyRealtime.connection.reason.code); + } + + /** + * Create ably realtime with auth callback which throws the specified exception. + */ + private AblyRealtime createAblyRealtimeWithTokenAuthError(final Exception exception) throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.autoConnect = false; + opts.useTokenAuth = true; + opts.authCallback = new Auth.TokenCallback() { + @Override + public Object getTokenRequest(Auth.TokenParams params) throws AblyException { + if (exception instanceof AblyException) { + throw (AblyException) exception; + } else if (exception instanceof RuntimeException) { + throw (RuntimeException) exception; + } else { + throw AblyException.fromThrowable(exception); + } + } + }; + return new AblyRealtime(opts); + } + /** * RSA12a: The clientId attribute of a TokenRequest or TokenDetails * used for authentication is null, or ConnectionDetails#clientId is null @@ -108,7 +348,7 @@ public void auth_client_match_token_null_clientId() { assertEquals("Verify connected state is reached", ConnectionState.connected, ablyRealtime.connection.state); /* check expected clientId */ - assertEquals("Auth#clientId is expected to be null", null, ablyRealtime.auth.clientId); + assertNull("Auth#clientId is expected to be null", ablyRealtime.auth.clientId); ablyRealtime.close(); } catch (AblyException e) { @@ -141,7 +381,7 @@ public void auth_clientid_null_before_auth() { AblyRealtime ablyRealtime = new AblyRealtime(opts); /* check expected clientId */ - assertEquals("Auth#clientId is expected to be null", null, ablyRealtime.auth.clientId); + assertNull("Auth#clientId is expected to be null", ablyRealtime.auth.clientId); /* wait for connected state */ ablyRealtime.connection.connect(); @@ -384,7 +624,7 @@ public void auth_client_match_tokendetails_clientId() { * RSA15a: Any clientId provided in ClientOptions must match any * non wildcard ('*') clientId value in TokenDetails * in authenticating a non-null clientId - * + * * Verify matching token clientId in token succeeds */ @Test @@ -446,7 +686,7 @@ public void auth_client_match_tokendetails_clientId_fail() { ClientOptions opts = createOptions(); opts.clientId = "options clientId"; opts.tokenDetails = tokenDetails; - AblyRealtime ablyRealtime = new AblyRealtime(opts); + new AblyRealtime(opts); } catch (AblyException e) { assertEquals("Verify error code indicates clientId mismatch", e.errorInfo.code, 40101); } @@ -460,6 +700,7 @@ public void auth_client_match_tokendetails_clientId_fail() { * object that contains an incompatible clientId, the library should ... transition * the connection state to FAILED */ + @Ignore("FIXME: fix exception") @Test public void auth_client_match_token_clientId_fail() { try { @@ -520,7 +761,7 @@ public void auth_clientid_publish_implicit() { /* Publish a message */ Message messageToPublish = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()) /* data */ ); channel.publish(new Message[] { messageToPublish }); @@ -530,7 +771,7 @@ public void auth_clientid_publish_implicit() { /* Get sent message */ Message messagePublished = protocolListener.sentMessages.get(0).messages[0]; - assertEquals("Sent message does not contain clientId", messagePublished.clientId, null); + assertNull("Sent message does not contain clientId", messagePublished.clientId); /* wait until message received on transport */ protocolListener.waitForRecv(1); @@ -542,7 +783,7 @@ public void auth_clientid_publish_implicit() { /* Publish a message with explicit clientId */ protocolListener.reset(); messageToPublish = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()), clientId /* clientId */ ); @@ -566,7 +807,7 @@ public void auth_clientid_publish_implicit() { /* Publish a message with incorrect clientId */ protocolListener.reset(); messageToPublish = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()), "invalid clientId" /* clientId */ ); @@ -576,7 +817,7 @@ public void auth_clientid_publish_implicit() { channel.publish(messageToPublish, pubComplete.add()); pubComplete.waitFor(); assertTrue("Verify publish callback called on completion", pubComplete.pending.isEmpty()); - assertTrue("Verify publish callback returns an error", pubComplete.errors.size() == 1); + assertEquals("Verify publish callback returns an error", 1, pubComplete.errors.size()); assertEquals("Verify publish callback error has expected error code", pubComplete.errors.iterator().next().code, 40012); /* verify no message sent or received on transport */ @@ -585,7 +826,7 @@ public void auth_clientid_publish_implicit() { /* Publish a message to verify that use of the channel can continue */ messageToPublish = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()) /* data */ ); channel.publish(new Message[] { messageToPublish }); @@ -595,7 +836,7 @@ public void auth_clientid_publish_implicit() { /* Get sent message */ messagePublished = protocolListener.sentMessages.get(0).messages[0]; - assertEquals("Sent message does not contain clientId", messagePublished.clientId, null); + assertNull("Sent message does not contain clientId", messagePublished.clientId); /* wait until message received on transport */ protocolListener.waitForRecv(1); @@ -617,6 +858,7 @@ public void auth_clientid_publish_implicit() { * are sent with explicit clientId * Spec: RTL6g4 */ + @Ignore("FIXME: fix exception") @Test public void auth_clientid_publish_explicit_before_identified() { AblyRealtime ably = null; @@ -643,7 +885,7 @@ public void auth_clientid_publish_explicit_before_identified() { /* publish before connection and attach */ Message messageToPublish = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()), clientId /* clientId */ ); @@ -673,7 +915,7 @@ public void auth_clientid_publish_explicit_before_identified() { /* Publish a message to verify that use of the channel can continue */ protocolListener.reset(); messageToPublish = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()) /* data */ ); channel.publish(new Message[] { messageToPublish }); @@ -683,7 +925,7 @@ public void auth_clientid_publish_explicit_before_identified() { /* Get sent message */ messagePublished = protocolListener.sentMessages.get(0).messages[0]; - assertEquals("Sent message does not contain clientId", messagePublished.clientId, null); + assertNull("Sent message does not contain clientId", messagePublished.clientId); /* wait until message received on transport */ protocolListener.waitForRecv(1); @@ -752,7 +994,7 @@ public Object getTokenRequest(Auth.TokenParams params) { ably.connect(); try { opts.wait(); - } catch(InterruptedException ie) {} + } catch(InterruptedException ignored) {} ably.auth.renew(); } @@ -770,6 +1012,158 @@ public Object getTokenRequest(Auth.TokenParams params) { } } + + /** + * Call renewAuth() whilst connecting; verify there's no crash (see https://github.com/ably/ably-java/issues/503) + */ + @Test + public void auth_renewAuth_whilst_connecting() { + try { + /* get a TokenDetails */ + final String testKey = testVars.keys[0].keyStr; + ClientOptions optsForToken = createOptions(testKey); + final AblyRest ablyForToken = new AblyRest(optsForToken); + + final TokenDetails tokenDetails = ablyForToken.auth.requestToken(new Auth.TokenParams(){{ ttl = 1000L; }}, null); + assertNotNull("Expected token value", tokenDetails.token); + + /* create Ably realtime instance with token and authCallback */ + class ProtocolListener extends DebugOptions implements DebugOptions.RawProtocolListener { + ProtocolListener() { + Setup.getTestVars().fillInOptions(this); + protocolListener = this; + } + @Override + public void onRawConnectRequested(String url) { + synchronized(this) { + notify(); + } + } + + @Override + public void onRawConnect(String url) {} + @Override + public void onRawMessageSend(ProtocolMessage message) {} + @Override + public void onRawMessageRecv(ProtocolMessage message) {} + } + + ProtocolListener opts = new ProtocolListener(); + opts.autoConnect = false; + opts.tokenDetails = tokenDetails; + opts.authCallback = new Auth.TokenCallback() { + /* implement callback, using Ably instance with key */ + @Override + public Object getTokenRequest(Auth.TokenParams params) { + return tokenDetails; + } + }; + + final AblyRealtime ably = new AblyRealtime(opts); + synchronized (opts) { + ably.connect(); + try { + opts.wait(); + } catch(InterruptedException ignored) {} + + ably.auth.renewAuth((success, tokenDetails1, errorInfo) -> { + //Ignore completion handling + }); + } + + Helpers.ConnectionWaiter connectionWaiter = new Helpers.ConnectionWaiter(ably.connection); + boolean isConnected = connectionWaiter.waitFor(ConnectionState.connected, 1, 4000L); + if(isConnected) { + /* done */ + ably.close(); + } else { + fail("auth_expired_token_expire_renew: unable to connect; final state = " + ably.connection.state); + } + } catch (AblyException e) { + e.printStackTrace(); + fail("auth_expired_token_expire_renew: Unexpected exception instantiating library"); + } + } + + @Ignore("Fix flakey test") + @Test + public void auth_renewAuth_callback_invoked() throws InterruptedException { + try { + /* get a TokenDetails */ + final String testKey = testVars.keys[0].keyStr; + final ClientOptions clientOptions = createOptions(testKey); + final AblyRest ablyRest = new AblyRest(clientOptions); + + final TokenDetails tokenDetails = ablyRest.auth.requestToken(new Auth.TokenParams() {{ + ttl = 1000L; + }}, null); + assertNotNull("Expected token value", tokenDetails.token); + + // create Ably realtime instance with token and authCallback + class ProtocolListener extends DebugOptions implements DebugOptions.RawProtocolListener { + ProtocolListener() { + Setup.getTestVars().fillInOptions(this); + protocolListener = this; + } + + @Override + public void onRawConnectRequested(String url) { + synchronized (this) { + notify(); + } + } + + @Override + public void onRawConnect(String url) { + } + + @Override + public void onRawMessageSend(ProtocolMessage message) { + } + + @Override + public void onRawMessageRecv(ProtocolMessage message) { + } + } + + final ProtocolListener protocolListener = new ProtocolListener(); + protocolListener.autoConnect = false; + protocolListener.tokenDetails = tokenDetails; + // implement callback, using Ably instance with key + protocolListener.authCallback = params -> tokenDetails; + + final AblyRealtime ably = new AblyRealtime(protocolListener); + synchronized (protocolListener) { + ably.connect(); + try { + protocolListener.wait(); + } catch (InterruptedException ie) { + fail("auth_expired_token_expire_renew protocolListener.wait(): interrupted -" + ie.getMessage()); + } + } + + final Helpers.ConnectionWaiter connectionWaiter = new Helpers.ConnectionWaiter(ably.connection); + boolean isConnected = connectionWaiter.waitFor(ConnectionState.connected, 1, 4000L); + if (isConnected) { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean isCalled = new AtomicBoolean(false); + ably.auth.renewAuth((success, tokenDetails1, errorInfo) -> { + latch.countDown(); + isCalled.set(true); + }); + latch.await(30, TimeUnit.SECONDS); + assertTrue("Callback not invoked", isCalled.get()); + ably.close(); + } else { + fail("auth_renewAuth_callback_invoked: unable to connect; final state = " + ably.connection.state); + } + } catch (AblyException e) { + e.printStackTrace(); + fail("auth_renewAuth_callback_invoked: Unexpected exception instantiating library: " + e.getMessage()); + } + } + + /** * Verify that with queryTime=false, when instancing with an already-expired token and authCallback, * connection can succeed @@ -787,7 +1181,7 @@ public void auth_expired_token_expire_before_connect_renew() { assertNotNull("Expected token value", tokenDetails.token); /* allow to expire */ - try { Thread.sleep(200L); } catch(InterruptedException ie) {} + try { Thread.sleep(200L); } catch(InterruptedException ignored) {} /* create Ably realtime instance with token and authCallback */ ClientOptions opts = createOptions(); diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelHistoryTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelHistoryTest.java index a88245ac5..b11439ff4 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelHistoryTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelHistoryTest.java @@ -11,7 +11,11 @@ import java.util.HashMap; import java.util.Locale; -import org.junit.*; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; import org.junit.rules.Timeout; import io.ably.lib.realtime.AblyRealtime; @@ -28,6 +32,7 @@ import io.ably.lib.types.PaginatedResult; import io.ably.lib.types.Param; +@Ignore("FIXME: fix exceptions") public class RealtimeChannelHistoryTest extends ParameterizedTest { private AblyRealtime ably; @@ -350,7 +355,7 @@ public void channelhistory_wait_b() { /* wait for the history to be persisted */ try { Thread.sleep(16000); - } catch(InterruptedException ie) {} + } catch(InterruptedException ignored) {} /* get the history for this channel */ PaginatedResult messages = channel.history(null); @@ -450,7 +455,7 @@ public void channelhistory_mixed_b() { /* wait for the history to be persisted */ try { Thread.sleep(16000); - } catch(InterruptedException ie) {} + } catch(InterruptedException ignored) {} /* publish to the channel */ msgComplete = new CompletionWaiter(); @@ -512,7 +517,7 @@ public void channelhistory_mixed_f() { /* wait for the history to be persisted */ try { Thread.sleep(16000); - } catch(InterruptedException ie) {} + } catch(InterruptedException ignored) {} /* publish to the channel */ msgComplete = new CompletionWaiter(); @@ -649,7 +654,6 @@ public void channelhistory_limit_b() { } catch (AblyException e) { e.printStackTrace(); fail("channelhistory_limit_b: Unexpected exception"); - return; } finally { if(ably != null) ably.close(); @@ -715,10 +719,7 @@ public void channelhistory_time_f() { for(int i = 20; i < 40; i++) expectedMessageHistory[i - 20] = messageContents.get("history" + i); Assert.assertArrayEquals("Expect messages in forward order", messages.items(), expectedMessageHistory); - } catch (AblyException e) { - e.printStackTrace(); - fail("channelhistory_time_f: Unexpected exception"); - } catch (InterruptedException e) { + } catch (AblyException | InterruptedException e) { e.printStackTrace(); fail("channelhistory_time_f: Unexpected exception"); } finally { @@ -786,10 +787,7 @@ public void channelhistory_time_b() { for(int i = 20; i < 40; i++) expectedMessageHistory[i - 20] = messageContents.get("history" + (59 - i)); Assert.assertArrayEquals("Expect messages in backwards order", messages.items(), expectedMessageHistory); - } catch (AblyException e) { - e.printStackTrace(); - fail("channelhistory_time_b: Unexpected exception"); - } catch (InterruptedException e) { + } catch (AblyException | InterruptedException e) { e.printStackTrace(); fail("channelhistory_time_b: Unexpected exception"); } finally { @@ -1200,7 +1198,7 @@ public void run() { /* wait 2 seconds */ try { Thread.sleep(2000L); - } catch(InterruptedException ie) {} + } catch(InterruptedException ignored) {} /* subscribe; this will trigger the attach */ MessageWaiter messageWaiter = new MessageWaiter(rxChannel); diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java index da88b0211..d9824da31 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java @@ -1,8 +1,15 @@ package io.ably.lib.test.realtime; import io.ably.lib.debug.DebugOptions; -import io.ably.lib.realtime.*; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.Channel.MessageListener; +import io.ably.lib.realtime.ChannelEvent; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.realtime.ChannelStateListener; +import io.ably.lib.realtime.CompletionListener; +import io.ably.lib.realtime.ConnectionState; +import io.ably.lib.realtime.ConnectionStateListener; import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.Helpers.ChannelWaiter; import io.ably.lib.test.common.Helpers.ConnectionWaiter; @@ -10,14 +17,19 @@ import io.ably.lib.test.util.MockWebsocketFactory; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.transport.Defaults; -import io.ably.lib.types.*; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ChannelMode; +import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.util.Log; import org.hamcrest.Matchers; -import org.junit.Rule; +import org.junit.Assert; +import org.junit.Ignore; import org.junit.Test; -import org.junit.rules.Timeout; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -26,7 +38,16 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.junit.Assert.*; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RealtimeChannelTest extends ParameterizedTest { @@ -373,6 +394,71 @@ public void onMessage(Message message) { } } + /** + *

+ * Validates a client can subscribe to messages without implicit channel attach + * Refer Spec TB4, RTL7g, RTL7h + *

+ * @throws AblyException + */ + @Test + public void subscribe_without_implicit_attach() { + String channelName = "subscribe_" + testParams.name; + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + ably = new AblyRealtime(opts); + + /* create a channel and set attachOnSubscribe to false */ + final Channel channel = ably.channels.get(channelName); + ChannelOptions chOpts = new ChannelOptions(); + chOpts.attachOnSubscribe = false; + channel.setOptions(chOpts); + + List receivedMsg = Collections.synchronizedList(new ArrayList<>()); + + /* Check for all subscriptions without ATTACHING state */ + channel.subscribe(message -> receivedMsg.add(true)); + assertEquals(ChannelState.initialized, channel.state); + + channel.subscribe("test_event", message -> receivedMsg.add(true)); + assertEquals(ChannelState.initialized, channel.state); + + channel.subscribe(new String[]{"test_event1", "test_event2"}, message -> receivedMsg.add(true)); + assertEquals(ChannelState.initialized, channel.state); + + channel.attach(); + (new ChannelWaiter(channel)).waitFor(ChannelState.attached); + + channel.publish("test_event", "hi there"); + // Expecting two msg: one from the wildcard subscription and one from test_event subscription + Exception conditionError = new Helpers.ConditionalWaiter(). + wait(() -> receivedMsg.size() == 2, 5000); + assertNull(conditionError); + + receivedMsg.clear(); + channel.publish("test_event1", "hi there"); + // Expecting two msg: one from the wildcard subscription and one from test_event1 subscription + conditionError = new Helpers.ConditionalWaiter(). + wait(() -> receivedMsg.size() == 2, 5000); + assertNull(conditionError); + + receivedMsg.clear(); + channel.publish("test_event2", "hi there"); + // Expecting two msg: one from the wildcard subscription and one from test_event2 subscription + conditionError = new Helpers.ConditionalWaiter(). + wait(() -> receivedMsg.size() == 2, 5000); + assertNull(conditionError); + + } catch (AblyException e) { + e.printStackTrace(); + fail("subscribe_without_implicit_attach: Unexpected exception"); + } finally { + if(ably != null) + ably.close(); + } + } + /** *

* Verifies that unsubscribe call with no argument removes all listeners, @@ -598,7 +684,7 @@ public MessageListener setMessageStack(List messageStack) { new Helpers.MessageWaiter(channel2).waitFor(messages.length); /* Validate that, - * - we received every message that has been published + * - we received every message that has been published */ assertThat(receivedMessageStack.size(), is(equalTo(messages.length))); @@ -689,7 +775,7 @@ public MessageListener setMessageStack(List messageStack) { new Helpers.MessageWaiter(channel2).waitFor(messages.length + 2); /* Validate that, - * - we received specific messages + * - we received specific messages */ assertThat(receivedMessageStack.size(), is(equalTo(messages.length))); @@ -774,7 +860,7 @@ public MessageListener setMessageStack(List messageStack) { new Helpers.MessageWaiter(channel2).waitFor(messages.length + 2); /* Validate that, - * - received same amount of emitted specific message + * - received same amount of emitted specific message * - received messages are the ones we emitted */ assertThat(receivedMessageStack.size(), is(equalTo(messages.length))); @@ -845,7 +931,7 @@ public void attach_success_callback() { Helpers.CompletionWaiter waiter = new Helpers.CompletionWaiter(); channel.attach(waiter); new ChannelWaiter(channel).waitFor(ChannelState.attached); - assertEquals("Verify failed state reached", channel.state, ChannelState.attached); + assertEquals("Verify attached state reached", channel.state, ChannelState.attached); /* Verify onSuccess callback gets called */ waiter.waitFor(); @@ -859,6 +945,63 @@ public void attach_success_callback() { } } + /** + * Spec: RTL4g + */ + @Test + public void attach_success_callback_for_channel_in_failed_state() { + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + ably = new AblyRealtime(opts); + + /* wait until connected */ + (new ConnectionWaiter(ably.connection)).waitFor(ConnectionState.connected); + + /* create a channel and attach */ + final Channel channel = ably.channels.get("attach_success"); + ChannelWaiter channelWaiter = new ChannelWaiter(channel); + channel.attach(); + channelWaiter.waitFor(ChannelState.attached); + + // Simulate connection failure + ably.connection.connectionManager.requestState( + new ConnectionManager.StateIndication( + ConnectionState.failed, + new ErrorInfo("Simulated connection failure", 40000) + ) + ); + + // Wait for the channel to reach the failed state + channelWaiter.waitFor(ChannelState.failed); + + assertNotNull(channel.reason); + assertEquals("Simulated connection failure", channel.reason.message); + + ably.connect(); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + + Helpers.CompletionWaiter attachListener = new Helpers.CompletionWaiter(); + channel.attach(attachListener); + + channelWaiter.waitFor(ChannelState.attaching); + assertNull(channel.reason); + channelWaiter.waitFor(ChannelState.attached); + + assertEquals("Verify attached state reached", ChannelState.attached, channel.state); + + /* Verify onSuccess callback gets called */ + attachListener.waitFor(); + assertTrue(attachListener.success); + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if(ably != null) + ably.close(); + } + } + /** * When client failed to attach to a channel, verify * attach {@code CompletionListener#onError(ErrorInfo)} @@ -898,6 +1041,7 @@ public void attach_fail_callback() { /** * When client detaches from a channel successfully after initialized state, * verify attach {@code CompletionListener#onSuccess()} gets called. + * Spec: RTL5a */ @Test public void detach_success_callback_initialized() { @@ -930,6 +1074,163 @@ public void detach_success_callback_initialized() { } } + /** + * Spec: RTL5j + */ + @Test + public void detach_success_callback_on_suspended_state() { + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + ably = new AblyRealtime(opts); + + /* wait until connected */ + (new ConnectionWaiter(ably.connection)).waitFor(ConnectionState.connected); + + /* create a channel and attach */ + final Channel channel = ably.channels.get("detach_success"); + ChannelWaiter channelWaiter = new ChannelWaiter(channel); + channel.attach(); + channelWaiter.waitFor(ChannelState.attached); + + ably.connection.connectionManager.requestState(ConnectionState.suspended); + + channelWaiter.waitFor(ChannelState.suspended); + assertEquals("Verify suspended state reached", ChannelState.suspended, channel.state); + + /* detach */ + Helpers.CompletionWaiter detachWaiter = new Helpers.CompletionWaiter(); + channel.detach(detachWaiter); + + /* Verify onSuccess callback gets called */ + detachWaiter.waitFor(); + assertTrue(detachWaiter.success); + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if(ably != null) + ably.close(); + } + } + + /** + * Spec: RTL5b + */ + @Test + public void detach_failure_callback_on_failed_state() { + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + ably = new AblyRealtime(opts); + + /* wait until connected */ + (new ConnectionWaiter(ably.connection)).waitFor(ConnectionState.connected); + + /* create a channel and attach */ + final Channel channel = ably.channels.get("detach_failure"); + ChannelWaiter channelWaiter = new ChannelWaiter(channel); + channel.attach(); + channelWaiter.waitFor(ChannelState.attached); + + // Simulate connection failure + ably.connection.connectionManager.requestState(ConnectionState.failed); + + channelWaiter.waitFor(ChannelState.failed); + assertEquals("Verify failed state reached", ChannelState.failed, channel.state); + + /* detach */ + Helpers.CompletionWaiter detachWaiter = new Helpers.CompletionWaiter(); + channel.detach(detachWaiter); + + /* Verify onSuccess callback gets called */ + detachWaiter.waitFor(); + assertFalse(detachWaiter.success); + assertNotNull(detachWaiter.error); + assertEquals("Channel state is failed", detachWaiter.error.message); + assertEquals(90000, detachWaiter.error.code); + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if(ably != null) + ably.close(); + } + } + + /** + * When connection is in failed or suspended, set error in callback + * Spec: RTL5g + */ + @Test + public void detach_fail_callback_for_connection_invalid_state() { + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + ably = new AblyRealtime(opts); + ConnectionWaiter connWaiter = new ConnectionWaiter(ably.connection); + + /* wait until connected */ + connWaiter.waitFor(ConnectionState.connected); + + /* create a channel and attach */ + final Channel channel = ably.channels.get("detach_failure"); + ChannelWaiter channelWaiter = new ChannelWaiter(channel); + channel.attach(); + channelWaiter.waitFor(ChannelState.attached); + + // Simulate connection closing from outside + ably.connection.connectionManager.requestState(new ConnectionManager.StateIndication( + ConnectionState.closing, + new ErrorInfo("Connection is closing", 80001) + )); + /* wait until connection closing */ + connWaiter.waitFor(ConnectionState.closing); + + // channel state is ATTACHED despite closing connection state + assertEquals(ChannelState.attached, channel.state); + + /* detach */ + Helpers.CompletionWaiter detachWaiter1 = new Helpers.CompletionWaiter(); + channel.detach(detachWaiter1); + + /* Verify onSuccess callback gets called */ + detachWaiter1.waitFor(); + assertFalse(detachWaiter1.success); + assertNotNull(detachWaiter1.error); + assertEquals("Connection is closing", detachWaiter1.error.message); + assertEquals(80001, detachWaiter1.error.code); + + // Simulate connection failure + ably.connection.connectionManager.requestState(ConnectionState.failed); + /* wait until connection failed */ + connWaiter.waitFor(ConnectionState.failed); + + // Mock channel state to ATTACHED despite failed connection state + channelWaiter.waitFor(ChannelState.failed); + channel.state = ChannelState.attached; + assertEquals(ChannelState.attached, channel.state); + + /* detach */ + Helpers.CompletionWaiter detachWaiter2 = new Helpers.CompletionWaiter(); + channel.detach(detachWaiter2); + + /* Verify onSuccess callback gets called */ + detachWaiter2.waitFor(); + assertFalse(detachWaiter2.success); + assertNotNull(detachWaiter2.error); + assertEquals("Connection failed", detachWaiter2.error.message); + assertEquals(80000, detachWaiter2.error.code); + + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if(ably != null) + ably.close(); + } + } + /** * When client detaches from a channel successfully after attached state, * verify attach {@code CompletionListener#onSuccess()} gets called. @@ -967,6 +1268,7 @@ public void detach_success_callback_attached() throws AblyException { /** * When client detaches from a channel successfully after detaching state, * verify attach {@code CompletionListener#onSuccess()} gets called. + * Spec: RTL5i */ @Test public void detach_success_callback_detaching() throws AblyException { @@ -1000,6 +1302,107 @@ public void detach_success_callback_detaching() throws AblyException { } } + + /** + * When client attaches to a channel in detaching state, verify that attach call will be done after detach + * response is received + * verify attach {@code CompletionListener#onSuccess()} gets called. + */ + // Spec: RTL4h + // https://github.com/ably/ably-java/issues/885 + @Test + public void attach_when_channel_in_detaching_state() throws AblyException { + AblyRealtime ably = null; + try { + final DebugOptions opts = createOptions(testVars.keys[0].keyStr); + final MockWebsocketFactory transportFactory = new MockWebsocketFactory(); + opts.transportFactory = transportFactory; + opts.logLevel = Log.VERBOSE; + ably = new AblyRealtime(opts); + + /* wait until connected */ + (new ConnectionWaiter(ably.connection)).waitFor(ConnectionState.connected); + assertEquals("Verify connected state reached", ConnectionState.connected, ably.connection.state); + final MockWebsocketFactory.MockWebsocketTransport transport = transportFactory.getCreatedTransport(); + /* create a channel and attach */ + final String channelName = "attach_channel"; + final Channel channel = ably.channels.get(channelName); + channel.attach(); + new ChannelWaiter(channel).waitFor(ChannelState.attached); + assertEquals("Verify attached state reached", ChannelState.attached, channel.state); + + //block detached so we can ensure that we are in detaching state but unblock immediately after assertion + transportFactory.blockReceiveProcessingAndQueueBlockedMessages(message -> message.action == ProtocolMessage.Action.detached); + /* detach */ + final Helpers.CompletionWaiter detachCompletionWaiter = new Helpers.CompletionWaiter(); + channel.detach(detachCompletionWaiter); + assertEquals("Verify detaching state reached", ChannelState.detaching, channel.state); + + //now we can send an attach as we previously blocked detaching + final Helpers.CompletionWaiter attachCompletionWaiter = new Helpers.CompletionWaiter(); + //attempt to attach while detaching without blocking attached + channel.attach(attachCompletionWaiter); + + //unblock and let the queued messages arrive + transportFactory.allowReceiveProcessing(message -> true); + + detachCompletionWaiter.waitFor(); + assertThat(detachCompletionWaiter.success, is(true)); + assertThat(channel.state, is(ChannelState.detached)); + //verify reattach - after detach + attachCompletionWaiter.waitFor(); + assertThat(attachCompletionWaiter.success,is(true)); + assertThat(channel.state, is(ChannelState.attached)); + } finally { + if(ably != null) + ably.close(); + } + } + + /** + * When client detaches from a channel in attaching state, verify that detach call will be done after attach + * response is received + * verify attach {@code CompletionListener#onSuccess()} gets called. + */ + // Spec: RTL5i + // https://github.com/ably/ably-java/issues/885 + @Test + public void detach_when_channel_in_attaching_state() throws AblyException { + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.logLevel = Log.VERBOSE; + ably = new AblyRealtime(opts); + + /* wait until connected */ + (new ConnectionWaiter(ably.connection)).waitFor(ConnectionState.connected); + assertEquals("Verify connected state reached", ConnectionState.connected, ably.connection.state); + + /* create a channel and attach */ + final String channelName = "attach_channel"; + final Channel channel = ably.channels.get(channelName); + final Helpers.CompletionWaiter attachCompletionWaiter = new Helpers.CompletionWaiter(); + channel.attach(attachCompletionWaiter); + assertEquals("Verify detaching state reached", ChannelState.attaching, channel.state); + //immediately start detach operation + final Helpers.CompletionWaiter detachCompletionWaiter = new Helpers.CompletionWaiter(); + channel.detach(detachCompletionWaiter); + + new ChannelWaiter(channel).waitFor(ChannelState.attached); + assertEquals("Verify attached state reached", ChannelState.attached, channel.state); + + //now wait for detach to complete + (new ChannelWaiter(channel)).waitFor(ChannelState.detached); + detachCompletionWaiter.waitFor(); + + assertThat(detachCompletionWaiter.success,is(true)); + assertThat(channel.state, is(ChannelState.detached)); + } finally { + if(ably != null) + ably.close(); + } + } + /** * When client detaches from a channel successfully after detached state, * verify attach {@code CompletionListener#onSuccess()} gets called. @@ -1047,6 +1450,7 @@ public void detach_success_callback_detached() throws AblyException { *

* */ + @Ignore("FIXME: fix exception") @Test public void transient_publish_connected() throws AblyException { AblyRealtime pubAbly = null, subAbly = null; @@ -1096,6 +1500,7 @@ public void transient_publish_connected() throws AblyException { *

* */ + @Ignore("FIXME: fix exception") @Test public void transient_publish_connecting() throws AblyException { AblyRealtime pubAbly = null, subAbly = null; @@ -1121,6 +1526,7 @@ public void transient_publish_connecting() throws AblyException { assertEquals("Verify channel remains in initialized state", pubChannel.state, ChannelState.initialized); ErrorInfo errorInfo = completionWaiter.waitFor(); + assertNull(errorInfo); assertEquals("Verify channel remains in initialized state", pubChannel.state, ChannelState.initialized); messageWaiter.waitFor(1); @@ -1159,7 +1565,7 @@ public void transient_publish_connection_failed() { try { pubChannel.publish("Lorem", "Ipsum!", completionWaiter); fail("failed to raise expected exception"); - } catch(AblyException e) { + } catch(AblyException ignored) { } } catch(AblyException e) { fail("unexpected exception"); @@ -1219,7 +1625,6 @@ public void transient_publish_channel_failed() { * Spec: RTL7c *

* - * @throws AblyException */ @Test public void attach_implicit_subscribe_fail() throws AblyException { @@ -1329,13 +1734,13 @@ public void channel_state_on_connection_suspended() { } /* - * Establish connection, attach channel, simulate sending attached and detached messages + * Establish connection, attach channel, simulate sending attached message * from the server, test correct behaviour * - * Tests RTL12, RTL13a + * Tests RTL12 */ @Test - public void channel_server_initiated_attached_detached() throws AblyException { + public void channel_server_initiated_attached() throws AblyException { AblyRealtime ably = null; long oldRealtimeTimeout = Defaults.realtimeRequestTimeout; final String channelName = "channel_server_initiated_attach_detach"; @@ -1348,6 +1753,7 @@ public void channel_server_initiated_attached_detached() throws AblyException { opts.channelRetryTimeout = 1000; ably = new AblyRealtime(opts); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); Channel channel = ably.channels.get(channelName); ChannelWaiter channelWaiter = new ChannelWaiter(channel); @@ -1355,38 +1761,140 @@ public void channel_server_initiated_attached_detached() throws AblyException { channel.attach(); channelWaiter.waitFor(ChannelState.attached); - final int[] updateEventsEmitted = new int[]{0}; - final boolean[] resumedFlag = new boolean[]{true}; - channel.on(ChannelEvent.update, new ChannelStateListener() { - @Override - public void onChannelStateChanged(ChannelStateChange stateChange) { - updateEventsEmitted[0]++; - resumedFlag[0] = stateChange.resumed; - } - }); - /* Inject attached message as if received from the server */ ProtocolMessage attachedMessage = new ProtocolMessage() {{ action = Action.attached; channel = channelName; - flags |= Flag.resumed.getMask(); }}; ably.connection.connectionManager.onMessage(null, attachedMessage); + ChannelStateListener.ChannelStateChange channelUpdateEvent = channelWaiter.waitFor(ChannelEvent.update); + assertEquals(ChannelEvent.update, channelUpdateEvent.event); + assertEquals(ChannelState.attached, channelUpdateEvent.previous); + assertEquals(ChannelState.attached, channelUpdateEvent.current); + assertFalse(channelUpdateEvent.resumed); + assertNull(channelUpdateEvent.reason); + + } finally { + if (ably != null) + ably.close(); + Defaults.realtimeRequestTimeout = oldRealtimeTimeout; + } + } + + /* + * Establish connection, attach channel, simulate sending detached messages + * from the server for channel in attached state. + * + * Tests RTL13a + */ + @Test + public void server_initiated_detach_for_attached_channel() throws AblyException { + AblyRealtime ably = null; + long oldRealtimeTimeout = Defaults.realtimeRequestTimeout; + final String channelName = "channel_server_initiated_detach_for_attached_channel"; + + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + + /* Make test faster */ + Defaults.realtimeRequestTimeout = 1000; + opts.channelRetryTimeout = 1000; + + ably = new AblyRealtime(opts); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + + Channel channel = ably.channels.get(channelName); + ChannelWaiter channelWaiter = new ChannelWaiter(channel); + + channel.attach(); + channelWaiter.waitFor(ChannelState.attached); + /* Inject detached message as if from the server */ ProtocolMessage detachedMessage = new ProtocolMessage() {{ action = Action.detached; channel = channelName; + error = new ErrorInfo("Simulated detach", 40000); }}; ably.connection.connectionManager.onMessage(null, detachedMessage); /* Channel should transition to attaching, then to attached */ - channelWaiter.waitFor(ChannelState.attaching); + ErrorInfo detachErr = channelWaiter.waitFor(ChannelState.attaching); + Assert.assertNotNull(detachErr); + Assert.assertEquals(40000, detachErr.code); + Assert.assertEquals("Simulated detach", detachErr.message); + + channelWaiter.waitFor(ChannelState.attached); + + List channelStates = channelWaiter.getRecordedStates(); + Assert.assertEquals(4, channelStates.size()); + Assert.assertEquals(ChannelState.attaching, channelStates.get(0)); + Assert.assertEquals(ChannelState.attached, channelStates.get(1)); + Assert.assertEquals(ChannelState.attaching, channelStates.get(2)); + Assert.assertEquals(ChannelState.attached, channelStates.get(3)); + + } finally { + if (ably != null) + ably.close(); + Defaults.realtimeRequestTimeout = oldRealtimeTimeout; + } + } + + /* + * Establish connection, attach channel, simulate sending detached messages + * from the server for channel in suspended state. + * + * Tests RTL13a + */ + @Test + public void server_initiated_detach_for_suspended_channel() throws AblyException { + AblyRealtime ably = null; + long oldRealtimeTimeout = Defaults.realtimeRequestTimeout; + final String channelName = "channel_server_initiated_detach_for_suspended_channel"; + + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + + /* Make test faster */ + Defaults.realtimeRequestTimeout = 1000; + opts.channelRetryTimeout = 1000; + + ably = new AblyRealtime(opts); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + + Channel channel = ably.channels.get(channelName); + ChannelWaiter channelWaiter = new ChannelWaiter(channel); + + channel.attach(); channelWaiter.waitFor(ChannelState.attached); - /* Verify received UPDATE message on channel */ - assertEquals("Verify exactly one UPDATE event was emitted on the channel", updateEventsEmitted[0], 1); - assertTrue("Verify resumed flag set in UPDATE event", resumedFlag[0]); + channel.setSuspended(new ErrorInfo("Set state to suspended", 400), true); + channelWaiter.waitFor(ChannelState.suspended); + + /* Inject detached message as if from the server */ + ProtocolMessage detachedMessage = new ProtocolMessage() {{ + action = Action.detached; + channel = channelName; + error = new ErrorInfo("Simulated detach", 40000); + }}; + ably.connection.connectionManager.onMessage(null, detachedMessage); + + /* Channel should transition to attaching, then to attached */ + ErrorInfo detachError = channelWaiter.waitFor(ChannelState.attaching); + Assert.assertNotNull(detachError); + Assert.assertEquals(40000, detachError.code); + Assert.assertEquals("Simulated detach", detachError.message); + + channelWaiter.waitFor(ChannelState.attached); + + List channelStates = channelWaiter.getRecordedStates(); + Assert.assertEquals(5, channelStates.size()); + Assert.assertEquals(ChannelState.attaching, channelStates.get(0)); + Assert.assertEquals(ChannelState.attached, channelStates.get(1)); + Assert.assertEquals(ChannelState.suspended, channelStates.get(2)); + Assert.assertEquals(ChannelState.attaching, channelStates.get(3)); + Assert.assertEquals(ChannelState.attached, channelStates.get(4)); + } finally { if (ably != null) ably.close(); @@ -1398,10 +1906,83 @@ public void onChannelStateChanged(ChannelStateChange stateChange) { * Establish connection, attach channel, disconnection and failed resume * verify that subsequent attaches are performed, and give rise to update events * - * Tests RTN15c3 + * Tests RTN15c6 + */ + @Test + public void channel_valid_resume_reattach_channels() throws AblyException { + AblyRealtime ably = null; + + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + ably = new AblyRealtime(opts); + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + ably.connect(); + connectionWaiter.waitFor(ConnectionState.connected); + String originalConnectionId = ably.connection.id; + + /* prepare channels */ + Channel attachedChannel = ably.channels.get("attached_channel"); + ChannelWaiter attachedChannelWaiter = new ChannelWaiter(attachedChannel); + attachedChannel.attach(); + attachedChannelWaiter.waitFor(ChannelState.attached); + attachedChannel.publish("chat", "message"); + + Channel suspendedChannel = ably.channels.get("suspended_channel"); + ChannelWaiter suspendedChannelWaiter = new ChannelWaiter(suspendedChannel); + suspendedChannel.attach(); + suspendedChannelWaiter.waitFor(ChannelState.attached); + suspendedChannel.setSuspended(null, true); + suspendedChannelWaiter.waitFor(ChannelState.suspended); + + assertEquals(ably.connection.connectionManager.msgSerial, 1); + + new Helpers.MutableConnectionManager(ably).disconnectAndSuppressRetries(); + connectionWaiter.waitFor(ConnectionState.disconnected); + assertEquals("Verify disconnected state is reached", ConnectionState.disconnected, ably.connection.state); + + /* wait for connection to be reestablished */ + System.out.println("channel_resume_lost_continuity: initiating reconnection (resume)"); + ably.connection.connect(); + + ErrorInfo resumeError = connectionWaiter.waitFor(ConnectionState.connected); + assertNull(resumeError); + assertNull(ably.connection.connectionManager.getStateErrorInfo()); + assertEquals("Same connection is used", originalConnectionId, ably.connection.id); + assertEquals(ably.connection.connectionManager.msgSerial, 1); + + attachedChannelWaiter.waitFor(ChannelState.attaching, ChannelState.attached); + suspendedChannelWaiter.waitFor(ChannelState.attached); + + assertFalse("Verify channel was not suspended", + attachedChannelWaiter.hasStates(ChannelState.suspended)); + assertTrue("Verify channel was attaching and attached", + attachedChannelWaiter.hasFinalStates(ChannelState.attaching, ChannelState.attached)); + + ChannelStateListener.ChannelStateChange stateChange = attachedChannelWaiter.getLastStateChange(); + assertEquals(ChannelState.attached, stateChange.current); + assertEquals(ChannelState.attaching, stateChange.previous); + + assertTrue("Verify channel was attaching", + suspendedChannelWaiter.hasFinalStates(ChannelState.attaching, ChannelState.attached)); + + stateChange = suspendedChannelWaiter.getLastStateChange(); + assertEquals(ChannelState.attached, stateChange.current); + assertEquals(ChannelState.attaching, stateChange.previous); + + } finally { + if (ably != null) + ably.close(); + } + } + + /* + * Establish connection, attach channel, disconnection and failed resume + * verify that subsequent attaches are performed, and give rise to update events + * + * Tests RTN15c7 */ @Test - public void channel_resume_lost_continuity() throws AblyException { + public void channel_invalid_resume_reattach_channels() throws AblyException { AblyRealtime ably = null; final String attachedChannelName = "channel_resume_lost_continuity_attached"; final String suspendedChannelName = "channel_resume_lost_continuity_suspended"; @@ -1409,105 +1990,69 @@ public void channel_resume_lost_continuity() throws AblyException { try { ClientOptions opts = createOptions(testVars.keys[0].keyStr); ably = new AblyRealtime(opts); + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + ably.connect(); + connectionWaiter.waitFor(ConnectionState.connected); + String originalConnectionId = ably.connection.id; /* prepare channels */ Channel attachedChannel = ably.channels.get(attachedChannelName); ChannelWaiter attachedChannelWaiter = new ChannelWaiter(attachedChannel); attachedChannel.attach(); attachedChannelWaiter.waitFor(ChannelState.attached); + attachedChannel.publish("chat", "message"); Channel suspendedChannel = ably.channels.get(suspendedChannelName); - suspendedChannel.state = ChannelState.suspended; ChannelWaiter suspendedChannelWaiter = new ChannelWaiter(suspendedChannel); + suspendedChannel.attach(); + suspendedChannelWaiter.waitFor(ChannelState.attached); + suspendedChannel.setSuspended(null, true); + suspendedChannelWaiter.waitFor(ChannelState.suspended); - final boolean[] suspendedStateReached = new boolean[2]; - final boolean[] attachingStateReached = new boolean[2]; - final boolean[] attachedStateReached = new boolean[2]; - final boolean[] resumedFlag = new boolean[]{true, true}; - attachedChannel.on(new ChannelStateListener() { - @Override - public void onChannelStateChanged(ChannelStateChange stateChange) { - switch(stateChange.current) { - case suspended: - suspendedStateReached[0] = true; - break; - case attaching: - attachingStateReached[0] = true; - break; - case attached: - attachedStateReached[0] = true; - resumedFlag[0] = stateChange.resumed; - break; - default: - break; - } - } - }); - suspendedChannel.on(new ChannelStateListener() { - @Override - public void onChannelStateChanged(ChannelStateChange stateChange) { - switch(stateChange.current) { - case attaching: - attachingStateReached[1] = true; - break; - case attached: - attachedStateReached[1] = true; - resumedFlag[1] = stateChange.resumed; - break; - default: - break; - } - } - }); - - /* disconnect, and sabotage the resume */ - String originalConnectionId = ably.connection.id; - ably.connection.key = "_____!ably___test_fake-key____"; - ably.connection.id = "ably___tes"; - ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); - - /* suppress automatic retries by the connection manager */ - try { - Method method = ably.connection.connectionManager.getClass().getDeclaredMethod("disconnectAndSuppressRetries"); - method.setAccessible(true); - method.invoke(ably.connection.connectionManager); - } catch (NoSuchMethodException|IllegalAccessException|InvocationTargetException e) { - fail("Unexpected exception in suppressing retries"); - } + assertEquals(ably.connection.connectionManager.msgSerial, 1); + new Helpers.MutableConnectionManager(ably).disconnectAndSuppressRetries(); connectionWaiter.waitFor(ConnectionState.disconnected); assertEquals("Verify disconnected state is reached", ConnectionState.disconnected, ably.connection.state); - /* wait */ - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + /* disconnect, and sabotage the resume */ + ably.connection.key = "_____!ably___test_fake-key____"; /* wait for connection to be reestablished */ System.out.println("channel_resume_lost_continuity: initiating reconnection (resume)"); ably.connection.connect(); - connectionWaiter.waitFor(ConnectionState.connected); - /* verify a new connection was assigned */ + + ErrorInfo resumeError = connectionWaiter.waitFor(ConnectionState.connected); + assertNotNull(resumeError); + assertEquals("Verify error code indicates invalid connection key", resumeError.code, 80018); + assertSame(resumeError, ably.connection.connectionManager.getStateErrorInfo()); assertNotEquals("A new connection was created", originalConnectionId, ably.connection.id); - /* previously suspended channel should transition to attaching, then to attached */ + AblyRealtime finalAbly = ably; + Exception conditionError = new Helpers.ConditionalWaiter(). + wait(() -> finalAbly.connection.connectionManager.msgSerial == 0, 10000); + assertNull(conditionError); + + attachedChannelWaiter.waitFor(ChannelState.attaching, ChannelState.attached); suspendedChannelWaiter.waitFor(ChannelState.attached); - /* previously attached channel should remain attached */ - attachedChannelWaiter.waitFor(ChannelState.attached); + assertFalse("Verify channel was not suspended", + attachedChannelWaiter.hasStates(ChannelState.suspended)); + assertTrue("Verify channel was attaching and attached", + attachedChannelWaiter.hasFinalStates(ChannelState.attaching, ChannelState.attached)); + + ChannelStateListener.ChannelStateChange stateChange = attachedChannelWaiter.getLastStateChange(); + assertEquals(ChannelState.attached, stateChange.current); + assertEquals(ChannelState.attaching, stateChange.previous); + + assertTrue("Verify channel was attaching", + suspendedChannelWaiter.hasFinalStates(ChannelState.attaching, ChannelState.attached)); + + stateChange = suspendedChannelWaiter.getLastStateChange(); + assertEquals(ChannelState.attached, stateChange.current); + assertEquals(ChannelState.attaching, stateChange.previous); - /* - * Verify each channel undergoes relevant events: - * - previously attached channel does attaching, attached, without visiting suspended; - * - previously suspended channel does attaching, attached - */ - assertEquals("Verify channel was not suspended", suspendedStateReached[0], false); - assertEquals("Verify channel was attaching", attachingStateReached[0], true); - assertEquals("Verify channel was attached", attachedStateReached[0], true); - assertFalse("Verify resumed flag set false in ATTACHED event", resumedFlag[0]); - - assertEquals("Verify channel was attaching", attachingStateReached[1], true); - assertEquals("Verify channel was attached", attachedStateReached[1], true); - assertFalse("Verify resumed flag set false in ATTACHED event", resumedFlag[1]); } finally { if (ably != null) ably.close(); @@ -1622,7 +2167,7 @@ public void onError(ErrorInfo reason) { if (errorDetaching[0] != null) errorDetaching.wait(1000); } - } catch (InterruptedException e) {} + } catch (InterruptedException ignored) {} assertNotNull("Verify detach operation failed", errorDetaching[0]); @@ -1684,7 +2229,7 @@ public void channel_reattach_failed_timeout() { /* Should get to suspended soon because send() is blocked */ ErrorInfo suspendReason = channelWaiter.waitFor(ChannelState.suspended); - assertEquals("Verify the suspended event contains the detach reason", 91200, suspendReason.code); + assertEquals("Verify the suspended event contains the detach reason", 90007, suspendReason.code); /* Unblock send(), and expect a transition to attached */ mockTransport.allowSend(); @@ -1825,7 +2370,7 @@ public void onError(ErrorInfo reason) { /* wait until the listener is called */ while(listenerError[0] == null) { - try { listenerError.wait(); } catch(InterruptedException e) {} + try { listenerError.wait(); } catch(InterruptedException ignored) {} } } @@ -1883,12 +2428,162 @@ public void no_messages_when_channel_state_not_attached() { } } - class DetachingProtocolListener implements DebugOptions.RawProtocolListener { + /* + * Checks that the DETACHED message sent by the server when a channel is released is dropped. + */ + @Test + public void detach_message_to_released_channel_is_dropped() throws AblyException { + AblyRealtime ably = null; + long oldRealtimeTimeout = Defaults.realtimeRequestTimeout; + final String channelName = "detach_message_to_released_channel_is_dropped"; + + try { + DebugOptions opts = createOptions(testVars.keys[0].keyStr); + Helpers.RawProtocolMonitor monitor = Helpers.RawProtocolMonitor.createReceiver(ProtocolMessage.Action.detached); + opts.protocolListener = monitor; + + /* Make test faster */ + Defaults.realtimeRequestTimeout = 1000; + opts.channelRetryTimeout = 1000; + + ably = new AblyRealtime(opts); + Channel channel = ably.channels.get(channelName); + channel.attach(); + (new ChannelWaiter(channel)).waitFor(ChannelState.attached); + + // Listen for detach messages and release the channel + ably.channels.release(channelName); + monitor.waitForRecv(1, 10000); + + assertFalse(ably.channels.containsKey("messages_to_non_existent_channels_are_dropped")); + } finally { + if (ably != null) + ably.close(); + Defaults.realtimeRequestTimeout = oldRealtimeTimeout; + } + } + + /* + * Spec: RTN11d + * Checks that all channels become if the state is CLOSED transitions all the channels to + * INITIALIZED and unsets: + * - RealtimeChannel.errorReason + * - Connection.errorReason + * - msgSerial + */ + @Test + public void connect_on_closed_client_should_reinitialize_channels() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + try (AblyRealtime ably = new AblyRealtime(opts)) { + + /* wait until connected */ + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + assertEquals("Verify connected state reached", ably.connection.state, ConnectionState.connected); + + /* create a channel and attach */ + final Channel channel = ably.channels.get("channel"); + channel.attach(); + new ChannelWaiter(channel).waitFor(ChannelState.attached); + assertEquals("Verify attached state reached", channel.state, ChannelState.attached); + + /* push a message to increase msgSerial */ + channel.publish("test", "test"); + assertEquals(1, ably.connection.connectionManager.msgSerial); + + ably.close(); + new ChannelWaiter(channel).waitFor(ChannelState.detached); + assertEquals(ConnectionState.closed, ably.connection.state); + assertEquals(1, ably.connection.connectionManager.msgSerial); + + ably.connect(); + + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + assertEquals(ChannelState.initialized, channel.state); + + assertNull(channel.reason); + assertNull(ably.connection.reason); + assertEquals(ChannelState.initialized, channel.state); + assertEquals(0, ably.connection.connectionManager.msgSerial); + } + } + + /* + * Spec: RTN11b + * Checks that all channels become if the state is CLOSING transitions all the channels to + * INITIALIZED and unsets: + * - RealtimeChannel.errorReason + * - Connection.errorReason + * - msgSerial + */ + @Test + public void connect_on_closing_client_should_reinitialize_channels() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + try (AblyRealtime ably = new AblyRealtime(opts)) { + + /* wait until connected */ + (new ConnectionWaiter(ably.connection)).waitFor(ConnectionState.connected); + assertEquals("Verify connected state reached", ably.connection.state, ConnectionState.connected); + + /* create a channel and attach */ + final Channel channel = ably.channels.get("channel"); + channel.attach(); + new ChannelWaiter(channel).waitFor(ChannelState.attached); + assertEquals("Verify attached state reached", channel.state, ChannelState.attached); + + /* push a message to increase msgSerial */ + channel.publish("test", "test"); + assertEquals(1, ably.connection.connectionManager.msgSerial); + + List observedChannelStates = new ArrayList<>(); + channel.on(stateChange -> observedChannelStates.add(stateChange.current)); + + List observedConnectionStates = new ArrayList<>(); + ably.connection.on(stateChange -> observedConnectionStates.add(stateChange.current)); + + ably.close(); + ably.connect(); + + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.closing); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + + assertEquals(List.of(ConnectionState.closing, ConnectionState.connecting, ConnectionState.connected), observedConnectionStates); + assertEquals(ChannelState.initialized, channel.state); + + channel.attach(); + new ChannelWaiter(channel).waitFor(ChannelState.attached); + + assertNull(channel.reason); + assertEquals(0, ably.connection.connectionManager.msgSerial); + assertEquals(List.of(ChannelState.detached, ChannelState.initialized, ChannelState.attaching, ChannelState.attached), observedChannelStates); + } + } + + /** + * This test ensures that when the connection is manually triggered, the channel can successfully + * transition to the attached state without interference or rewriting of its immediate attach action. + */ + @Test + public void connect_should_not_rewrite_immediate_attach() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + try (AblyRealtime ably = new AblyRealtime(opts)) { + ably.close(); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.closed); + assertEquals("Verify closed state reached", ConnectionState.closed, ably.connection.state); + /* create a channel connect and attach */ + final Channel channel = ably.channels.get("channel"); + ably.connect(); + channel.attach(); + new ChannelWaiter(channel).waitFor(ChannelState.attached); + assertEquals("Verify attached state reached", ChannelState.attached, channel.state); + } + } + + static class DetachingProtocolListener implements DebugOptions.RawProtocolListener { public Channel theChannel; boolean messageReceived; - public DetachingProtocolListener() { + DetachingProtocolListener() { messageReceived = false; } diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectFailTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectFailTest.java index 8709da80b..65133d4cd 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectFailTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectFailTest.java @@ -1,21 +1,9 @@ package io.ably.lib.test.realtime; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; - +import io.ably.lib.debug.DebugOptions; import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; import io.ably.lib.realtime.ConnectionEvent; import io.ably.lib.realtime.ConnectionState; @@ -25,14 +13,33 @@ import io.ably.lib.rest.Auth.TokenCallback; import io.ably.lib.rest.Auth.TokenDetails; import io.ably.lib.rest.Auth.TokenParams; +import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.Helpers.ConnectionWaiter; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.util.MockWebsocketFactory; import io.ably.lib.transport.Defaults; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.ProtocolMessage; -import io.ably.lib.util.Log; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import static io.ably.lib.test.common.Helpers.assertTimeoutBetween; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RealtimeConnectFailTest extends ParameterizedTest { @@ -68,7 +75,8 @@ public void connect_fail_notfound_error() throws AblyException { public void connect_fail_authorized_error() throws AblyException { AblyRealtime ably = null; try { - ClientOptions opts = createOptions(testVars.appId + ".invalid_key_id:invalid_key_value"); + String keyId = testVars.keys[0].keyName.split("\\.")[1]; + ClientOptions opts = createOptions(testVars.appId + "." + keyId + ":invalid_key_value"); ably = new AblyRealtime(opts); ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); @@ -131,6 +139,24 @@ public void connect_fail_suspended() { } } + @Test + public void connect_after_suspend_should_clean_msg_serial() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.disconnectedRetryTimeout = Integer.MAX_VALUE; + opts.suspendedRetryTimeout = Integer.MAX_VALUE; + try (AblyRealtime ably = new AblyRealtime(opts)) { + ConnectionWaiter waiter = new ConnectionWaiter(ably.connection); + waiter.waitFor(ConnectionState.connecting); + ably.connection.connectionManager.requestState(ConnectionState.suspended); + waiter.waitFor(ConnectionState.suspended); + ably.connection.connectionManager.msgSerial = 100; + assertEquals("Verify suspended state reached", ConnectionState.suspended, ably.connection.state); + ably.connect(); + waiter.waitFor(ConnectionState.connected); + assertEquals(0, ably.connection.connectionManager.msgSerial); + } + } + /** * Verify that the connection in the disconnected state (after attempts to * connect to a non-existent ws host) allows an immediate explicit connect @@ -304,6 +330,7 @@ public void onConnectionStateChanged(ConnectionStateChange state) { * Verify that the connection fails when attempting to recover with a * malformed connection id */ + @Ignore("FIXME: fix exception") @Test public void connect_invalid_recover_fail() { AblyRealtime ably = null; @@ -333,15 +360,16 @@ public void connect_unknown_recover_fail() { AblyRealtime ably = null; try { ClientOptions opts = createOptions(testVars.keys[0].keyStr); - String recoverConnectionId = "0123456789abcdef-99"; - opts.recover = recoverConnectionId + ":0"; + String recoveryKey = + "{\"connectionKey\":\"0123456789abcdef-99\",\"msgSerial\":5,\"channelSerials\":{\"channel1\":\"98\",\"channel2\":\"32\",\"channel3\":\"09\"}}"; + opts.recover = recoveryKey; ably = new AblyRealtime(opts); ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); ErrorInfo connectedError = connectionWaiter.waitFor(ConnectionState.connected); assertEquals("Verify connected state is reached", ConnectionState.connected, ably.connection.state); assertNotNull("Verify error is returned", connectedError); - assertEquals("Verify correct error code is given", 80008, connectedError.code); - assertFalse("Verify new connection id is assigned", recoverConnectionId.equals(ably.connection.key)); + assertEquals("Verify correct error code is given", 80018, connectedError.code); + assertFalse("Verify new connection id is assigned", "0123456789abcdef-99".equals(ably.connection.key)); } catch (AblyException e) { e.printStackTrace(); fail("init0: Unexpected exception instantiating library"); @@ -408,13 +436,11 @@ public void onError(ErrorInfo reason) { */ @Test public void connect_reauth_failure_state_flow_test() { - try { - AblyRest ablyRest = null; ClientOptions opts = createOptions(testVars.keys[0].keyStr); - ablyRest = new AblyRest(opts); - final TokenDetails tokenDetails = ablyRest.auth.requestToken(new TokenParams() {{ ttl = 8000L; }}, null); + AblyRest ablyRest = new AblyRest(opts); + final TokenDetails tokenDetails = ablyRest.auth.requestToken(new TokenParams() {{ ttl = 2000L; }}, null); assertNotNull("Expected token value", tokenDetails.token); final ArrayList stateHistory = new ArrayList<>(); @@ -423,31 +449,14 @@ public void connect_reauth_failure_state_flow_test() { optsForRealtime.authCallback = new TokenCallback() { @Override public Object getTokenRequest(TokenParams params) throws AblyException { - // return already expired token + // always return same token return tokenDetails; } }; optsForRealtime.tokenDetails = tokenDetails; final AblyRealtime ablyRealtime = new AblyRealtime(optsForRealtime); - ablyRealtime.connection.on(ConnectionState.connected, new ConnectionStateListener() { - @Override - public void onConnectionStateChanged(ConnectionStateChange state) { - /* To go quicker into a disconnected state we use a - * smaller value for maxIdleInterval - */ - try { - Field field = ablyRealtime.connection.connectionManager.getClass().getDeclaredField("maxIdleInterval"); - field.setAccessible(true); - field.setLong(ablyRealtime.connection.connectionManager, 5000L); - } catch (NoSuchFieldException|IllegalAccessException e) { - fail("Unexpected exception in checking connectionStateTtl"); - } - } - }); - (new ConnectionWaiter(ablyRealtime.connection)).waitFor(ConnectionState.connected); - // TODO: improve by collecting and testing also auth attempts final List correctHistory = Arrays.asList( ConnectionState.disconnected, ConnectionState.connecting, @@ -465,7 +474,7 @@ public void onConnectionStateChanged(ConnectionStateChange state) { if (state.current == ConnectionState.disconnected) { disconnections++; if (disconnections == maxDisconnections) { - assertTrue("Verifying state change history", stateHistory.equals(correctHistory)); + assertEquals(correctHistory, stateHistory); ablyRealtime.close(); } } @@ -488,16 +497,14 @@ public void onConnectionStateChanged(ConnectionStateChange state) { public void connect_auth_failure_and_suspend_test() { AblyRealtime ablyRealtime = null; AblyRest ablyRest = null; - int oldDisconnectTimeout = Defaults.TIMEOUT_DISCONNECT; try { /* Make test faster */ - Defaults.TIMEOUT_DISCONNECT = 1000; - final int[] numberOfAuthCalls = new int[] {0}; final boolean[] reachedFinalState = new boolean[] {false}; ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.disconnectedRetryTimeout = 1000; ablyRest = new AblyRest(opts); final TokenDetails tokenDetails = ablyRest.auth.requestToken(new TokenParams() {{ ttl = 5000L; }}, null); @@ -518,7 +525,7 @@ public Object getTokenRequest(TokenParams params) throws AblyException { ablyRealtime.connection.on(new ConnectionStateListener() { @Override public void onConnectionStateChanged(ConnectionStateChange state) { - System.out.println(String.format("New state: %s", state.current)); + System.out.format(Locale.ROOT, "New state: %s\n", state.current); synchronized (reachedFinalState) { reachedFinalState[0] = state.current == ConnectionState.closed || state.current == ConnectionState.suspended || @@ -539,10 +546,195 @@ public void onConnectionStateChanged(ConnectionStateChange state) { e.printStackTrace(); fail("init0: Unexpected exception instantiating library"); } finally { - Defaults.TIMEOUT_DISCONNECT = oldDisconnectTimeout; if (ablyRealtime != null) ablyRealtime.close(); } } + /** + * Connect to unknown host and check if timer time is jittered + * Spec: RTB1 + */ + @Test + public void disconnect_retry_connection_timeout_jitter() { + + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.realtimeHost = "non.existent.host"; + opts.environment = null; + opts.disconnectedRetryTimeout = 5000; // Disconnected retry timeout set to 5 seconds. + ably = new AblyRealtime(opts); + + final ArrayList disconnectedRetryTimeouts = new ArrayList<>(); + + new Helpers.ConnectionWaiter(ably.connection).waitFor(ConnectionState.connecting); + + do { + new Helpers.ConnectionWaiter(ably.connection).waitFor(ConnectionState.disconnected); + Instant start = Instant.now(); + new Helpers.ConnectionWaiter(ably.connection).waitFor(ConnectionState.connecting); + Instant end = Instant.now(); + disconnectedRetryTimeouts.add(Duration.between(start, end).toMillis()); + } while (disconnectedRetryTimeouts.stream().reduce(0L, Long::sum) + 10000 < Defaults.connectionStateTtl); + + System.out.println("Generated retry timeout values => "); + System.out.println(String.join(",", disconnectedRetryTimeouts.stream().map(Object::toString).toArray(String[]::new))); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + // Add deviation of 50ms since Instant.now() is being calculated after connecting state is reached + assertTimeoutBetween(disconnectedRetryTimeouts.get(0).intValue(), 4000d, 5000d + 50); + assertTimeoutBetween(disconnectedRetryTimeouts.get(1).intValue(), 5333.33, 6666.66 + 50); + assertTimeoutBetween(disconnectedRetryTimeouts.get(2).intValue(), 6666.66, 8333.33 + 50); + + for (int i = 3; i < disconnectedRetryTimeouts.size(); i++) + { + assertTimeoutBetween(disconnectedRetryTimeouts.get(i).intValue(), 8000d, 10000d + 50); + } + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if (ably != null) + ably.close(); + } + } + + /** + * Connect and check if timer time is jittered + * Spec: RTB1 + */ + @Test + public void disconnect_retry_channel_timeout_jitter_after_first_detach() { + AblyRealtime ably = null; + try { + DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); + opts.channelRetryTimeout = 5000; // channel retry timeout set to 5 seconds. + opts.realtimeRequestTimeout = 100; // quickly timeout and transition to suspended + opts.transportFactory = new MockWebsocketFactory(); + ((MockWebsocketFactory)opts.transportFactory).allowSend(); + fillInOptions(opts); + + ably = new AblyRealtime(opts); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + + /* Block send() */ + ((MockWebsocketFactory)opts.transportFactory).blockSend(); + + Channel channel = ably.channels.get("failed_attach"); + Helpers.ChannelWaiter channelWaiter = new Helpers.ChannelWaiter(channel); + channel.attach(); + channelWaiter.waitFor(ChannelState.attaching); + + final ArrayList channelRetryTimeouts = new ArrayList<>(); + + /* Inject detached message as if from the server */ + ProtocolMessage detachedMessage = new ProtocolMessage() {{ + action = Action.detached; + channel = "failed_attach"; + error = new ErrorInfo("Test error", 12345); + }}; + ably.connection.connectionManager.onMessage(null, detachedMessage); + + do + { + channelWaiter.waitFor(ChannelState.suspended); + Instant start = Instant.now(); + + channelWaiter.waitFor(ChannelState.attaching); + Instant end = Instant.now(); + + channelRetryTimeouts.add(Duration.between(start, end).toMillis()); + } while (channelRetryTimeouts.size() < 8); // channel keeps retrying attach indefinitely, limit the number of retries. + + System.out.println("Generated retry timeout values => "); + System.out.println(String.join(",", channelRetryTimeouts.stream().map(Object::toString).toArray(String[]::new))); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + // Add deviation of 50ms since Instant.now() is being calculated after connecting state is reached + assertTimeoutBetween(channelRetryTimeouts.get(0).intValue(), 4000d, 5000d + 50); + assertTimeoutBetween(channelRetryTimeouts.get(1).intValue(), 5333.33, 6666.66 + 50); + assertTimeoutBetween(channelRetryTimeouts.get(2).intValue(), 6666.66, 8333.33 + 50); + + for (int i = 3; i < channelRetryTimeouts.size(); i++) + { + assertTimeoutBetween(channelRetryTimeouts.get(i).intValue(), 8000d, 10000d + 50); + } + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if (ably != null) + ably.close(); + } + } + + @Test + public void disconnect_retry_channel_timeout_jitter_after_consistent_detach() { + AblyRealtime ably = null; + try { + DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); + opts.channelRetryTimeout = 5000; // channel retry timeout set to 5 seconds., no realtimeRequestTimeout is set. + opts.transportFactory = new MockWebsocketFactory(); + ((MockWebsocketFactory)opts.transportFactory).allowSend(); + fillInOptions(opts); + + ably = new AblyRealtime(opts); + new ConnectionWaiter(ably.connection).waitFor(ConnectionState.connected); + + /* Block send() */ + ((MockWebsocketFactory)opts.transportFactory).blockSend(); + + Channel channel = ably.channels.get("failed_attach"); + Helpers.ChannelWaiter channelWaiter = new Helpers.ChannelWaiter(channel); + channel.attach(); + channelWaiter.waitFor(ChannelState.attaching); + + final ArrayList channelRetryTimeouts = new ArrayList<>(); + + /* Inject detached message as if from the server */ + ProtocolMessage detachedMessage = new ProtocolMessage() {{ + action = Action.detached; + channel = "failed_attach"; + error = new ErrorInfo("Test error", 12345); + }}; + + do + { + ably.connection.connectionManager.onMessage(null, detachedMessage); + + channelWaiter.waitFor(ChannelState.suspended); + Instant start = Instant.now(); + + channelWaiter.waitFor(ChannelState.attaching); + Instant end = Instant.now(); + + channelRetryTimeouts.add(Duration.between(start, end).toMillis()); + } while (channelRetryTimeouts.size() < 8); // channel keeps retrying attach indefinitely, limit the number of retries. + + System.out.println("Generated retry timeout values => "); + System.out.println(String.join(",", channelRetryTimeouts.stream().map(Object::toString).toArray(String[]::new))); + + // Upper bound = min((retryAttempt + 2) / 3, 2) * initialTimeout + // Lower bound = 0.8 * Upper bound + // Add deviation of 50ms since Instant.now() is being calculated after connecting state is reached + assertTimeoutBetween(channelRetryTimeouts.get(0).intValue(), 4000d, 5000d + 50); + assertTimeoutBetween(channelRetryTimeouts.get(1).intValue(), 5333.33, 6666.66 + 50); + assertTimeoutBetween(channelRetryTimeouts.get(2).intValue(), 6666.66, 8333.33 + 50); + + for (int i = 3; i < channelRetryTimeouts.size(); i++) + { + assertTimeoutBetween(channelRetryTimeouts.get(i).intValue(), 8000d, 10000d + 50); + } + } catch (AblyException e) { + e.printStackTrace(); + fail("init0: Unexpected exception instantiating library"); + } finally { + if (ably != null) + ably.close(); + } + } + } diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectTest.java index 4c5ed455b..2e744f7df 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeConnectTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import org.junit.Ignore; import org.junit.Test; import io.ably.lib.debug.DebugOptions; @@ -80,6 +81,7 @@ public void connect_heartbeat() { * Perform a simple connect, close the connection, and verify that * the connection can be re-established by calling connect(). */ + @Ignore("FIXME: fix exception") @Test public void connect_after_close() { try { diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java index 3f8eb9a11..2f8acfd05 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeCryptoTest.java @@ -15,6 +15,7 @@ import javax.crypto.KeyGenerator; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.Timeout; @@ -32,7 +33,8 @@ import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; import io.ably.lib.util.Crypto; -import io.ably.lib.util.Crypto.ChannelCipher; +import io.ably.lib.util.Crypto.EncryptingChannelCipher; +import io.ably.lib.util.Crypto.DecryptingChannelCipher; import io.ably.lib.util.Crypto.CipherParams; public class RealtimeCryptoTest extends ParameterizedTest { @@ -45,6 +47,7 @@ public class RealtimeCryptoTest extends ParameterizedTest { * and publish an encrypted message on that channel using * the default cipher params */ + @Ignore("FIXME: fix exception") @Test public void single_send() { String channelName = "single_send_" + testParams.name; @@ -102,6 +105,7 @@ public void single_send() { * and publish an encrypted message on that channel using * a 256-bit key */ + @Ignore("FIXME: fix exception") @Test public void single_send_256() { String channelName = "single_send_256_" + testParams.name; @@ -117,7 +121,9 @@ public void single_send_256() { final CipherParams params = Crypto.getDefaultParams(key); /* create a channel */ - ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; this.cipherParams = params; }}; + ChannelOptions channelOpts = new ChannelOptions(); + channelOpts.encrypted = true; + channelOpts.cipherParams = params; final Channel channel = ably.channels.get(channelName, channelOpts); /* attach */ @@ -234,6 +240,7 @@ private void _multiple_send(String channelName, int messageCount, long delay) { } } + @Ignore("FIXME: fix exception") @Test public void multiple_send_2_200() { int messageCount = 2; @@ -241,6 +248,7 @@ public void multiple_send_2_200() { _multiple_send("multiple_send_binary_2_200_" + testParams.name, messageCount, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_20_100() { int messageCount = 20; @@ -253,6 +261,7 @@ public void multiple_send_20_100() { * and the text protocol. Publish an encrypted message on that channel using * the default cipher params and verify correct receipt. */ + @Ignore("FIXME: fix exception") @Test public void single_send_binary_text() { String channelName = "single_send_binary_text_" + testParams.name; @@ -269,9 +278,16 @@ public void single_send_binary_text() { final CipherParams params = Crypto.getDefaultParams(); /* create a channel */ - final ChannelOptions senderChannelOpts = new ChannelOptions() {{ encrypted = true; cipherParams = params; }}; + final ChannelOptions senderChannelOpts = new ChannelOptions(); + senderChannelOpts.encrypted = true; + senderChannelOpts.cipherParams = params; + final Channel senderChannel = sender.channels.get(channelName, senderChannelOpts); - final ChannelOptions receiverChannelOpts = new ChannelOptions() {{ encrypted = true; cipherParams = params; }}; + + final ChannelOptions receiverChannelOpts = new ChannelOptions(); + receiverChannelOpts.encrypted = true; + receiverChannelOpts.cipherParams = params; + final Channel receiverChannel = receiver.channels.get(channelName, receiverChannelOpts); /* attach */ @@ -330,6 +346,7 @@ public void single_send_binary_text() { * the default cipher params and verify that the decrypt failure * is noticed as bad recovered plaintext. */ + @Ignore("FIXME: fix exception") @Test public void single_send_key_mismatch() { AblyRealtime sender = null; @@ -403,6 +420,7 @@ public void single_send_key_mismatch() { * Publish an unencrypted message and verify that the receiving connection * does not attempt to decrypt it. */ + @Ignore("FIXME: fix exception") @Test public void single_send_unencrypted() { AblyRealtime sender = null; @@ -474,6 +492,7 @@ public void single_send_unencrypted() { * Publish an unencrypted message and verify that the receiving connection * does not attempt to decrypt it. */ + @Ignore("FIXME: fix exception") @Test public void single_send_encrypted_unhandled() { AblyRealtime sender = null; @@ -523,7 +542,7 @@ public void single_send_encrypted_unhandled() { ); /* check the the message payload is indicated as encrypted */ -// assertTrue("Verify correct message text received", messageWaiter.receivedMessages.get(0).data instanceof CipherData); +// assertTrue("Verify correct message text received", messageWaiter.receivedMessages.get(0).data instanceof CipherData); } catch (AblyException e) { e.printStackTrace(); @@ -544,6 +563,7 @@ public void single_send_encrypted_unhandled() { * - publish with an updated key on the tx connection and verify that it is not decrypted by the rx connection; * - publish with an updated key on the rx connection and verify connect receipt */ + @Ignore("FIXME: fix exception") @Test public void set_cipher_params() { AblyRealtime sender = null; @@ -559,9 +579,14 @@ public void set_cipher_params() { final CipherParams params1 = Crypto.getDefaultParams(); /* create a channel */ - ChannelOptions senderChannelOpts = new ChannelOptions() {{ encrypted = true; cipherParams = params1; }}; + ChannelOptions senderChannelOpts = new ChannelOptions(); + senderChannelOpts.encrypted = true; + senderChannelOpts.cipherParams = params1; final Channel senderChannel = sender.channels.get("set_cipher_params", senderChannelOpts); - ChannelOptions receiverChannelOpts = new ChannelOptions() {{ encrypted = true; cipherParams = params1; }}; + + ChannelOptions receiverChannelOpts = new ChannelOptions(); + receiverChannelOpts.encrypted = true; + receiverChannelOpts.cipherParams = params1; final Channel receiverChannel = receiver.channels.get("set_cipher_params", receiverChannelOpts); /* attach */ @@ -655,8 +680,9 @@ public void set_cipher_params() { * This test should be removed when we get rid of the methods * ChannelOptions.fromCipherKey(...) which are deprecated and have * been replaced with ChannelOptions.withCipherKey(...). - * @see TB3 */ + @Ignore("FIXME: fix exception") @Test @Deprecated public void channel_options_from_cipher_key() { @@ -724,8 +750,9 @@ public void channel_options_from_cipher_key() { /** * Test channel options creation with the cipher key. - * @see TB3 */ + @Ignore("FIXME: fix exception") @Test public void channel_options_with_cipher_key() { String channelName = "cipher_params_test_" + testParams.name; @@ -793,12 +820,13 @@ public void channel_options_with_cipher_key() { @Test public void encodeDecodeVariableSizesWithAES256CBC() throws NoSuchAlgorithmException, AblyException { final CipherParams params = Crypto.getParams("aes", generateNonce(32), generateNonce(16)); - final ChannelCipher cipher = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params; }}); + final Crypto.EncryptingChannelCipher encipher = Crypto.createChannelEncipher(params); + final Crypto.DecryptingChannelCipher decipher = Crypto.createChannelDecipher(params); for (int i=1; i<1000; i++) { final int size = RANDOM.nextInt(2000) + 1; final byte[] message = generateNonce(size); - final byte[] encrypted = cipher.encrypt(message); - final byte[] decrypted = cipher.decrypt(encrypted); + final byte[] encrypted = encipher.encrypt(message); + final byte[] decrypted = decipher.decrypt(encrypted); try { assertArrayEquals(message, decrypted); } catch (final AssertionError e) { @@ -828,6 +856,7 @@ private static String byteArrayToHexString(final byte[] bytes) { return new String(hexChars); } + @Ignore("FIXME: fix BadPaddingException") @Test public void decodeAppleLibrarySequences() throws NoSuchAlgorithmException, AblyException { final Map apple = new LinkedHashMap<>(); @@ -1053,12 +1082,13 @@ public void decodeAppleLibrarySequences() throws NoSuchAlgorithmException, AblyE // We have to create a new ChannelCipher for each message we encode because // cipher instances only use the IV we've supplied via CipherParams for the // encryption of the very first message. - final ChannelCipher cipher = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params; }}); + final EncryptingChannelCipher encipher = Crypto.createChannelEncipher(params); + final DecryptingChannelCipher decipher = Crypto.createChannelDecipher(params); final byte[] appleMessage = hexStringToByteArray(entry.getKey()); final byte[] appleEncrypted = hexStringToByteArray(entry.getValue()); - final byte[] encrypted = cipher.encrypt(appleMessage); - final byte[] decrypted = cipher.decrypt(appleEncrypted); + final byte[] encrypted = encipher.encrypt(appleMessage); + final byte[] decrypted = decipher.decrypt(appleEncrypted); try { assertArrayEquals(appleMessage, decrypted); @@ -1096,7 +1126,7 @@ private static byte[] generateNonce(final int size) { /** * Test Crypto.generateRandomKey. - * @see RSE2 + * @see RSE2 */ @Test public void generate_random_key() { diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeDeltaDecoderTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeDeltaDecoderTest.java index b3f721094..78d2ea026 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeDeltaDecoderTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeDeltaDecoderTest.java @@ -1,15 +1,6 @@ package io.ably.lib.test.realtime; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - -import java.util.Objects; - import com.google.gson.JsonObject; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; - import io.ably.lib.debug.DebugOptions; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; @@ -21,11 +12,18 @@ import io.ably.lib.transport.ITransport; import io.ably.lib.transport.WebSocketTransport; import io.ably.lib.types.ClientOptions; -import io.ably.lib.types.DeltaExtras; import io.ably.lib.types.Message; import io.ably.lib.types.MessageExtras; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.util.Base64Coder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; public class RealtimeDeltaDecoderTest extends ParameterizedTest { private static final String[] testData = new String[] { @@ -64,7 +62,7 @@ public void simple_delta_codec() { Message message = messageWaiter.receivedMessages.get(i); int messageIndex = Integer.parseInt(message.name); assertEquals("Verify message order", i, messageIndex); - assertEquals("Verify message data", true, testData[messageIndex].equals(message.data)); + assertEquals("Verify message data", testData[messageIndex], message.data); } } catch(Exception e) { fail(testName + ": Unexpected exception " + e.getMessage()); 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 ba320bf00..5c4a33d62 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 @@ -1,25 +1,22 @@ package io.ably.lib.test.realtime; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import fi.iki.elonen.NanoHTTPD; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; import java.io.IOException; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import fi.iki.elonen.NanoHTTPD; -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.test.common.ParameterizedTest; -import io.ably.lib.transport.Defaults; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ClientOptions; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; /** * Test for correct version headers passed to websocket @@ -84,14 +81,14 @@ public void realtime_websocket_param_test() { * Defaults.ABLY_VERSION_PARAM, as ultimately the request param has been derived from those values. */ assertEquals("Verify correct version", requestParameters.get("v"), - Collections.singletonList("1.2")); + Collections.singletonList("2")); - /* Spec RTN2g - * This test should not directly validate version against Defaults.ABLY_LIB_VERSION, nor - * Defaults.ABLY_LIB_PARAM, as ultimately the request param has been derived from those values. + /* Spec RSC7d3 + * This test should not directly validate version against Defaults.ABLY_AGENT_VERSION, nor + * Defaults.ABLY_AGENT_PARAM, as ultimately the request param has been derived from those values. */ - assertEquals("Verify correct lib version", requestParameters.get("lib"), - Collections.singletonList("java-1.2.3")); + assertEquals("Verify correct lib version", requestParameters.get("agent"), + Collections.singletonList("ably-java/1.2.54 jre/" + System.getProperty("java.version"))); /* Spec RTN2a */ assertEquals("Verify correct format", requestParameters.get("format"), diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeJWTTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeJWTTest.java index a82b0f078..1e648aa58 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeJWTTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeJWTTest.java @@ -1,27 +1,43 @@ package io.ably.lib.test.realtime; -import static org.junit.Assert.*; - import io.ably.lib.debug.DebugOptions; import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpCore; -import io.ably.lib.http.HttpCore.*; +import io.ably.lib.http.HttpCore.ResponseHandler; import io.ably.lib.http.HttpHelpers; -import io.ably.lib.test.common.Setup.Key; -import io.ably.lib.util.Log; -import org.junit.Before; -import org.junit.Test; - -import io.ably.lib.types.*; -import io.ably.lib.realtime.*; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.realtime.CompletionListener; +import io.ably.lib.realtime.ConnectionEvent; +import io.ably.lib.realtime.ConnectionState; +import io.ably.lib.realtime.ConnectionStateListener; import io.ably.lib.rest.AblyRest; -import io.ably.lib.rest.Auth.*; -import io.ably.lib.test.common.Helpers.*; +import io.ably.lib.rest.Auth.TokenCallback; +import io.ably.lib.rest.Auth.TokenParams; +import io.ably.lib.test.common.Helpers.ChannelWaiter; +import io.ably.lib.test.common.Helpers.ConnectionWaiter; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.common.Setup.Key; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.Param; +import io.ably.lib.types.ProtocolMessage; +import org.junit.Ignore; +import org.junit.Test; import java.io.UnsupportedEncodingException; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RealtimeJWTTest extends ParameterizedTest { @@ -70,6 +86,7 @@ public void auth_clientid_match_the_one_requested_in_jwt() { * Request a JWT with subscribe-only capabilities * Verifies that publishing on a channel fails */ + @Ignore("FIXME: fix exception") @Test public void auth_jwt_with_subscribe_only_capability() { try { @@ -288,7 +305,9 @@ public Object getTokenRequest(TokenParams params) throws AblyException { @Override public Object handleResponse(HttpCore.Response response, ErrorInfo error) throws AblyException { try { - callbackCalled.add(true); + synchronized (tokens) { + callbackCalled.add(true); + } resultToken[0] = new String(response.body, "UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); @@ -314,11 +333,14 @@ public void onRawConnect(String url) { } public void onRawMessageSend(ProtocolMessage message) { } @Override public void onRawMessageRecv(ProtocolMessage message) { - if (message.action == ProtocolMessage.Action.auth) { - authMessages[0] = true; + synchronized (tokens) { + if (message.action == ProtocolMessage.Action.auth) { + authMessages[0] = true; + } } } }; + final AblyRealtime ablyRealtime = new AblyRealtime(options); /* Once connected for the first time capture the assigned token and @@ -326,9 +348,9 @@ public void onRawMessageRecv(ProtocolMessage message) { ablyRealtime.connection.once(ConnectionEvent.connected, new ConnectionStateListener() { @Override public void onConnectionStateChanged(ConnectionStateChange stateChange) { - assertTrue("Callback not called the first time", callbackCalled.get(0)); - assertEquals("State is not connected", ConnectionState.connected, stateChange.current); synchronized (tokens) { + assertTrue("Callback not called the first time", callbackCalled.get(0)); + assertEquals("State is not connected", ConnectionState.connected, stateChange.current); tokens[0] = ablyRealtime.auth.getTokenDetails().token; } } @@ -348,12 +370,13 @@ public void onConnectionStateChanged(ConnectionStateChange stateChange) { ablyRealtime.connection.on(ConnectionEvent.update, new ConnectionStateListener() { @Override public void onConnectionStateChanged(ConnectionStateChange state) { - assertTrue("Callback not called the second time", callbackCalled.get(1)); - assertEquals("Callback not called 2 times", callbackCalled.size(), 2); - assertNotEquals("Token should not be the same", tokens[0], ablyRealtime.auth.getTokenDetails().token); - assertTrue("Auth protocol message has not been received", authMessages[0]); - updateEvents[0] = true; - ablyRealtime.close(); + synchronized (tokens) { + assertTrue("Callback not called the second time", callbackCalled.get(1)); + assertNotEquals("Token should not be the same", ablyRealtime.auth.getTokenDetails().token, tokens[0]); + assertTrue("Auth protocol message has not been received", authMessages[0]); + updateEvents[0] = true; + ablyRealtime.close(); + } } }); diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeMessageTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeMessageTest.java index babd880c4..dff2b1711 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeMessageTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeMessageTest.java @@ -3,18 +3,29 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; - -import com.google.gson.*; +import java.util.concurrent.atomic.AtomicInteger; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import io.ably.lib.test.util.AblyCommonsReader; +import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.MessageAction; import io.ably.lib.types.MessageExtras; +import io.ably.lib.types.Param; import io.ably.lib.util.Serialisation; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.Timeout; @@ -36,7 +47,6 @@ import io.ably.lib.test.common.Helpers.MessageWaiter; import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.ParameterizedTest; -import io.ably.lib.test.common.Setup; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; import io.ably.lib.types.Callback; @@ -48,7 +58,7 @@ public class RealtimeMessageTest extends ParameterizedTest { - private static final String testMessagesEncodingFile = "ably-common/test-resources/messages-encoding.json"; + private static final String testMessagesEncodingFile = "test-resources/messages-encoding.json"; private static Gson gson = new Gson(); @Rule @@ -57,6 +67,7 @@ public class RealtimeMessageTest extends ParameterizedTest { /** * Connect to the service and attach, subscribe to an event, and publish on that channel */ + @Ignore("FIXME: fix exception") @Test public void single_send() { AblyRealtime ably = null; @@ -101,6 +112,7 @@ public void single_send() { * attach, subscribe to an event, publish on one * connection and confirm receipt on the other. */ + @Ignore("FIXME: fix exception") @Test public void single_send_noecho() { AblyRealtime txAbly = null; @@ -159,6 +171,7 @@ public void single_send_noecho() { * Get a channel and subscribe without explicitly attaching. * Verify that the channel reaches the attached state. */ + @Ignore("FIXME: fix exception") @Test public void subscribe_implicit_attach() { AblyRealtime ably = null; @@ -285,6 +298,7 @@ private void _multiple_send(String channelName, int messageCount, int msgSize, b * Test right and wrong channel states to publish messages * Tests RTL6c */ + @Ignore("FIXME: fix exception") @Test public void publish_channel_state() { AblyRealtime ably = null; @@ -390,6 +404,7 @@ private void _multiple_send_batch(String channelName, int messageCount, int batc } } + @Ignore("FIXME: fix exception") @Test public void multiple_send_10_1000_16_string() { int messageCount = 10; @@ -397,6 +412,7 @@ public void multiple_send_10_1000_16_string() { _multiple_send("multiple_send_10_1000_16_string_" + testParams.name, messageCount, 16, false, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_10_1000_16_binary() { int messageCount = 10; @@ -404,6 +420,7 @@ public void multiple_send_10_1000_16_binary() { _multiple_send("multiple_send_10_1000_16_binary_" + testParams.name, messageCount, 16, true, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_10_1000_512_string() { int messageCount = 10; @@ -411,6 +428,7 @@ public void multiple_send_10_1000_512_string() { _multiple_send("multiple_send_10_1000_512_string_" + testParams.name, messageCount, 512, false, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_10_1000_512_binary() { int messageCount = 10; @@ -418,6 +436,7 @@ public void multiple_send_10_1000_512_binary() { _multiple_send("multiple_send_10_1000_512_binary_" + testParams.name, messageCount, 512, true, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_20_200() { int messageCount = 20; @@ -425,6 +444,7 @@ public void multiple_send_20_200() { _multiple_send("multiple_send_20_200_" + testParams.name, messageCount, 256, true, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_200_50() { int messageCount = 200; @@ -432,6 +452,7 @@ public void multiple_send_200_50() { _multiple_send("multiple_send_binary_200_50_" + testParams.name, messageCount, 256, true, delay); } + @Ignore("FIXME: fix exception") @Test public void multiple_send_1000_10() { int messageCount = 1000; @@ -506,16 +527,11 @@ public void ensure_disconnect_with_error_does_not_move_to_failed() { } } + @Ignore("FIXME: fix exception") @Test public void messages_encoding_fixtures() { MessagesEncodingData fixtures; - try { - fixtures = (MessagesEncodingData) Setup.loadJson(testMessagesEncodingFile, MessagesEncodingData.class); - } catch(IOException e) { - fail(); - return; - } - + fixtures = AblyCommonsReader.read(testMessagesEncodingFile, MessagesEncodingData.class); AblyRealtime ably = null; try { ClientOptions opts = createOptions(testVars.keys[0].keyStr); @@ -570,15 +586,11 @@ public MessagesEncodingDataItem[] handleResponse(HttpCore.Response response, Err } } + @Ignore("FIXME: fix exception") @Test public void messages_msgpack_and_json_encoding_is_compatible() { MessagesEncodingData fixtures; - try { - fixtures = (MessagesEncodingData) Setup.loadJson(testMessagesEncodingFile, MessagesEncodingData.class); - } catch(IOException e) { - fail(); - return; - } + fixtures = AblyCommonsReader.read(testMessagesEncodingFile, MessagesEncodingData.class); // Publish each data type through raw JSON POST and retrieve through MsgPack and JSON. @@ -751,7 +763,7 @@ private void expectDataToMatch(MessagesEncodingDataItem fixtureMessage, Message String receivedDataHex = sb.toString(); assertEquals("Verify decoded message data", fixtureMessage.expectedHexValue, receivedDataHex); } else { - throw new RuntimeException(String.format("unhandled: %s", fixtureMessage.expectedType)); + throw new RuntimeException(String.format(Locale.ROOT, "unhandled: %s", fixtureMessage.expectedType)); } } @@ -778,34 +790,18 @@ static class MessagesEncodingDataItem { } @Test + public void reject_invalid_message_data() throws AblyException { HashMap data = new HashMap(); Message message = new Message("event", data); - Log.LogHandler originalLogHandler = Log.handler; - int originalLogLevel = Log.level; - Log.setLevel(Log.DEBUG); - final ArrayList capturedLog = new ArrayList<>(); - Log.setHandler(new Log.LogHandler() { - @Override - public void println(int severity, String tag, String msg, Throwable tr) { - capturedLog.add(new LogLine(severity, tag, msg, tr)); - } - }); - try { message.encode(null); + fail("reject_invalid_message_data: Expected AblyException to be thrown."); } catch(AblyException e) { assertEquals(null, message.encoding); assertEquals(data, message.data); - assertEquals(1, capturedLog.size()); - LogLine capturedLine = capturedLog.get(0); - assertTrue(capturedLine.tag.contains("ably")); - assertTrue(capturedLine.msg.contains("Message data must be either `byte[]`, `String` or `JSONElement`; implicit coercion of other types to String is deprecated")); } catch(Throwable t) { fail("reject_invalid_message_data: Unexpected exception"); - } finally { - Log.setHandler(originalLogHandler); - Log.setLevel(originalLogLevel); } } @@ -871,19 +867,15 @@ public void message_from_encoded_json_object() throws AblyException { * Refer Spec. TM3 * @throws AblyException */ + @Ignore("FIXME: fix exception") @Test public void messages_from_encoded_json_array() throws AblyException { JsonArray fixtures = null; MessagesData testMessages = null; - try { - testMessages = (MessagesData) Setup.loadJson(testMessagesEncodingFile, MessagesData.class); - JsonObject jsonObject = (JsonObject) Setup.loadJson(testMessagesEncodingFile, JsonObject.class); - //We use this as-is for decoding purposes. - fixtures = jsonObject.getAsJsonArray("messages"); - } catch(IOException e) { - fail(); - return; - } + testMessages = AblyCommonsReader.read(testMessagesEncodingFile, MessagesData.class); + JsonObject jsonObject = AblyCommonsReader.read(testMessagesEncodingFile, JsonObject.class); + //We use this as-is for decoding purposes. + fixtures = jsonObject.getAsJsonArray("messages"); Message[] decodedMessages = Message.fromEncodedArray(fixtures, null); for(int index = 0; index < decodedMessages.length; index++) { @@ -916,8 +908,9 @@ static class MessagesData { * Publish a message that contains extras of arbitrary creation. Validate that when we receive that message * echoed back from the service that those extras remain intact. * - * @see RSL6a2 + * @see RSL6a2 */ + @Ignore("FIXME: fix exception") @Test public void opaque_message_extras() throws AblyException { AblyRealtime ably = null; @@ -966,4 +959,84 @@ public void opaque_message_extras() throws AblyException { } } } + + /** + * Check that important chat SDK fields are populated (serial, action, createdAt) + */ + @Test + public void should_have_serial_action_createdAt() throws AblyException { + ClientOptions opts = createOptions(testVars.keys[7].keyStr); + opts.clientId = "chat"; + try (AblyRealtime realtime = new AblyRealtime(opts)) { + final Channel channel = realtime.channels.get("foo::$chat::$chatMessages"); + CompletionWaiter msgComplete = new CompletionWaiter(); + channel.subscribe(message -> { + assertNotNull(message.serial); + assertNotNull(message.version); + assertNotNull(message.createdAt); + assertEquals(MessageAction.MESSAGE_CREATE, message.action); + assertEquals("chat.message", message.name); + assertEquals("hello world!", ((JsonObject)message.data).get("text").getAsString()); + msgComplete.onSuccess(); + }); + + CompletionWaiter attachListener = new CompletionWaiter(); + channel.attach(attachListener); + assertNull(attachListener.waitFor(1, 10_000)); + + /* publish to the channel */ + JsonObject chatMessage = new JsonObject(); + chatMessage.addProperty("text", "hello world!"); + realtime.request( + "POST", + "/chat/v2/rooms/foo/messages", + new Param[] { new Param("v", 3) }, + HttpUtils.requestBodyFromGson(chatMessage, opts.useBinaryProtocol), + null + ); + + // wait until we get message on the channel + assertNull(msgComplete.waitFor(1, 10_000)); + } + } + + @Test + public void should_not_duplicate_messages() throws Exception { + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + String testChannelName = "my-channel" + System.currentTimeMillis(); + try (AblyRest rest = new AblyRest(opts)) { + final io.ably.lib.rest.Channel channel = rest.channels.get(testChannelName); + + Message[] messages = new Message[] { + new Message("name", "message 1"), + new Message("name", "message 2"), + new Message("name", "message 3"), + }; + + channel.publish(messages); + } + + try (AblyRealtime realtime = new AblyRealtime(opts)) { + final ChannelOptions options = new ChannelOptions(); + options.params = new HashMap<>(); + options.params.put("rewind", "10"); + final Channel channel = realtime.channels.get(testChannelName, options); + final CompletionWaiter completionWaiter = new CompletionWaiter(); + final AtomicInteger counter = new AtomicInteger(); + + channel.subscribe(message -> { + int value = counter.incrementAndGet(); + if (value == 3) completionWaiter.onSuccess(); + }); + + completionWaiter.waitFor(); + + assertEquals("Should be exactly 3 messages", 3, counter.get()); + + Thread.sleep(1500); + + assertEquals("Should be exactly 3 messages even after 1.5 sec wait", 3, counter.get()); + } + } + } diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceHistoryTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceHistoryTest.java index 6a0e07f2d..85cc365f4 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceHistoryTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceHistoryTest.java @@ -37,6 +37,7 @@ import java.util.Locale; +@Ignore("FIXME: fix ably exception") public class RealtimePresenceHistoryTest extends ParameterizedTest { private static final String testClientId = "testClientId"; @@ -210,7 +211,7 @@ public void presencehistory_types_forward() { * Connect twice to the service, each using the default (binary) protocol. * Publish messages on one connection to a given channel; then attach * the second connection to the same channel and verify a complete message - * history can be obtained. + * history can be obtained. */ @Test public void presencehistory_second_channel() { @@ -684,7 +685,7 @@ public void presencehistory_time_b() { rtOpts.clientId = testClientId; ably = new AblyRealtime(rtOpts); String channelName = "persisted:presencehistory_time_b_" + testParams.name; - + /* create a channel */ final Channel channel = ably.channels.get(channelName); @@ -1033,7 +1034,7 @@ public void presencehistory_paginate_first_b() { * Connect twice to the service. * Publish messages on one connection to a given channel; while in progress, * attach the second connection to the same channel and verify a message - * history up to the point of attachment can be obtained. + * history up to the point of attachment can be obtained. */ @Test @Ignore("Fails due to issues in sandbox. See https://github.com/ably/realtime/issues/1845 for details.") diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java index f14c05c3f..85357acd2 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java @@ -1,26 +1,58 @@ package io.ably.lib.test.realtime; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.emptyCollectionOf; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.isOneOf; import static org.hamcrest.Matchers.not; -import static org.junit.Assert.*; - -import java.io.IOException; -import java.util.*; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import io.ably.lib.debug.DebugOptions; -import io.ably.lib.realtime.*; -import io.ably.lib.test.common.Setup; -import io.ably.lib.types.*; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelEvent; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.realtime.ChannelStateListener; +import io.ably.lib.realtime.CompletionListener; +import io.ably.lib.realtime.ConnectionEvent; +import io.ably.lib.realtime.ConnectionState; +import io.ably.lib.realtime.ConnectionStateListener; +import io.ably.lib.realtime.Presence; +import io.ably.lib.test.util.AblyCommonsReader; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Capability; +import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import io.ably.lib.types.PresenceMessage; +import io.ably.lib.types.ProtocolMessage; import io.ably.lib.util.Serialisation; import org.junit.Before; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.Timeout; @@ -37,13 +69,11 @@ import io.ably.lib.test.common.ParameterizedTest; import io.ably.lib.test.util.MockWebsocketFactory; import io.ably.lib.transport.ConnectionManager; -import io.ably.lib.transport.Defaults; import io.ably.lib.types.PresenceMessage.Action; -import io.ably.lib.util.Log; public class RealtimePresenceTest extends ParameterizedTest { - private static final String testMessagesEncodingFile = "ably-common/test-resources/presence-messages-encoding.json"; + private static final String testMessagesEncodingFile = "test-resources/presence-messages-encoding.json"; private static final String testClientId1 = "testClientId1"; private static final String testClientId2 = "testClientId2"; private Auth.TokenDetails token1; @@ -304,8 +334,7 @@ public void enter_leave_simple() { } finally { if(clientAbly1 != null) clientAbly1.close(); - if(testChannel != null) - testChannel.dispose(); + testChannel.dispose(); } } @@ -373,8 +402,7 @@ public void enter_enter_simple() { } finally { if(clientAbly1 != null) clientAbly1.close(); - if(testChannel != null) - testChannel.dispose(); + testChannel.dispose(); } } @@ -442,8 +470,7 @@ public void enter_update_simple() { } finally { if(clientAbly1 != null) clientAbly1.close(); - if(testChannel != null) - testChannel.dispose(); + testChannel.dispose(); } } @@ -512,8 +539,7 @@ public void enter_update_null() { } finally { if(clientAbly1 != null) clientAbly1.close(); - if(testChannel != null) - testChannel.dispose(); + testChannel.dispose(); } } @@ -1508,7 +1534,7 @@ public Presence.PresenceListener setMessageStack(List messageSt leavePresenceWaiter.waitFor(ably1.options.clientId, Action.leave); /* Validate that, - * - we received all actions + *- we received all actions */ assertThat(receivedMessageStack.size(), is(equalTo(4))); for (PresenceMessage message : receivedMessageStack) { @@ -1586,7 +1612,7 @@ public void onPresenceMessage(PresenceMessage message) { } catch(InterruptedException e) {} /* Validate that, - * - we received specific actions + *- we received specific actions */ assertThat(receivedMessageStack.size(), is(equalTo(3))); for (PresenceMessage message : receivedMessageStack) { @@ -1598,6 +1624,191 @@ public void onPresenceMessage(PresenceMessage message) { } } + /** + *

+ * Validates a client can subscribe to presence without implicit channel attach + * Refer Spec TB4, RTP6d, RTP6e + *

+ * @throws AblyException + */ + @Test + public void presence_subscribe_without_implicit_attach() { + String ablyChannel = "subscribe_" + testParams.name; + AblyRealtime ably = null; + try { + ClientOptions option1 = createOptions(testVars.keys[0].keyStr); + option1.clientId = "client1"; + ably = new AblyRealtime(option1); + + /* create a channel and set attachOnSubscribe to false */ + final Channel channel = ably.channels.get(ablyChannel); + ChannelOptions chOpts = new ChannelOptions(); + chOpts.attachOnSubscribe = false; + channel.setOptions(chOpts); + + List receivedPresenceMsg = Collections.synchronizedList(new ArrayList<>()); + + /* Check for all subscriptions without ATTACHING state */ + channel.presence.subscribe(m -> receivedPresenceMsg.add(true)); + assertEquals(ChannelState.initialized, channel.state); + + channel.presence.subscribe(Action.enter, m -> receivedPresenceMsg.add(true)); + assertEquals(ChannelState.initialized, channel.state); + + channel.presence.subscribe(EnumSet.of(Action.enter, Action.leave),m -> receivedPresenceMsg.add(true)); + assertEquals(ChannelState.initialized, channel.state); + + channel.attach(); + (new ChannelWaiter(channel)).waitFor(ChannelState.attached); + + channel.presence.enter("enter client1", null); + // Expecting 3 msg: one from the wildcard subscription and two from specific event subscription + Exception conditionError = new Helpers.ConditionalWaiter(). + wait(() -> receivedPresenceMsg.size() == 3, 5000); + assertNull(conditionError); + + receivedPresenceMsg.clear(); + channel.presence.leave(null); + // Expecting 2 msg: one from the wildcard subscription and one from specific event subscription + conditionError = new Helpers.ConditionalWaiter(). + wait(() -> receivedPresenceMsg.size() == 2, 5000); + assertNull(conditionError); + + } catch (AblyException e) { + e.printStackTrace(); + fail("presence_subscribe_without_implicit_attach: Unexpected exception"); + } finally { + if(ably != null) + ably.close(); + } + } + + /** + *

+ * Validates a client can subscribe to presence without implicit channel attach + * Refer Spec TB4, RTP6d, RTP6e + *

+ * @throws AblyException + */ + @Test + public void presence_subscribe_without_implicit_attach_and_completion_listener_throws_exception() throws AblyException { + String ablyChannel = "subscribe_" + testParams.name; + ClientOptions option1 = createOptions(testVars.keys[0].keyStr); + option1.clientId = "client1"; + try (AblyRealtime ably = new AblyRealtime(option1)) { + /* create a channel and set attachOnSubscribe to false */ + final Channel channel = ably.channels.get(ablyChannel); + ChannelOptions chOpts = new ChannelOptions(); + chOpts.attachOnSubscribe = false; + channel.setOptions(chOpts); + + // When completionWaiter passed with attachOnSubscribe=false, throws exception. + CompletionWaiter completionWaiter = new CompletionWaiter(); + try { + channel.presence.subscribe(m -> {}, completionWaiter); + } catch (AblyException e) { + assertEquals(400, e.errorInfo.statusCode); + assertEquals(40000, e.errorInfo.code); + assertThat(e.errorInfo.message, containsString("attachOnSubscribe=false doesn't expect attach completion callback")); + } + assertEquals(ChannelState.initialized, channel.state); + + } catch (AblyException e) { + e.printStackTrace(); + fail("presence_subscribe_without_implicit_attach: Unexpected exception"); + } + } + + /** + *

+ * Validates a client sending multiple presence updates when the channel is in the attaching + * state will have all messages sent once the channel attaches, and all listeners will be called. + *

+ * + */ + @Test + public void realtime_presence_update_multiple_queued_messages() throws AblyException { + /* Ably instance that will emit presence events */ + AblyRealtime ably1 = null; + /* Ably instance that will receive presence events */ + AblyRealtime ably2 = null; + + String channelName = "test.presence.subscribe.update_multiple_queued_messages" + System.currentTimeMillis(); + EnumSet actions = EnumSet.of(Action.update, Action.enter); + + try { + ClientOptions option1 = createOptions(testVars.keys[0].keyStr); + option1.clientId = "emitter client"; + ClientOptions option2 = createOptions(testVars.keys[0].keyStr); + option2.clientId = "receiver client"; + + ably1 = new AblyRealtime(option1); + ably2 = new AblyRealtime(option2); + + Channel channel1 = ably1.channels.get(channelName); + + Channel channel2 = ably2.channels.get(channelName); + channel2.attach(); + (new ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + CompletionWaiter messageCompletionListener = new CompletionWaiter(); + + final ArrayList receivedMessageStack = new ArrayList<>(); + channel2.presence.subscribe(actions, new Presence.PresenceListener() { + @Override + public void onPresenceMessage(PresenceMessage message) { + synchronized (receivedMessageStack) { + receivedMessageStack.add(message); + receivedMessageStack.notify(); + } + } + }); + + /* + Start emitting channel with ably client 1 (emitter) + + This is synchronized against the channel so that channel.setState cant mark + the channel as attached until we're done queueing up events. + */ + synchronized (channel1) { + channel1.presence.enter("Hello, #2!", messageCompletionListener); + channel1.presence.update("Lorem ipsum", messageCompletionListener); + channel1.presence.update("Dolor sit!", messageCompletionListener); + } + + /* Wait until receiver client (ably2) observes {@code Action.leave} + * is emitted from emitter client (ably1) + */ + try { + synchronized (receivedMessageStack) { + while (receivedMessageStack.size() == 0 || + !receivedMessageStack.get(receivedMessageStack.size()-1).clientId.equals(ably1.options.clientId) || + !receivedMessageStack.get(receivedMessageStack.size()-1).data.equals("Dolor sit!")) + receivedMessageStack.wait(); + } + } catch(InterruptedException ignored) {} + + /* Validate that, + *- we received specific actions + */ + assertThat(receivedMessageStack.size(), is(equalTo(3))); + for (PresenceMessage message : receivedMessageStack) { + assertTrue(actions.contains(message.action)); + } + + /* + * Validate that + * - our listeners are called within 10 seconds + */ + messageCompletionListener.waitFor(3, 10000); + assertTrue(messageCompletionListener.success); + + } finally { + if (ably1 != null) ably1.close(); + if (ably2 != null) ably2.close(); + } + } + /** *

* Validates a client can observe presence messages of other client, @@ -1663,7 +1874,7 @@ public Presence.PresenceListener setMessageStack(List messageSt waiter.waitFor(ably1.options.clientId, Action.leave); /* Validate that, - * - we received specific actions + *- we received specific actions */ assertThat(receivedMessageStack, is(not(empty()))); for (PresenceMessage message : receivedMessageStack) { @@ -2039,6 +2250,7 @@ public void realtime_presence_get_throws_when_channel_failed() throws AblyExcept * * Tests RTP17, RTP19, RTP19a, RTP5f, RTP6b */ + @Ignore("FIXME: fix exception") @Test public void realtime_presence_suspended_reenter() throws AblyException { AblyRealtime ably = null; @@ -2172,7 +2384,7 @@ public void onPresenceMessage(PresenceMessage message) { * Tests RTP2a, RTP2b1, RTP2b2, RTP2c, RTP2d, RTP2g, RTP18c, RTP6a features */ @Test - public void realtime_presence_map_test() throws AblyException { + public void realtime_presence_map_test() { AblyRealtime ably = null; try { ClientOptions opts = createOptions(testVars.keys[0].keyStr); @@ -2592,13 +2804,14 @@ public void onPresenceMessage(PresenceMessage message) { * Test channel state change effect on presence * Tests RTP5a, RTP5b, RTP5c3, RTP16b */ + @Ignore("FIXME: fix exception") @Test public void presence_state_change () { AblyRealtime ably = null; try { DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); fillInOptions(opts); - opts.autoConnect = false; /* to queue presence messages */ + opts.autoConnect = false; /* to queue presence messages */ final MockWebsocketFactory mockTransport = new MockWebsocketFactory(); opts.transportFactory = mockTransport; @@ -2819,7 +3032,7 @@ public void presence_enter_without_permission() throws AblyException { /* get first token */ Auth.TokenParams tokenParams = new Auth.TokenParams(); Capability capability = new Capability(); - capability.addResource(channelName, "publish"); /* no presence permission! */ + capability.addResource(channelName, "publish"); /* no presence permission! */ tokenParams.capability = capability.toString(); tokenParams.clientId = testClientId1; @@ -3169,8 +3382,6 @@ public void presence_get() throws AblyException, InterruptedException { final String channelName = "presence_get" + testParams.name; ClientOptions opts = createOptions(testVars.keys[0].keyStr); ably1 = new AblyRealtime(opts); - opts.autoConnect = false; - ably2 = new AblyRealtime(opts); Channel channel1 = ably1.channels.get(channelName); CompletionWaiter completionWaiter = new CompletionWaiter(); @@ -3178,6 +3389,8 @@ public void presence_get() throws AblyException, InterruptedException { channel1.presence.enterClient("2", null, completionWaiter); completionWaiter.waitFor(2); + opts.autoConnect = false; + ably2 = new AblyRealtime(opts); Channel channel2 = ably2.channels.get(channelName); PresenceWaiter waiter2 = new PresenceWaiter(channel2); @@ -3244,6 +3457,68 @@ public void presence_get() throws AblyException, InterruptedException { } } + /** + * Test Presence.get() + * check if parent channel is able to detect presence + * during intermittent detach cycles + */ + + public void checkMembersWithChannelPresence(Channel testChannel) throws AblyException { + PresenceMessage[] presenceMessages = testChannel.presence.get(true); + testChannel.detach(); + assertEquals("Members count with channel presence should be " + presenceMessages.length, presenceMessages.length, 1); + } + + @Test + public void test_consistent_presence_for_members() { + AblyRealtime clientAbly1 = null; + TestChannel testChannel = new TestChannel(); + try { + /* subscribe for presence events in the anonymous connection */ + PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel); + /* set up a connection with specific clientId */ + ClientOptions client1Opts = new ClientOptions() {{ + tokenDetails = token1; + clientId = testClientId1; + }}; + fillInOptions(client1Opts); + clientAbly1 = new AblyRealtime(client1Opts); + + (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected); + assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected); + + Channel client1Channel = clientAbly1.channels.get(testChannel.channelName); + client1Channel.attach(); + (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached); + assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached); + + String enterString = "Entering presence from child channel"; + + CompletionWaiter enterComplete = new CompletionWaiter(); + client1Channel.presence.enter(enterString, enterComplete); + enterComplete.waitFor(); + + presenceWaiter.waitFor(testClientId1, Action.enter); + assertNotNull(presenceWaiter.contains(testClientId1, Action.enter)); + assertEquals(presenceWaiter.receivedMessages.get(0).data, enterString); + + int parent_detach_cycle = 6; + for (int cycle = 0; cycle < parent_detach_cycle ; cycle++) { + Thread.sleep(1000); + checkMembersWithChannelPresence(testChannel.realtimeChannel); + } + + } catch(AblyException | InterruptedException e) { + e.printStackTrace(); + fail("Unexpected exception running test: " + e.getMessage()); + } finally { + if(clientAbly1 != null) + clientAbly1.close(); + if(testChannel != null) + testChannel.dispose(); + } + } + /** * Authenticate using wildcard token, initialize AblyRealtime so clientId is not known a priori, * call enter() without attaching first, start connection @@ -3360,15 +3635,11 @@ public void message_from_encoded_json_object() throws AblyException { public void messages_from_encoded_json_array() throws AblyException { JsonArray fixtures = null; MessagesData testMessages = null; - try { - testMessages = (MessagesData) Setup.loadJson(testMessagesEncodingFile, MessagesData.class); - JsonObject jsonObject = (JsonObject) Setup.loadJson(testMessagesEncodingFile, JsonObject.class); - //We use this as-is for decoding purposes. - fixtures = jsonObject.getAsJsonArray("messages"); - } catch(IOException e) { - fail(); - return; - } + testMessages = AblyCommonsReader.read(testMessagesEncodingFile, MessagesData.class); + JsonObject jsonObject = AblyCommonsReader.readAsJsonObject(testMessagesEncodingFile); + //We use this as-is for decoding purposes. + fixtures = jsonObject.getAsJsonArray("messages"); + PresenceMessage[] decodedMessages = PresenceMessage.fromEncodedArray(fixtures, null); for(int index = 0; index < decodedMessages.length; index++) { PresenceMessage testInputMsg = testMessages.messages[index]; diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeReauthTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeReauthTest.java index 349e89e36..4a7c14386 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeReauthTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeReauthTest.java @@ -1,19 +1,5 @@ package io.ably.lib.test.realtime; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.util.ArrayList; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; - import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; @@ -31,7 +17,19 @@ import io.ably.lib.types.Capability; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; -import io.ably.lib.util.Log; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.ArrayList; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * Created by VOstopolets on 8/26/16. diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeRecoverTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeRecoverTest.java index c8a97c478..59d08c2d4 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeRecoverTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeRecoverTest.java @@ -1,12 +1,6 @@ package io.ably.lib.test.realtime; import io.ably.lib.debug.DebugOptions; -import io.ably.lib.transport.ConnectionManager; -import io.ably.lib.transport.Defaults; -import io.ably.lib.transport.ITransport; -import io.ably.lib.transport.WebSocketTransport; -import io.ably.lib.types.ProtocolMessage; - import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; @@ -16,15 +10,22 @@ import io.ably.lib.test.common.Helpers.ConnectionWaiter; import io.ably.lib.test.common.Helpers.MessageWaiter; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.transport.ConnectionManager; +import io.ably.lib.transport.ITransport; +import io.ably.lib.transport.WebSocketTransport; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; - +import io.ably.lib.types.ProtocolMessage; +import org.junit.Ignore; import org.junit.Test; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.lessThan; -import static org.junit.Assert.*; -import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RealtimeRecoverTest extends ParameterizedTest { @@ -40,6 +41,7 @@ public class RealtimeRecoverTest extends ParameterizedTest { * on recover * Spec: RTN16a,RTN16b */ + @Ignore("FIXME: fix exception") @Test public void recover_disconnected() { AblyRealtime ablyRx = null, ablyTx = null, ablyRxRecover = null; @@ -75,7 +77,7 @@ public void recover_disconnected() { /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertTrue("Verify success from all message callbacks", errors.length == 0); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -100,7 +102,7 @@ public void recover_disconnected() { /* wait for the publish callback to be called */ errors = msgComplete2.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertTrue("Verify success from all message callbacks", errors.length == 0); /* establish a new rx connection with recover string, and wait for connection */ ClientOptions recoverOpts = createOptions(testVars.keys[0].keyStr); @@ -139,6 +141,7 @@ public void recover_disconnected() { * on recover * Spec: RTN16a,RTN16b */ + @Ignore("FIXME: fix exception") @Test public void recover_implicit_connect() { AblyRealtime ablyRx = null, ablyTx = null, ablyRxRecover = null; @@ -174,7 +177,7 @@ public void recover_implicit_connect() { /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertTrue("Verify success from all message callbacks", errors.length == 0); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -199,7 +202,7 @@ public void recover_implicit_connect() { /* wait for the publish callback to be called */ errors = msgComplete2.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertTrue("Verify success from all message callbacks", errors.length == 0); /* establish a new rx connection with recover string, and wait for connection */ ClientOptions recoverOpts = createOptions(testVars.keys[0].keyStr); @@ -233,6 +236,7 @@ public void recover_implicit_connect() { * Disconnect+suspend and then reconnect the send connection; verify that * each subsequent publish causes a CompletionListener call. */ + @Ignore("FIXME: fix exception") @Test public void recover_verify_publish() { AblyRealtime ablyRx = null, ablyTx = null; @@ -268,7 +272,7 @@ public void recover_verify_publish() { /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertTrue("Verify success from all message callbacks", errors.length == 0); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -310,7 +314,7 @@ public void recover_verify_publish() { System.out.println("*** published. About to wait for callbacks"); errors = msgComplete2.waitFor(); System.out.println("*** done"); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertTrue("Verify success from all message callbacks", errors.length == 0); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeResumeTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeResumeTest.java index 731987a89..068054528 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeResumeTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeResumeTest.java @@ -1,33 +1,41 @@ package io.ably.lib.test.realtime; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - import io.ably.lib.debug.DebugOptions; -import io.ably.lib.types.*; -import io.ably.lib.util.Log; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; - import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.ConnectionState; +import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.Helpers.ChannelWaiter; import io.ably.lib.test.common.Helpers.CompletionSet; import io.ably.lib.test.common.Helpers.ConnectionWaiter; import io.ably.lib.test.common.Helpers.MessageWaiter; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.util.MockWebsocketFactory; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.PresenceMessage; +import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.util.Log; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.HashMap; import java.util.List; +import java.util.Map; -public class RealtimeResumeTest extends ParameterizedTest { +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; - private static final String TAG = RealtimeResumeTest.class.getName(); +public class RealtimeResumeTest extends ParameterizedTest { @Rule public Timeout testTimeout = Timeout.seconds(60); @@ -44,6 +52,8 @@ public void resume_none() { try { ClientOptions opts = createOptions(testVars.keys[0].keyStr); ably = new AblyRealtime(opts); + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + connectionWaiter.waitFor(ConnectionState.connected); /* create and attach channel */ final Channel channel = ably.channels.get(channelName); @@ -52,34 +62,25 @@ public void resume_none() { (new ChannelWaiter(channel)).waitFor(ChannelState.attached); assertEquals("Verify attached state reached", channel.state, ChannelState.attached); - /* disconnect the connection, without closing, - /* suppressing automatic retries by the connection manager */ - System.out.println("Simulating dropped transport"); - try { - Method method = ably.connection.connectionManager.getClass().getDeclaredMethod("disconnectAndSuppressRetries"); - method.setAccessible(true); - method.invoke(ably.connection.connectionManager); - } catch (NoSuchMethodException|IllegalAccessException| InvocationTargetException e) { - fail("Unexpected exception in suppressing retries"); - } + new Helpers.MutableConnectionManager(ably).disconnectAndSuppressRetries(); + connectionWaiter.waitFor(ConnectionState.disconnected); /* reconnect the rx connection */ ably.connection.connect(); System.out.println("Waiting for reconnection"); - ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); connectionWaiter.waitFor(ConnectionState.connected); assertEquals("Verify connected state is reached", ConnectionState.connected, ably.connection.state); /* wait */ System.out.println("Got reconnection; waiting 2s"); - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + try { Thread.sleep(2000L); } catch(InterruptedException ignored) {} /* Check the channel is still attached. */ assertEquals("Verify channel still attached", channel.state, ChannelState.attached); } catch (AblyException e) { e.printStackTrace(); - fail("init0: Unexpected exception instantiating library"); + fail("Unexpected exception: "+e.getMessage()); } finally { if(ably != null) { ably.close(); @@ -94,6 +95,7 @@ public void resume_none() { * the connection continues to receive messages on attached * channels after reconnection. */ + @Ignore("FIXME: fix exception") @Test public void resume_simple() { AblyRealtime ablyTx = null; @@ -125,12 +127,12 @@ public void resume_simple() { CompletionSet msgComplete1 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_simple) " + i, msgComplete1.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -144,7 +146,7 @@ public void resume_simple() { ablyRx.connection.connectionManager.requestState(ConnectionState.disconnected); /* wait */ - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + try { Thread.sleep(2000L); } catch(InterruptedException ignored) {} /* reconnect the rx connection */ ablyRx.connection.connect(); @@ -153,12 +155,12 @@ public void resume_simple() { CompletionSet msgComplete2 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_simple) " + i, msgComplete2.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ errors = msgComplete2.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -184,6 +186,7 @@ public void resume_simple() { * verify that the messages sent whilst disconnected are delivered * on resume */ + @Ignore("FIXME: fix exception") @Test public void resume_disconnected() { AblyRealtime ablyTx = null; @@ -215,12 +218,12 @@ public void resume_disconnected() { CompletionSet msgComplete1 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_disconnected) " + i, msgComplete1.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -234,18 +237,18 @@ public void resume_disconnected() { ablyRx.connection.connectionManager.requestState(ConnectionState.disconnected); /* wait */ - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + try { Thread.sleep(2000L); } catch(InterruptedException ignored) {} /* publish next messages to the channel */ CompletionSet msgComplete2 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_disconnected) " + i, msgComplete2.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ errors = msgComplete2.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* reconnect the rx connection, and expect the messages to be delivered */ ablyRx.connection.connect(); @@ -269,6 +272,7 @@ public void resume_disconnected() { /** * Verify resume behaviour with multiple channels */ + @Ignore("FIXME: fix exception") @Test public void resume_multiple_channel() { AblyRealtime ablyTx = null; @@ -310,12 +314,12 @@ public void resume_multiple_channel() { for(int i = 0; i < messageCount; i++) { channelTx1.publish("test_event1", "Test message (resume_multiple_channel) " + i, msgComplete1.add()); channelTx2.publish("test_event2", "Test message (resume_multiple_channel) " + i, msgComplete1.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter1.waitFor(messageCount); @@ -332,19 +336,19 @@ public void resume_multiple_channel() { ablyRx.connection.connectionManager.requestState(ConnectionState.disconnected); /* wait */ - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + try { Thread.sleep(2000L); } catch(InterruptedException ignored) {} /* publish next messages to the channel */ CompletionSet msgComplete2 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx1.publish("test_event1", "Test message (resume_multiple_channel) " + i, msgComplete2.add()); channelTx2.publish("test_event2", "Test message (resume_multiple_channel) " + i, msgComplete2.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ errors = msgComplete2.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* reconnect the rx connection, and expect the messages to be delivered */ ablyRx.connection.connect(); @@ -371,6 +375,7 @@ public void resume_multiple_channel() { * Verify resume behaviour across disconnect periods covering * multiple subminute intervals */ + @Ignore("FIXME: fix exception") @Test public void resume_multiple_interval() { AblyRealtime ablyTx = null; @@ -402,12 +407,12 @@ public void resume_multiple_interval() { CompletionSet msgComplete1 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_multiple_interval) " + i, msgComplete1.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -421,18 +426,18 @@ public void resume_multiple_interval() { ablyRx.connection.connectionManager.requestState(ConnectionState.disconnected); /* wait */ - try { Thread.sleep(20000L); } catch(InterruptedException e) {} + try { Thread.sleep(20000L); } catch(InterruptedException ignored) {} /* publish next messages to the channel */ CompletionSet msgComplete2 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_multiple_interval) " + i, msgComplete2.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ errors = msgComplete2.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* reconnect the rx connection, and expect the messages to be delivered */ ablyRx.connection.connect(); @@ -459,6 +464,7 @@ public void resume_multiple_interval() { * Disconnect and then reconnect the send connection; verify that * each subsequent publish causes a CompletionListener call. */ + @Ignore("FIXME: fix exception") @Test public void resume_verify_publish() { AblyRealtime ablyTx = null; @@ -490,12 +496,12 @@ public void resume_verify_publish() { CompletionSet msgComplete1 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_simple) " + i, msgComplete1.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -507,17 +513,12 @@ public void resume_verify_publish() { * of the library, to simulate a dropped transport without * causing the connection itself to be disposed */ System.out.println("*** about to disconnect tx connection"); - /* suppress automatic retries by the connection manager */ - try { - Method method = ablyTx.connection.connectionManager.getClass().getDeclaredMethod("disconnectAndSuppressRetries"); - method.setAccessible(true); - method.invoke(ablyTx.connection.connectionManager); - } catch (NoSuchMethodException|IllegalAccessException|InvocationTargetException e) { - fail("Unexpected exception in suppressing retries"); - } + + new Helpers.MutableConnectionManager(ablyTx).disconnectAndSuppressRetries(); + (new ConnectionWaiter(ablyTx.connection)).waitFor(ConnectionState.disconnected); /* wait */ - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + try { Thread.sleep(2000L); } catch(InterruptedException ignored) {} /* reconnect the tx connection */ System.out.println("*** about to reconnect tx connection"); @@ -528,7 +529,7 @@ public void resume_verify_publish() { CompletionSet msgComplete2 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { channelTx.publish("test_event", "Test message (resume_simple) " + i, msgComplete2.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called. This never finishes if @@ -537,7 +538,7 @@ public void resume_verify_publish() { System.out.println("*** published. About to wait for callbacks"); errors = msgComplete2.waitFor(); System.out.println("*** done"); - assertTrue("Verify success from all message callbacks", errors.length == 0); + assertEquals("Verify success from all message callbacks", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -580,19 +581,16 @@ public void resume_publish_queue() { final Channel senderChannel = sender.channels.get(channelName); senderChannel.attach(); (new ChannelWaiter(senderChannel)).waitFor(ChannelState.attached); - assertEquals( - "The sender's channel should be attached", - senderChannel.state, ChannelState.attached - ); + assertEquals("The sender's channel should be attached", + senderChannel.state, ChannelState.attached); /* create and attach channel to recv on */ final Channel receiverChannel = receiver.channels.get(channelName); receiverChannel.attach(); (new ChannelWaiter(receiverChannel)).waitFor(ChannelState.attached); - assertEquals( - "The receiver's channel should be attached", - receiverChannel.state, ChannelState.attached - ); + assertEquals("The receiver's channel should be attached", + receiverChannel.state, ChannelState.attached); + /* subscribe */ MessageWaiter messageWaiter = new MessageWaiter(receiverChannel); @@ -600,21 +598,17 @@ public void resume_publish_queue() { CompletionSet msgComplete1 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { senderChannel.publish("test_event", "Test message (resume_publish_queue) " + i, msgComplete1.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* wait for the publish callback to be called */ ErrorInfo[] errors = msgComplete1.waitFor(); - assertTrue( - "First round of messages has errors", errors.length == 0 - ); + assertEquals("First round of messages has errors", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); - assertEquals( - "Did not receive the entire first round of messages", - messageWaiter.receivedMessages.size(), messageCount - ); + assertEquals("Did not receive the entire first round of messages", + messageWaiter.receivedMessages.size(), messageCount); messageWaiter.reset(); /* disconnect the sender, without closing; @@ -624,7 +618,7 @@ public void resume_publish_queue() { sender.connection.connectionManager.requestState(ConnectionState.disconnected); /* wait */ - try { Thread.sleep(2000L); } catch(InterruptedException e) {} + try { Thread.sleep(2000L); } catch(InterruptedException ignored) {} /* * publish further messages to the channel, which should be queued @@ -633,20 +627,16 @@ public void resume_publish_queue() { CompletionSet msgComplete2 = new CompletionSet(); for(int i = 0; i < messageCount; i++) { senderChannel.publish("queued_message_" + i, "Test queued message (resume_publish_queue) " + i, msgComplete2.add()); - try { Thread.sleep(delay); } catch(InterruptedException e){} + try { Thread.sleep(delay); } catch(InterruptedException ignored){} } /* reconnect the sender */ sender.connection.connect(); (new ConnectionWaiter(sender.connection)).waitFor(ConnectionState.connected); - /* wait for the publish callback to be called.*/ errors = msgComplete2.waitFor(); - assertTrue( - "Second round of messages (queued) has errors", - errors.length == 0 - ); + assertEquals("Second round of messages (queued) has errors", 0, errors.length); /* wait for the subscription callback to be called */ messageWaiter.waitFor(messageCount); @@ -657,10 +647,8 @@ public void resume_publish_queue() { received.size(), messageCount ); for(int i=0; i message.action == ProtocolMessage.Action.ack || + message.action == ProtocolMessage.Action.nack); + + for (int i = 0; i < 3; i++) { + senderChannel.publish("pending_queued_message_" + i, "Test pending queued messages " + i, + senderCompletion.add()); + } + assertEquals(sender.connection.connectionManager.getPendingMessages().size(),3); + + final String connectionId = sender.connection.id; + + new Helpers.MutableConnectionManager(sender).disconnectAndSuppressRetries(); + (new ConnectionWaiter(sender.connection)).waitFor(ConnectionState.disconnected); + + sender.connection.connectionManager.requestState(ConnectionState.disconnected); + (new ConnectionWaiter(sender.connection)).waitFor(ConnectionState.disconnected); + assertEquals("Connection must be disconnected", ConnectionState.disconnected, sender.connection.state); + + System.out.println("resume_publish_test: Disconnected"); + + //send 3 more messages while disconnected + for (int i = 0; i < 3; i++) { + senderChannel.publish("queued_message_" + i, "Test pending queued messages " + i, + senderCompletion.add()); + } + + //now let's unblock the ack nacks and reconnect + mockWebsocketFactory.blockReceiveProcessing(message -> false); + sender.connection.connect(); + (new ConnectionWaiter(sender.connection)).waitFor(ConnectionState.connected); + assertEquals("Connection must be connected", ConnectionState.connected, sender.connection.state); + //make sure connection id is a resume success + assertEquals("Connection id has changed", connectionId, sender.connection.id); + + //replace mock transport + transport = mockWebsocketFactory.getCreatedTransport(); + + (new ChannelWaiter(senderChannel)).waitFor(ChannelState.attached); + /* wait for the publish callback to be called.*/ + ErrorInfo[] senderErrors = senderCompletion.waitFor(); + assertEquals("Second round of send has errors", 0, senderErrors.length); + + assertEquals("Second round of messages has incorrect size", 6, transport.getPublishedMessages().size()); + //make sure they were sent with correct serials + for (int i = 0; i < transport.getPublishedMessages().size(); i++) { + ProtocolMessage protocolMessage = transport.getPublishedMessages().get(i); + assertEquals("Second round sent serial incorrect", Long.valueOf(i+3), protocolMessage.msgSerial); + } + + //make sure that pending queue is cleared + assertEquals("There are still pending messages in the queue", + sender.connection.connectionManager.getPendingMessages().size(), + 0); + + } catch (AblyException e) { + fail("Unexpected exception: "+e.getMessage()); + } finally { + if (sender != null) { + sender.close(); + } + } + } + + /** + * In case of resume failure verify that messages are being resent + * */ + @Test + public void resume_publish_resend_pending_messages_when_resume_failed() throws AblyException { + final String channelName = "sender_channel"; + final MockWebsocketFactory mockWebsocketFactory = new MockWebsocketFactory(); + final DebugOptions options = createOptions(testVars.keys[0].keyStr); + options.logLevel = Log.VERBOSE; + options.realtimeRequestTimeout = 2000L; + options.transportFactory = mockWebsocketFactory; + try(AblyRealtime ably = new AblyRealtime(options)) { + final long newTtl = 1000L; + final long newIdleInterval = 1000L; + + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + connectionWaiter.waitFor(ConnectionState.connected); + + Helpers.MutableConnectionManager connectionManager = new Helpers.MutableConnectionManager(ably); + connectionManager.setField("connectionStateTtl", newTtl); + connectionManager.setField("maxIdleInterval", newIdleInterval); + + final Channel senderChannel = ably.channels.get(channelName); + senderChannel.attach(); + (new ChannelWaiter(senderChannel)).waitFor(ChannelState.attached); + assertEquals( + "The sender's channel should be attached", + senderChannel.state, ChannelState.attached + ); + + MockWebsocketFactory.MockWebsocketTransport transport = mockWebsocketFactory.getCreatedTransport(); + CompletionSet senderCompletion = new CompletionSet(); + //send 3 successful messages + for (int i = 0; i < 3; i++) { + senderChannel.publish("non_pending messages" + i, "Test pending queued messages " + i, + senderCompletion.add()); + } + + /* wait for the publish callback to be called.*/ + ErrorInfo[] errors = senderCompletion.waitFor(); + assertEquals("First completion has errors", 0, errors.length); + + //assert that messages sent till now are sent with correct size and serials + assertEquals("First round of messages has incorrect size", 3, transport.getPublishedMessages().size()); + for (int i = 0; i < transport.getPublishedMessages().size(); i++) { + ProtocolMessage protocolMessage = transport.getPublishedMessages().get(i); + assertEquals("Sent serial incorrect", Long.valueOf(i), protocolMessage.msgSerial); + } + + //block acks nacks before send + mockWebsocketFactory.blockReceiveProcessing(message -> message.action == ProtocolMessage.Action.ack || + message.action == ProtocolMessage.Action.nack); + for (int i = 0; i < 3; i++) { + senderChannel.publish("pending_queued_message_" + i, "Test pending queued messages " + i, + senderCompletion.add()); + } + + final String firstConnectionId = ably.connection.id; + + connectionManager.disconnectAndSuppressRetries(); + connectionWaiter.waitFor(ConnectionState.disconnected); + assertEquals("Disconnected state was not reached", ConnectionState.disconnected, ably.connection.state); + + //send some more messages while disconnected + for (int i = 0; i < 3; i++) { + senderChannel.publish("queued_message_" + i, "Test pending queued messages " + i, + senderCompletion.add()); + } + //now let's unblock the ack nacks and reconnect + mockWebsocketFactory.blockReceiveProcessing(message -> false); + + /* We want this greater than newTtl + newIdleInterval */ + final long waitInDisconnectedState = 3000L; + /* Wait for the connection to go stale, then reconnect */ + try { + Thread.sleep(waitInDisconnectedState); + } catch (InterruptedException ignored) { + } + ably.connection.connect(); + connectionWaiter.waitFor(ConnectionState.connected); + assertEquals("Connected state was not reached", ConnectionState.connected, ably.connection.state); + //replace transport + transport = mockWebsocketFactory.getCreatedTransport(); + /* Verify the connection is new */ + assertNotNull(ably.connection.id); + assertNotEquals("Connection has the same id", firstConnectionId, ably.connection.id); + + // wait for channel to get attached + (new ChannelWaiter(senderChannel)).waitFor(ChannelState.attached); + assertEquals("Connection has the same id", ChannelState.attached, senderChannel.state); + + ErrorInfo[] resendErrors = senderCompletion.waitFor(); + assertEquals("Second round of messages (queued) has errors", 0, resendErrors.length); + + assertEquals("Second round of messages has incorrect size", 6, transport.getPublishedMessages().size()); + //make sure they were sent with reset serials + for (int i = 0; i < transport.getPublishedMessages().size(); i++) { + ProtocolMessage protocolMessage = transport.getPublishedMessages().get(i); + assertEquals("Sent serial incorrect", Long.valueOf(i), protocolMessage.msgSerial); + } + } + } + + /** + * In case of resume failure verify that presence messages are resent + * */ + @Test + public void resume_publish_reenter_when_resume_failed() throws AblyException { + final String channelName = "sender_channel"; + final MockWebsocketFactory mockWebsocketFactory = new MockWebsocketFactory(); + final DebugOptions options = createOptions(testVars.keys[0].keyStr); + final String[] clients = new String[]{"client1", "client2", "client3", "client4", "client5", + "client6", "client7", "client8", "client9"}; + + options.logLevel = Log.VERBOSE; + options.realtimeRequestTimeout = 2000L; + + options.transportFactory = mockWebsocketFactory; + try(AblyRealtime ably = new AblyRealtime(options)) { + + ConnectionWaiter connectionWaiter = new ConnectionWaiter(ably.connection); + connectionWaiter.waitFor(ConnectionState.connected); + + final long newTtl = 1000L; + final long newIdleInterval = 1000L; + + Helpers.MutableConnectionManager connectionManager = new Helpers.MutableConnectionManager(ably); + connectionManager.setField("connectionStateTtl", newTtl); + connectionManager.setField("maxIdleInterval", newIdleInterval); + + final Channel senderChannel = ably.channels.get(channelName); + senderChannel.attach(); + (new ChannelWaiter(senderChannel)).waitFor(ChannelState.attached); + assertEquals("The sender's channel should be attached", senderChannel.state, ChannelState.attached); + + MockWebsocketFactory.MockWebsocketTransport transport = mockWebsocketFactory.getCreatedTransport(); + CompletionSet presenceCompletion = new CompletionSet(); + //enter first three clients + for (int i = 0; i < 3; i++) { + senderChannel.presence.enterClient(clients[i],null,presenceCompletion.add()); + } + /* wait for the publish callback to be called.*/ + ErrorInfo[] errors = presenceCompletion.waitFor(); + assertEquals("Firstenter has errors", 0, errors.length); + + //assert that messages sent till now are sent with correct size and client ids + assertEquals("First round of presence messages have incorrect size", 3, + transport.getSentPresenceMessages().size()); + for (int i = 0; i < transport.getSentPresenceMessages().size(); i++) { + PresenceMessage presenceMessage = transport.getSentPresenceMessages().get(i); + assertEquals("Sent presence serial incorrect", clients[i], presenceMessage.clientId); + } + + //block acks nacks before send + mockWebsocketFactory.blockReceiveProcessing(message -> message.action == ProtocolMessage.Action.ack || + message.action == ProtocolMessage.Action.nack); + + //enter next 3 clients + for (int i = 3; i < 6; i++) { + senderChannel.presence.enterClient(clients[i],null,presenceCompletion.add()); + } + + final String firstConnectionId = ably.connection.id; + + connectionManager.disconnectAndSuppressRetries(); + connectionWaiter.waitFor(ConnectionState.disconnected); + assertEquals("Disconnected state was not reached", ConnectionState.disconnected, ably.connection.state); + + //enter last 3 clients while disconnected + for (int i = 6; i < 9; i++) { + senderChannel.presence.enterClient(clients[i],null,presenceCompletion.add()); + } + + /* We want this greater than newTtl + newIdleInterval */ + final long waitInDisconnectedState = 5000L; + /* Wait for the connection to go stale, then reconnect */ + try { + Thread.sleep(waitInDisconnectedState); + } catch (InterruptedException ignored) { + } + + //now let's unblock the ack nacks and reconnect + mockWebsocketFactory.blockReceiveProcessing(message -> false); + /* Wait for the connection to go stale, then reconnect */ + ably.connection.connect(); + connectionWaiter.waitFor(ConnectionState.connected); + assertEquals("Connected state was not reached", ConnectionState.connected, ably.connection.state); + //replace transport + transport = mockWebsocketFactory.getCreatedTransport(); + /* Verify the connection is new */ + assertNotNull(ably.connection.id); + assertNotEquals("Connection has the same id", firstConnectionId, ably.connection.id); + + System.out.println("presence_resume_test: First connection id:"+firstConnectionId); + System.out.println("presence_resume_test: Second connection id:"+ably.connection.id); + + // wait for channel to get attached + (new ChannelWaiter(senderChannel)).waitFor(ChannelState.attached); + assertEquals("Connection has the same id", ChannelState.attached, senderChannel.state); + + // presenceCompletion.add(); + ErrorInfo[] resendErrors = presenceCompletion.waitFor(); + for (ErrorInfo resendError : resendErrors) { + System.out.println("presence_resume_test: error "+resendError.message); + } + assertEquals("Second round of messages (queued) has errors", 0, resendErrors.length); + + for (PresenceMessage presenceMessage: transport.getSentPresenceMessages()) { + System.out.println("presence_resume_test: sent message with client: "+presenceMessage.clientId +" " + + " action:"+presenceMessage.action); + } + assertEquals("Second round of messages has incorrect size", 6, transport.getSentPresenceMessages().size()); + //make sure they were sent with correct client ids + final Map sentPresenceMap = new HashMap<>(); + for (PresenceMessage presenceMessage: transport.getSentPresenceMessages()){ + sentPresenceMap.put(presenceMessage.clientId, presenceMessage); + } + + for (int i = 3; i < 9; i++) { + assertTrue("Client id isn't there:" + clients[i], sentPresenceMap.containsKey(clients[i])); + } + } + } + + //RTL4j2 @Test public void resume_rewind_1 () { @@ -686,7 +1012,6 @@ public void resume_rewind_1 () String testName = "resume_rewind_1"; try { - ClientOptions common_opts = createOptions(testVars.keys[0].keyStr); sender = new AblyRealtime(common_opts); receiver1 = new AblyRealtime(common_opts); @@ -708,21 +1033,22 @@ public void onRawMessageRecv(ProtocolMessage message) {} }; receiver2 = new AblyRealtime(receiver2_opts); - Channel recever1_channel = receiver1.channels.get("[?rewind=1]" + testName); - Channel recever2_channel = receiver2.channels.get("[?rewind=1]" + testName); - Channel sender_channel = sender.channels.get(testName); + Channel receiver1_channel = receiver1.channels.get("[?rewind=1]" + testName); + Channel receiver2_channel = receiver2.channels.get("[?rewind=1]" + testName); + + Channel sender_channel = sender.channels.get(testName); sender_channel.attach(); (new ChannelWaiter(sender_channel)).waitFor(ChannelState.attached); sender_channel.publish("0", testMessage); /* subscribe 1*/ - MessageWaiter messageWaiter_1 = new MessageWaiter(recever1_channel); + MessageWaiter messageWaiter_1 = new MessageWaiter(receiver1_channel); messageWaiter_1.waitFor(1); assertEquals("Verify rewound message", testMessage, messageWaiter_1.receivedMessages.get(0).data); /* subscribe 2*/ - MessageWaiter messageWaiter_2 = new MessageWaiter(recever2_channel); + MessageWaiter messageWaiter_2 = new MessageWaiter(receiver2_channel); messageWaiter_2.waitFor(1, 7000); assertEquals("Verify no message received on attach_rewind", 0, messageWaiter_2.receivedMessages.size()); diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeSuite.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeSuite.java index e177269df..d292b960f 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeSuite.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeSuite.java @@ -15,6 +15,7 @@ @SuiteClasses({ ConnectionManagerTest.class, RealtimeHttpHeaderTest.class, + RealtimeAnnotationsTest.class, RealtimeAuthTest.class, RealtimeJWTTest.class, RealtimeReauthTest.class, diff --git a/lib/src/test/java/io/ably/lib/test/rest/HttpHeaderTest.java b/lib/src/test/java/io/ably/lib/test/rest/HttpHeaderTest.java index b227145f5..55e47003b 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/HttpHeaderTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/HttpHeaderTest.java @@ -1,21 +1,21 @@ package io.ably.lib.test.rest; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -import org.junit.AfterClass; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; - import fi.iki.elonen.NanoHTTPD; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Channel; import io.ably.lib.test.common.ParameterizedTest; -import io.ably.lib.transport.Defaults; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static io.ably.lib.transport.Defaults.ABLY_AGENT_VERSION; /** * Created by VOstopolets on 8/17/16. @@ -46,15 +46,12 @@ public static void tearDown() { } /** - * The header X-Ably-Lib: [lib][.optional variant]?-[version] + * The header Ably-Agent: [lib]/[version] * should be included in all REST requests to the Ably endpoint - * see {@link io.ably.lib.http.HttpUtils#ABLY_LIB_VERSION} + * see {@link io.ably.lib.transport.Defaults#ABLY_AGENT_PARAM} *

- * Spec: RSC7b, G4 + * Spec: RSC7d, G4, RSA7e2 *

- * - * Spec: RSC7a: Must have the header X-Ably-Version: 1.0 (or whatever the - * spec version is). */ @Test public void header_lib_channel_publish() { @@ -76,6 +73,7 @@ public void header_lib_channel_publish() { /* Get last headers */ Map headers = server.getHeaders(); + String expectedAblyAgentHeader = ABLY_AGENT_VERSION + " jre/" + System.getProperty("java.version"); /* Check header * This test should not directly validate version against Defaults.ABLY_VERSION, Defaults.ABLY_LIB_VERSION, @@ -83,18 +81,56 @@ public void header_lib_channel_publish() { * from those values. */ Assert.assertNotNull("Expected headers", headers); - Assert.assertEquals(headers.get("x-ably-version"), "1.2"); - Assert.assertEquals(headers.get("x-ably-lib"), "java-1.2.3"); + Assert.assertEquals(headers.get("x-ably-version"), "2"); + Assert.assertEquals(headers.get("ably-agent"), expectedAblyAgentHeader); + // RSA7e2 + Assert.assertNull("Shouldn't include 'x-ably-clientid' if `clientId` is not specified", headers.get("x-ably-clientid")); } catch (AblyException e) { e.printStackTrace(); Assert.fail("header_lib_channel_publish: Unexpected exception"); } } + /** + * The header `X-Ably-ClientId` + * should be included in all REST requests to the Ably endpoint + * if {@link ClientOptions#clientId} is specified + *

+ * Spec: RSA7e2 + *

+ */ + @Test + public void header_client_id_on_channel_publish() { + try { + /* Init values for local server */ + ClientOptions opts = createOptions(testVars.keys[0].keyStr); + opts.environment = null; + opts.tls = false; + opts.port = server.getListeningPort(); + opts.restHost = "localhost"; + opts.clientId = "test client"; + AblyRest ably = new AblyRest(opts); + + /* Publish message */ + String messageName = "test message"; + String messageData = String.valueOf(System.currentTimeMillis()); + + Channel channel = ably.channels.get("test"); + channel.publish(messageName, messageData); + + /* Get last headers */ + Map headers = server.getHeaders(); + Assert.assertEquals(headers.get("x-ably-clientid"), /* Base64Coder.encodeString("test client") */ "dGVzdCBjbGllbnQ="); + } catch (AblyException e) { + e.printStackTrace(); + Assert.fail("header_client_id_on_channel_publish: Unexpected exception"); + } + } + private static class SessionHandlerNanoHTTPD extends NanoHTTPD { Map requestHeaders; - public SessionHandlerNanoHTTPD(int port) { + SessionHandlerNanoHTTPD(int port) { super(port); } diff --git a/lib/src/test/java/io/ably/lib/test/rest/HttpTest.java b/lib/src/test/java/io/ably/lib/test/rest/HttpTest.java index fa3649bc3..fc96a1359 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/HttpTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/HttpTest.java @@ -1,36 +1,30 @@ package io.ably.lib.test.rest; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.junit.Assert.*; -import static org.mockito.AdditionalMatchers.aryEq; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyBoolean; -import static org.mockito.Matchers.anyString; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import java.io.IOException; -import java.net.MalformedURLException; -import java.net.Proxy; -import java.net.URL; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import io.ably.lib.http.*; +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.router.RouterNanoHTTPD; +import io.ably.lib.http.AsyncHttpScheduler; +import io.ably.lib.http.Http; +import io.ably.lib.http.HttpConstants; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpHelpers; +import io.ably.lib.http.SyncHttpScheduler; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.test.util.EmptyPlatformAgentProvider; +import io.ably.lib.test.util.StatusHandler; import io.ably.lib.test.util.TimeHandler; -import io.ably.lib.types.*; -import io.ably.lib.util.Log; +import io.ably.lib.transport.Defaults; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Callback; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Param; +import io.ably.lib.util.PlatformAgentProvider; import org.hamcrest.Description; import org.hamcrest.TypeSafeMatcher; import org.junit.AfterClass; import org.junit.Assert; import org.junit.BeforeClass; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -40,11 +34,30 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; -import fi.iki.elonen.NanoHTTPD; -import fi.iki.elonen.router.RouterNanoHTTPD; -import io.ably.lib.rest.AblyRest; -import io.ably.lib.test.util.StatusHandler; -import io.ably.lib.transport.Defaults; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyBoolean; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; /** * Created by gokhanbarisaker on 2/2/16. @@ -56,6 +69,7 @@ public class HttpTest { private static final String[] CUSTOM_HOSTS = { "f.ably-realtime.com", "g.ably-realtime.com", "h.ably-realtime.com", "i.ably-realtime.com", "j.ably-realtime.com", "k.ably-realtime.com" }; private static final String TEST_SERVER_HOST = "localhost"; private static final int TEST_SERVER_PORT = 27331; + private static final PlatformAgentProvider platformAgentProvider = new EmptyPlatformAgentProvider(); @Rule public Timeout testTimeout = Timeout.seconds(60); @@ -101,6 +115,7 @@ public static void tearDown() { * * @throws Exception */ + @Ignore("FIXME: flaky test") @Test public void http_ably_execute_fallback() throws AblyException { ClientOptions options = new ClientOptions(); @@ -116,17 +131,17 @@ public void http_ably_execute_fallback() throws AblyException { /* * Extend the httpCore, so that we can capture provided url arguments without mocking and changing its organic behavior. */ - HttpCore httpCore = new HttpCore(options, null) { + HttpCore httpCore = new HttpCore(options, null, platformAgentProvider) { /* Store only string representations to avoid try/catch blocks */ List urlArgumentStack; @Override - public T httpExecute(URL url, Proxy proxy, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, ResponseHandler responseHandler) throws AblyException { + public T httpExecute(URL url, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, ResponseHandler responseHandler) throws AblyException { // Store a copy of given argument urlArgumentStack.add(url.getHost()); // Execute the original method without changing behavior - return super.httpExecute(url, proxy, method, headers, requestBody, withCredentials, responseHandler); + return super.httpExecute(url, method, headers, requestBody, withCredentials, responseHandler); } public HttpCore setUrlArgumentStack(List urlArgumentStack) { @@ -148,15 +163,15 @@ public HttpCore setUrlArgumentStack(List urlArgumentStack) { ); } catch (AblyException e) { /* Verify that, - * - an {@code AblyException} with {@code ErrorInfo} having a `50x` status code is thrown. + * - an {@code AblyException} with {@code ErrorInfo} having a `50x` status code is thrown. */ assertThat(e.errorInfo.statusCode / 10, is(equalTo(50))); } /* Verify that, - * - {code HttpCore#httpExecute} have been called with (httpMaxRetryCount + 1) URLs - * - first call executed against production rest host - * - other calls executed against a random fallback host + * - {code HttpCore#httpExecute} have been called with (httpMaxRetryCount + 1) URLs + * - first call executed against production rest host + * - other calls executed against a random fallback host */ int expectedCallCount = options.httpMaxRetryCount + 1; assertThat(urlHostArgumentStack.size(), is(equalTo(expectedCallCount))); @@ -181,7 +196,7 @@ public void http_ably_execute_null_fallbacks() throws AblyException { ArrayList urlHostArgumentStack = new ArrayList<>(); - HttpCore httpCore = new HttpCore(options, null) { + HttpCore httpCore = new HttpCore(options, null, platformAgentProvider) { List urlArgumentStack; @Override @@ -209,7 +224,7 @@ public HttpCore setUrlArgumentStack(List urlArgumentStack) { ); } catch (AblyException.HostFailedException e) { /* Verify that, - * - a {@code AblyException.HostFailedException} is thrown. + * - a {@code AblyException.HostFailedException} is thrown. */ assertTrue(true); } catch (AblyException e) { @@ -241,7 +256,7 @@ public void http_ably_execute_first_attempt_to_default() throws AblyException { options.fallbackRetryTimeout = 100; AblyRest ably = new AblyRest(options); - HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth)); + HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth, platformAgentProvider)); String responseExpected = "Lorem Ipsum"; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -257,7 +272,6 @@ public void http_ably_execute_first_attempt_to_default() throws AblyException { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -300,7 +314,6 @@ public void http_ably_execute_first_attempt_to_default() throws AblyException { verify(httpCore, times(3)) .httpExecute( /* Just validating call counter. Ignore following parameters */ any(URL.class), /* Ignore */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -330,7 +343,7 @@ public void http_ably_execute_overriden_host() throws AblyException { options.restHost = fakeHost; AblyRest ably = new AblyRest(options); - HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth)); + HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth, platformAgentProvider)); String responseExpected = "Lorem Ipsum"; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -346,7 +359,6 @@ public void http_ably_execute_overriden_host() throws AblyException { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -367,7 +379,7 @@ public void http_ably_execute_overriden_host() throws AblyException { ); } catch (AblyException e) { /* Verify that, - * - an {@code AblyException} with {@code ErrorInfo} having the 500 error from above + * - an {@code AblyException} with {@code ErrorInfo} having the 500 error from above */ ErrorInfo expectedErrorInfo = new ErrorInfo("Internal Server Error", 500, 50000); assertThat(e, new ErrorInfoMatcher(expectedErrorInfo)); @@ -387,7 +399,7 @@ public void http_ably_execute_overriden_host() throws AblyException { ); } catch (AblyException e) { /* Verify that, - * - an {@code AblyException} with {@code ErrorInfo} having the 500 error from above + * - an {@code AblyException} with {@code ErrorInfo} having the 500 error from above */ ErrorInfo expectedErrorInfo = new ErrorInfo("Internal Server Error", 500, 50000); assertThat(e, new ErrorInfoMatcher(expectedErrorInfo)); @@ -398,7 +410,6 @@ public void http_ably_execute_overriden_host() throws AblyException { verify(httpCore, times(2)) .httpExecute( /* Just validating call counter. Ignore following parameters */ any(URL.class), /* Ignore */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -426,7 +437,7 @@ public void http_ably_execute_empty_fallback_array() throws AblyException { options.fallbackHosts = new String[0]; AblyRest ably = new AblyRest(options); - HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth)); + HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth, platformAgentProvider)); String responseExpected = "Lorem Ipsum"; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -442,7 +453,6 @@ public void http_ably_execute_empty_fallback_array() throws AblyException { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -462,9 +472,7 @@ public void http_ably_execute_empty_fallback_array() throws AblyException { false /* Ignore */ ); } catch (AblyException e) { - /* Verify that, - * - an {@code AblyException} with {@code ErrorInfo} with the 500 error from above. - */ + /* Verify that, an {@code AblyException} with {@code ErrorInfo} with the 500 error from above. */ ErrorInfo expectedErrorInfo = new ErrorInfo("Internal Server Error", 500, 50000); assertThat(e, new ErrorInfoMatcher(expectedErrorInfo)); } @@ -474,7 +482,6 @@ public void http_ably_execute_empty_fallback_array() throws AblyException { verify(httpCore, times(1)) .httpExecute( /* Just validating call counter. Ignore following parameters */ any(URL.class), /* Ignore */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -506,7 +513,7 @@ public void http_ably_execute_custom_fallback_array() throws AblyException { int expectedCallCount = options.httpMaxRetryCount + 1; AblyRest ably = new AblyRest(options); - HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth)); + HttpCore httpCore = Mockito.spy(new HttpCore(ably.options, ably.auth, platformAgentProvider)); String responseExpected = "Lorem Ipsum"; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -522,7 +529,6 @@ public void http_ably_execute_custom_fallback_array() throws AblyException { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -545,7 +551,6 @@ public void http_ably_execute_custom_fallback_array() throws AblyException { verify(httpCore, times(expectedCallCount)) .httpExecute( /* Just validating call counter. Ignore following parameters */ any(URL.class), /* Ignore */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -554,9 +559,9 @@ public void http_ably_execute_custom_fallback_array() throws AblyException { ); /* Verify that, - * - delivered expected response - * - first call executed against production rest host - * - other calls executed against a random custom fallback host */ + * - delivered expected response + * - first call executed against production rest host + * - other calls executed against a random custom fallback host */ List allValues = url.getAllValues(); assertThat("Unexpected response", responseActual, is(equalTo(responseExpected))); assertThat("Unexpected default primary host", allValues.get(0).getHost(), is(equalTo(Defaults.HOST_REST))); @@ -579,7 +584,7 @@ public void http_ably_execute_custom_fallback() throws AblyException { ArrayList urlHostArgumentStack = new ArrayList<>(); - HttpCore httpCore = new HttpCore(options, null) { + HttpCore httpCore = new HttpCore(options, null, platformAgentProvider) { /* Store only string representations to avoid try/catch blocks */ List urlArgumentStack; @@ -611,9 +616,7 @@ public HttpCore setUrlArgumentStack(List urlArgumentStack) { false /* Ignore requireAblyAuth */ ); } catch (AblyException.HostFailedException e) { - /* Verify that, - * - a {@code AblyException.HostFailedException} is thrown. - */ + /* Verify that, a {@code AblyException.HostFailedException} is thrown. */ assertTrue(true); } catch (AblyException e) { assertTrue(false); @@ -641,7 +644,7 @@ public HttpCore setUrlArgumentStack(List urlArgumentStack) { */ @Test public void http_execute_nofallback() throws Exception { - HttpCore httpCore = Mockito.spy(new HttpCore(new ClientOptions(), null)); + HttpCore httpCore = Mockito.spy(new HttpCore(new ClientOptions(), null, platformAgentProvider)); String responseExpected = "Lorem Ipsum"; String hostExpected = Defaults.HOST_REST; @@ -652,7 +655,6 @@ public void http_execute_nofallback() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -679,7 +681,6 @@ public void http_execute_nofallback() throws Exception { verify(httpCore, times(1)) .httpExecute( /* Just validating call counter. Ignore following parameters */ url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -704,7 +705,7 @@ public void http_execute_nofallback() throws Exception { */ @Test public void http_execute_singlefallback() throws Exception { - HttpCore httpCore = Mockito.spy(new HttpCore(new ClientOptions(), null)); + HttpCore httpCore = Mockito.spy(new HttpCore(new ClientOptions(), null, platformAgentProvider)); String hostExpectedPattern = PATTERN_HOST_FALLBACK; String responseExpected = "Lorem Ipsum"; @@ -721,7 +722,6 @@ public void http_execute_singlefallback() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -750,7 +750,6 @@ public void http_execute_singlefallback() throws Exception { verify(httpCore, times(2)) .httpExecute( /* Just validating call counter. Ignore following parameters */ url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -775,7 +774,7 @@ public void http_execute_singlefallback() throws Exception { */ @Test public void http_execute_multiplefallback() throws Exception { - HttpCore httpCore = Mockito.spy(new HttpCore(new ClientOptions(), null)); + HttpCore httpCore = Mockito.spy(new HttpCore(new ClientOptions(), null, platformAgentProvider)); String hostExpectedPattern = PATTERN_HOST_FALLBACK; String responseExpected = "Lorem Ipsum"; @@ -792,7 +791,6 @@ public void http_execute_multiplefallback() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -829,7 +827,6 @@ public void http_execute_multiplefallback() throws Exception { verify(httpCore, times(3)) .httpExecute( /* Just validating call counter. Ignore following parameters */ url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -855,7 +852,7 @@ public void http_execute_multiplefallback() throws Exception { public void http_execute_fallback_success_timeout_unexpired() throws Exception { ClientOptions opts = new ClientOptions(); opts.fallbackRetryTimeout = 2000L; - HttpCore httpCore = Mockito.spy(new HttpCore(opts, null)); + HttpCore httpCore = Mockito.spy(new HttpCore(opts, null, platformAgentProvider)); String hostExpected = Defaults.HOST_REST; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -871,7 +868,6 @@ public void http_execute_fallback_success_timeout_unexpired() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -903,7 +899,6 @@ public void http_execute_fallback_success_timeout_unexpired() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -942,7 +937,7 @@ public void http_execute_fallback_success_timeout_unexpired() throws Exception { public void http_execute_fallback_failure_timeout_unexpired() throws Exception { ClientOptions opts = new ClientOptions(); opts.fallbackRetryTimeout = 2000L; - HttpCore httpCore = Mockito.spy(new HttpCore(opts, null)); + HttpCore httpCore = Mockito.spy(new HttpCore(opts, null, platformAgentProvider)); String primaryHost = Defaults.HOST_REST; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -958,7 +953,6 @@ public void http_execute_fallback_failure_timeout_unexpired() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -995,7 +989,6 @@ public void http_execute_fallback_failure_timeout_unexpired() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -1033,7 +1026,7 @@ public void http_execute_fallback_failure_timeout_unexpired() throws Exception { public void http_execute_fallback_timeout_expired() throws Exception { ClientOptions opts = new ClientOptions(); opts.fallbackRetryTimeout = 2000L; - HttpCore httpCore = Mockito.spy(new HttpCore(opts, null)); + HttpCore httpCore = Mockito.spy(new HttpCore(opts, null, platformAgentProvider)); String hostExpected = Defaults.HOST_REST; ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); @@ -1049,7 +1042,6 @@ public void http_execute_fallback_timeout_expired() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -1080,7 +1072,6 @@ public void http_execute_fallback_timeout_expired() throws Exception { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -1117,7 +1108,7 @@ public void http_execute_fallback_timeout_expired() throws Exception { @Test public void http_execute_excessivefallback() throws AblyException { ClientOptions options = new ClientOptions(); - HttpCore httpCore = Mockito.spy(new HttpCore(options, null)); + HttpCore httpCore = Mockito.spy(new HttpCore(options, null, platformAgentProvider)); ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); int excessiveFallbackCount = options.httpMaxRetryCount + 1; @@ -1133,7 +1124,6 @@ public void http_execute_excessivefallback() throws AblyException { .when(httpCore) /* when following method is executed on {@code HttpCore} instance */ .httpExecute( url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ - any(Proxy.class), /* Ignore */ anyString(), /* Ignore */ aryEq(new Param[0]), /* Ignore */ any(HttpCore.RequestBody.class), /* Ignore */ @@ -1173,7 +1163,7 @@ public void http_execute_excessivefallback() throws AblyException { @Test public void http_execute_response_50x() throws AblyException, MalformedURLException { URL url; - HttpCore httpCore = new HttpCore(new ClientOptions(), null); + HttpCore httpCore = new HttpCore(new ClientOptions(), null, platformAgentProvider); AblyException.HostFailedException hfe; @@ -1208,7 +1198,7 @@ public void http_execute_response_50x() throws AblyException, MalformedURLExcept @Test public void http_execute_response_non5xx() throws AblyException, MalformedURLException { URL url; - HttpCore httpCore = new HttpCore(new ClientOptions(), null); + HttpCore httpCore = new HttpCore(new ClientOptions(), null, platformAgentProvider); /* Informational 1xx */ @@ -1352,7 +1342,7 @@ static class GrumpyAnswer implements Answer { * @param nope Expected nope * @param value Expected value that will be returned after grumpiness level goes below or equal to 0. */ - public GrumpyAnswer(int grumpinessLevel, Throwable nope, String value) { + GrumpyAnswer(int grumpinessLevel, Throwable nope, String value) { this.grumpinessLevel = grumpinessLevel; this.nope = nope; this.value = value; @@ -1371,7 +1361,7 @@ public String answer(InvocationOnMock invocation) throws Throwable { static class ErrorInfoMatcher extends TypeSafeMatcher { ErrorInfo errorInfo; - public ErrorInfoMatcher(ErrorInfo errorInfo) { + ErrorInfoMatcher(ErrorInfo errorInfo) { super(); this.errorInfo = errorInfo; } diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestAppStatsTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestAppStatsTest.java index 61fa62b4f..dcee1a53b 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestAppStatsTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestAppStatsTest.java @@ -1,9 +1,5 @@ package io.ably.lib.test.rest; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.*; - import io.ably.lib.http.HttpHelpers; import io.ably.lib.http.HttpUtils; import io.ably.lib.rest.AblyRest; @@ -16,13 +12,19 @@ import io.ably.lib.types.Param; import io.ably.lib.types.Stats; import io.ably.lib.types.StatsReader; - -import java.util.Date; - import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; +import java.util.Date; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + @SuppressWarnings("deprecation") public class RestAppStatsTest extends ParameterizedTest { diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestAuthAttributeTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestAuthAttributeTest.java index 107f25cf8..8f4b98a78 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestAuthAttributeTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestAuthAttributeTest.java @@ -1,5 +1,18 @@ package io.ably.lib.test.rest; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.rest.Auth; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Capability; +import io.ably.lib.types.ClientOptions; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; @@ -9,24 +22,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import java.util.ArrayList; -import java.util.List; - -import org.junit.Before; -import org.junit.Test; - -import io.ably.lib.rest.AblyRest; -import io.ably.lib.rest.Auth; -import io.ably.lib.rest.Auth.AuthOptions; -import io.ably.lib.rest.Auth.TokenCallback; -import io.ably.lib.rest.Auth.TokenDetails; -import io.ably.lib.rest.Auth.TokenParams; -import io.ably.lib.rest.Auth.TokenRequest; -import io.ably.lib.test.common.ParameterizedTest; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.Capability; -import io.ably.lib.types.ClientOptions; - /** * Created by VOstopolets on 9/3/16. */ @@ -47,6 +42,7 @@ public void setupClient() throws Exception { * Spec: RSA10g,RSA10j *

*/ + @Ignore("FIXME: flaky test") @Test public void auth_stores_options_params() { try { @@ -55,19 +51,19 @@ public void auth_stores_options_params() { capability.addResource("testchannel", "subscribe"); final String capabilityStr = capability.toString(); final String testClientId = "firstClientId"; - TokenParams tokenParams = new TokenParams() {{ + Auth.TokenParams tokenParams = new Auth.TokenParams() {{ ttl = 4000L; clientId = testClientId; capability = capabilityStr; }}; /* init custom AuthOptions */ - AuthOptions authOptions = new AuthOptions() {{ - authCallback = new TokenCallback() { + Auth.AuthOptions authOptions = new Auth.AuthOptions() {{ + authCallback = new Auth.TokenCallback() { private AblyRest ably = new AblyRest(createOptions(testVars.keys[0].keyStr)); @Override - public Object getTokenRequest(TokenParams params) throws AblyException { + public Object getTokenRequest(Auth.TokenParams params) throws AblyException { return ably.auth.requestToken(params, null); } }; @@ -78,7 +74,7 @@ public Object getTokenRequest(TokenParams params) throws AblyException { * Deliberate use of British spelling alias authorise() to check that * it works (0.9 RSA10l) */ @SuppressWarnings("deprecation") - TokenDetails tokenDetails1 = ably.auth.authorise(tokenParams, authOptions); + Auth.TokenDetails tokenDetails1 = ably.auth.authorise(tokenParams, authOptions); /* Verify that, * tokenDetails1 isn't null, @@ -93,7 +89,7 @@ public Object getTokenRequest(TokenParams params) throws AblyException { } catch(InterruptedException ie) {} /* authorize with default options */ - TokenDetails tokenDetails2 = ably.auth.authorize(null, null); + Auth.TokenDetails tokenDetails2 = ably.auth.authorize(null, null); /* Verify that, * tokenDetails2 isn't null, @@ -128,13 +124,13 @@ public long time() throws AblyException { return fakeServerTime; } }; - final AuthOptions authOptions = new AuthOptions(); + final Auth.AuthOptions authOptions = new Auth.AuthOptions(); authOptions.key = ablyForTime.options.key; authOptions.queryTime = true; - TokenParams tokenParams = new TokenParams(); + Auth.TokenParams tokenParams = new Auth.TokenParams(); /* create token request with custom AuthOptions that has attribute queryTime */ - TokenRequest tokenRequest = ablyForTime.auth.createTokenRequest(tokenParams, authOptions); + Auth.TokenRequest tokenRequest = ablyForTime.auth.createTokenRequest(tokenParams, authOptions); /* verify that issued time of server equals fake expected value */ assertEquals(expectedClientId, tokenRequest.clientId); @@ -150,8 +146,8 @@ public long time() throws AblyException { tokenRequest = ablyForTime.auth.createTokenRequest(tokenParams, null); /* Verify that, - * - timestamp not equals fake server time - * - timestamp equals local time */ + * - timestamp not equals fake server time + * - timestamp equals local time */ assertEquals(expectedClientId, tokenRequest.clientId); assertNotEquals(fakeServerTime, tokenRequest.timestamp); long localTime = System.currentTimeMillis(); @@ -180,41 +176,41 @@ public void auth_stores_options_exception_timestamp() { /* create custom token callback for capturing timestamp values */ final List timestampCapturedList = new ArrayList<>(); - TokenCallback tokenCallback = new TokenCallback() { + Auth.TokenCallback tokenCallback = new Auth.TokenCallback() { private List timestampCapturedList; - public TokenCallback setTimestampCapturedList(List timestampCapturedList) { + public Auth.TokenCallback setTimestampCapturedList(List timestampCapturedList) { this.timestampCapturedList = timestampCapturedList; return this; } @Override - public Object getTokenRequest(TokenParams params) throws AblyException { + public Object getTokenRequest(Auth.TokenParams params) throws AblyException { this.timestampCapturedList.add(params.timestamp); return ablyForToken.auth.requestToken(null, null); } }.setTimestampCapturedList(timestampCapturedList); /* authorize with custom timestamp */ - AuthOptions authOptions = new AuthOptions(); + Auth.AuthOptions authOptions = new Auth.AuthOptions(); authOptions.key = ably.options.key; authOptions.authCallback = tokenCallback; - TokenParams tokenParams = new TokenParams(); + Auth.TokenParams tokenParams = new Auth.TokenParams(); tokenParams.timestamp = expectedTimestamp; - TokenDetails tokenDetails1 = ably.auth.authorize(tokenParams, authOptions); + Auth.TokenDetails tokenDetails1 = ably.auth.authorize(tokenParams, authOptions); final String token1 = tokenDetails1.token; final String clientId1 = tokenDetails1.clientId; /* force authorize with stored TokenParams values */ - TokenDetails tokenDetails2 = ably.auth.authorize(null, authOptions); + Auth.TokenDetails tokenDetails2 = ably.auth.authorize(null, authOptions); final String token2 = tokenDetails2.token; final String clientId2 = tokenDetails2.clientId; /* Verify that, - * - new token was issued - * - authorize called twice - * - first timestamp value equals expected timestamp - * - second timestamp value is not expected + * - new token was issued + * - authorize called twice + * - first timestamp value equals expected timestamp + * - second timestamp value is not expected * tokenDetails1 and tokenDetails2 aren't null, * the values of each attribute are equals */ assertNotNull(tokenDetails1); @@ -242,21 +238,21 @@ public Object getTokenRequest(TokenParams params) throws AblyException { public void auth_authorize_force() { try { /* authorize with default options */ - TokenDetails tokenDetails1 = ably.auth.authorize(null, null); + Auth.TokenDetails tokenDetails1 = ably.auth.authorize(null, null); /* init custom AuthOptions */ final String custom_test_value = "test_forced_token"; - AuthOptions authOptions = new AuthOptions() {{ - authCallback = new TokenCallback() { + Auth.AuthOptions authOptions = new Auth.AuthOptions() {{ + authCallback = new Auth.TokenCallback() { @Override - public Object getTokenRequest(TokenParams params) throws AblyException { + public Object getTokenRequest(Auth.TokenParams params) throws AblyException { return custom_test_value; } }; }}; /* authorize with custom AuthOptions */ - TokenDetails tokenDetails2 = ably.auth.authorize(null, authOptions); + Auth.TokenDetails tokenDetails2 = ably.auth.authorize(null, authOptions); /* Verify that, * tokenDetails1 and tokenDetails2 aren't null, diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestAuthTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestAuthTest.java index b1844d4bc..d3553e7a8 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestAuthTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestAuthTest.java @@ -1,29 +1,11 @@ package io.ably.lib.test.rest; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -import io.ably.lib.http.HttpConstants; -import io.ably.lib.http.HttpCore; -import io.ably.lib.test.common.Helpers; -import io.ably.lib.types.*; - -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.junit.rules.Timeout; - import fi.iki.elonen.NanoHTTPD; import fi.iki.elonen.router.RouterNanoHTTPD; import io.ably.lib.debug.DebugOptions; +import io.ably.lib.http.HttpConstants; +import io.ably.lib.http.HttpCore; +import io.ably.lib.network.HttpRequest; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.rest.Auth.AuthMethod; @@ -32,11 +14,39 @@ import io.ably.lib.rest.Auth.TokenParams; import io.ably.lib.rest.Auth.TokenRequest; import io.ably.lib.rest.Channel; -import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.Helpers.RawHttpTracker; +import io.ably.lib.test.common.ParameterizedTest; import io.ably.lib.test.util.TokenServer; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.MessageSerializer; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.Timeout; -import static org.junit.Assert.*; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static junit.framework.TestCase.assertNull; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RestAuthTest extends ParameterizedTest { @@ -191,20 +201,20 @@ public String getTokenRequest(TokenParams params) throws AblyException { } /** - * Init library with a key and clientId; expect token auth to be chosen - * Spec: RSA4, RSC17, RSA7b1 + * Init library with a key and clientId; expect basic auth to be chosen + * Spec: RSC17, RSA7b1 */ @Test - public void authinit_clientId_implies_token() { + public void authinit_clientId_auth_basic() { try { ClientOptions opts = createOptions(testVars.keys[0].keyStr); opts.clientId = "testClientId"; AblyRest ably = new AblyRest(opts); - assertEquals("Unexpected Auth method mismatch", ably.auth.getAuthMethod(), AuthMethod.token); + assertEquals("Unexpected Auth method mismatch", ably.auth.getAuthMethod(), AuthMethod.basic); assertEquals("Unexpected clientId mismatch", ably.auth.clientId, "testClientId"); } catch (AblyException e) { e.printStackTrace(); - fail("authinit_clientId_implies_token: Unexpected exception instantiating library"); + fail("authinit_clientId_auth_basic: Unexpected exception instantiating library"); } } @@ -1316,7 +1326,7 @@ public Object getTokenRequest(TokenParams params) throws AblyException { /* Publish a message */ Message messagePublishee = new Message( - "wildcard", /* name */ + "wildcard", /* name */ String.valueOf(System.currentTimeMillis()), /* data */ "brian that is called brian" /* clientId */ ); @@ -1368,7 +1378,7 @@ public void auth_clientid_publish_implicit() { DebugOptions options = new DebugOptions(testVars.keys[0].keyStr) {{ this.httpListener = new RawHttpListener() { @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { try { if(testParams.useBinaryProtocol) { @@ -1396,7 +1406,7 @@ public void onRawHttpException(String id, String method, Throwable t) {} /* Publish a message */ Message messagePublishee = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()) /* data */ ); @@ -1433,7 +1443,7 @@ public void auth_clientid_publish_explicit_in_message() { DebugOptions options = new DebugOptions(testVars.keys[0].keyStr) {{ this.httpListener = new RawHttpListener() { @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { try { if(testParams.useBinaryProtocol) { @@ -1461,7 +1471,7 @@ public void onRawHttpException(String id, String method, Throwable t) {} /* Publish a message */ Message messagePublishee = new Message( - "I have clientId", /* name */ + "I have clientId", /* name */ String.valueOf(System.currentTimeMillis()), /* data */ messageClientId /* clientId */ ); @@ -1901,7 +1911,7 @@ public Object getTokenRequest(TokenParams params) throws AblyException { private static class SessionHandlerNanoHTTPD extends RouterNanoHTTPD { private final ArrayList requestHistory = new ArrayList<>(); - public SessionHandlerNanoHTTPD(int port) { + SessionHandlerNanoHTTPD(int port) { super(port); } diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestCapabilityTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestCapabilityTest.java index fbaa61e96..312f59c7c 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestCapabilityTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestCapabilityTest.java @@ -106,7 +106,7 @@ public void authcapability3() { } /** - * Non-empty ops intersection + * Non-empty ops intersection */ @Test public void authcapability4() { @@ -116,7 +116,7 @@ public void authcapability4() { authOptions.key = key.keyStr; TokenParams tokenParams = new TokenParams(); Capability requestedCapability = new Capability(); - requestedCapability.addResource("channel2", new String[]{"presence", "subscribe"}); + requestedCapability.addResource("channel2", "presence", "subscribe"); tokenParams.capability = requestedCapability.toString(); TokenDetails tokenDetails = ably.auth.requestToken(tokenParams, authOptions); Capability expectedCapability = new Capability(); @@ -130,7 +130,7 @@ public void authcapability4() { } /** - * Non-empty paths intersection + * Non-empty paths intersection */ @Test public void authcapability5() { @@ -140,8 +140,8 @@ public void authcapability5() { authOptions.key = key.keyStr; TokenParams tokenParams = new TokenParams(); Capability requestedCapability = new Capability(); - requestedCapability.addResource("channel2", new String[]{"presence", "subscribe"}); - requestedCapability.addResource("channelx", new String[]{"presence", "subscribe"}); + requestedCapability.addResource("channel2", "presence", "subscribe"); + requestedCapability.addResource("channelx", "presence", "subscribe"); tokenParams.capability = requestedCapability.toString(); TokenDetails tokenDetails = ably.auth.requestToken(tokenParams, authOptions); Capability expectedCapability = new Capability(); @@ -155,7 +155,7 @@ public void authcapability5() { } /** - * Wildcard ops intersection + * Wildcard ops intersection */ @Test public void authcapability6() { @@ -169,7 +169,7 @@ public void authcapability6() { tokenParams.capability = requestedCapability.toString(); TokenDetails tokenDetails = ably.auth.requestToken(tokenParams, authOptions); Capability expectedCapability = new Capability(); - expectedCapability.addResource("channel2", new String[]{"publish", "subscribe"}); + expectedCapability.addResource("channel2", "publish", "subscribe"); assertNotNull("Expected token value", tokenDetails.token); assertEquals("Unexpected capability", tokenDetails.capability, expectedCapability.toString()); } catch (AblyException e) { @@ -185,11 +185,11 @@ public void authcapability7() { authOptions.key = key.keyStr; TokenParams tokenParams = new TokenParams(); Capability requestedCapability = new Capability(); - requestedCapability.addResource("channel6", new String[]{"publish", "subscribe"}); + requestedCapability.addResource("channel6", "publish", "subscribe"); tokenParams.capability = requestedCapability.toString(); TokenDetails tokenDetails = ably.auth.requestToken(tokenParams, authOptions); Capability expectedCapability = new Capability(); - expectedCapability.addResource("channel6", new String[]{"publish", "subscribe"}); + expectedCapability.addResource("channel6", "publish", "subscribe"); assertNotNull("Expected token value", tokenDetails.token); assertEquals("Unexpected capability", tokenDetails.capability, expectedCapability.toString()); } catch (AblyException e) { @@ -199,7 +199,7 @@ public void authcapability7() { } /** - * Wildcard resources intersection + * Wildcard resources intersection */ @Test public void authcapability8() { @@ -276,7 +276,7 @@ public void authinvalid0() { public void authinvalid1() { TokenParams tokenParams = new TokenParams(); Capability invalidCapability = new Capability(); - invalidCapability.addResource("channel0", new String[]{"*", "publish"}); + invalidCapability.addResource("channel0", "*", "publish"); tokenParams.capability = invalidCapability.toString(); try { ably.auth.requestToken(tokenParams, null); @@ -289,7 +289,7 @@ public void authinvalid1() { public void authinvalid2() { TokenParams tokenParams = new TokenParams(); Capability invalidCapability = new Capability(); - invalidCapability.addResource("channel0", new String[0]); + invalidCapability.addResource("channel0"); tokenParams.capability = invalidCapability.toString(); try { ably.auth.requestToken(tokenParams, null); diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestChannelBulkPublishTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestChannelBulkPublishTest.java index 0dca55de5..5b1e9778a 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestChannelBulkPublishTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestChannelBulkPublishTest.java @@ -1,21 +1,30 @@ package io.ably.lib.test.rest; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Random; - -import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.rest.AblyRest; import io.ably.lib.test.common.Helpers.ChannelWaiter; import io.ably.lib.test.common.Helpers.MessageWaiter; -import io.ably.lib.types.*; - -import io.ably.lib.rest.AblyRest; -import io.ably.lib.realtime.*; -import org.junit.Before; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Message; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import io.ably.lib.types.PublishResponse; import org.junit.Ignore; import org.junit.Test; -import static org.junit.Assert.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RestChannelBulkPublishTest extends ParameterizedTest { diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestChannelHistoryTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestChannelHistoryTest.java index bbd0879de..e54fb0a23 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestChannelHistoryTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestChannelHistoryTest.java @@ -7,7 +7,11 @@ import java.util.HashMap; import java.util.UUID; -import org.junit.*; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; import org.junit.rules.Timeout; import io.ably.lib.rest.AblyRest; diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestChannelPublishTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestChannelPublishTest.java index e6984797b..59f9c709f 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestChannelPublishTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestChannelPublishTest.java @@ -1,31 +1,36 @@ package io.ably.lib.test.rest; -import static org.hamcrest.core.IsEqual.equalTo; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.net.HttpURLConnection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import io.ably.lib.debug.DebugOptions; import io.ably.lib.http.HttpCore; -import io.ably.lib.rest.Auth; -import io.ably.lib.types.*; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Test; - +import io.ably.lib.network.HttpRequest; import io.ably.lib.rest.AblyRest; +import io.ably.lib.rest.Auth; import io.ably.lib.rest.Channel; import io.ably.lib.test.common.Helpers.AsyncWaiter; import io.ably.lib.test.common.Helpers.CompletionSet; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.AsyncPaginatedResult; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Message; +import io.ably.lib.types.MessageSerializer; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class RestChannelPublishTest extends ParameterizedTest { @@ -130,10 +135,10 @@ public void channel_idempotent_publish_client_generated_single() { opts.useBinaryProtocol = true; opts.httpListener = new DebugOptions.RawHttpListener() { @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { /* verify request body contains the supplied ids */ try { - if(method.equalsIgnoreCase("POST")) { + if(request.getMethod().equalsIgnoreCase("POST")) { Message[] requestedMessages = MessageSerializer.readMsgpack(requestBody.getEncoded()); assertEquals(requestedMessages[0].id, messageWithId.id); } @@ -191,10 +196,10 @@ public void channel_idempotent_publish_client_generated_multiple() { opts.useBinaryProtocol = true; opts.httpListener = new DebugOptions.RawHttpListener() { @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { /* verify request body contains the supplied ids */ try { - if(method.equalsIgnoreCase("POST")) { + if(request.getMethod().equalsIgnoreCase("POST")) { Message[] requestedMessages = MessageSerializer.readMsgpack(requestBody.getEncoded()); assertEquals(requestedMessages[0].id, messageWithId0.id); assertEquals(requestedMessages[1].id, messageWithId1.id); @@ -249,10 +254,10 @@ static class FailFirstRequest implements DebugOptions.RawHttpListener { } @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { /* verify request body contains the supplied ids */ try { - if(method.equalsIgnoreCase("POST")) { + if(request.getMethod().equalsIgnoreCase("POST")) { ++postRequestCount; Message[] requestedMessages = MessageSerializer.readMsgpack(requestBody.getEncoded()); if(expectedId != null) { @@ -301,7 +306,7 @@ public void channel_idempotent_publish_client_generated_retried() { opts.useBinaryProtocol = true; opts.httpListener = requestListener; /* generate a fallback which resolves to the same address, which the library will treat as a different host */ - opts.fallbackHosts = new String[]{ablyForToken.httpCore.getPrimaryHost().toUpperCase()}; + opts.fallbackHosts = new String[]{ablyForToken.httpCore.getPrimaryHost().toUpperCase(Locale.ROOT)}; AblyRest ably = new AblyRest(opts); /* publish message */ @@ -338,10 +343,10 @@ public void channel_idempotent_publish_library_generated_multiple() { opts.useBinaryProtocol = true; opts.httpListener = new DebugOptions.RawHttpListener() { @Override - public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, String method, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { + public HttpCore.Response onRawHttpRequest(String id, HttpRequest request, String authHeader, Map> requestHeaders, HttpCore.RequestBody requestBody) { /* verify request body contains the library-generated ids */ try { - if(method.equalsIgnoreCase("POST")) { + if(request.getMethod().equalsIgnoreCase("POST")) { Message[] requestedMessages = MessageSerializer.readMsgpack(requestBody.getEncoded()); assertTrue(requestedMessages[0].id.endsWith(":0")); assertTrue(requestedMessages[1].id.endsWith(":1")); @@ -410,7 +415,7 @@ public void channel_idempotent_publish_library_generated_retried() { opts.useBinaryProtocol = true; opts.httpListener = requestListener; /* generate a fallback which resolves to the same address, which the library will treat as a different host */ - opts.fallbackHosts = new String[]{ablyForToken.httpCore.getPrimaryHost().toUpperCase()}; + opts.fallbackHosts = new String[]{ablyForToken.httpCore.getPrimaryHost().toUpperCase(Locale.ROOT)}; AblyRest ably = new AblyRest(opts); /* publish message */ diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestClientTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestClientTest.java new file mode 100644 index 000000000..34c9624bc --- /dev/null +++ b/lib/src/test/java/io/ably/lib/test/rest/RestClientTest.java @@ -0,0 +1,110 @@ +package io.ably.lib.test.rest; + +import io.ably.lib.debug.DebugOptions; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.test.common.Helpers; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.AblyException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.Timeout; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class RestClientTest extends ParameterizedTest { + + @Rule + public Timeout testTimeout = Timeout.seconds(30); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + /** + * Include `request_id` if addRequestIds in client options is enabled + * Spec: RSC7c + */ + @Test + public void request_contains_request_id() throws AblyException { + DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); + fillInOptions(opts); + Helpers.RawHttpTracker httpListener = new Helpers.RawHttpTracker(); + + opts.httpListener = httpListener; + /* disable addRequestIds */ + opts.addRequestIds = false; + AblyRest ablyA = new AblyRest(opts); + + ablyA.channels.get("test").publish("foo", "bar"); + /* verify client_id is not a part of url query */ + assertNull("Verify clientId is not present in query", httpListener.getFirstRequest().url.getQuery()); + + /* enable addRequestIds */ + opts.addRequestIds = true; + AblyRest ablyB = new AblyRest(opts); + + ablyB.channels.get("test").publish("foo", "bar"); + /* verify client_id is a part of url query */ + assertTrue("Verify clientId is present in query", httpListener.getLastRequest().url.getQuery().contains("request_id")); + } + + /** + * Include `request_id` in ErrorInfo if addRequestIds in client options is enabled + * Spec: RSC7c + */ + @Test + public void error_info_contains_request_id() throws AblyException { + DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); + fillInOptions(opts); + + Helpers.RawHttpTracker httpListener = new Helpers.RawHttpTracker(); + opts.httpListener = httpListener; + opts.addRequestIds = true; + opts.environment = null; + opts.restHost = ""; + opts.fallbackHosts = new String[]{"ably.com"}; + AblyRest ably = new AblyRest(opts); + + try{ + ably.channels.get("test").publish("foo", "bar"); + } catch (AblyException e) { + assertTrue(e.errorInfo.message.contains("request_id")); + } + + /* verify client_id is a part of url query */ + assertTrue("Verify clientId is present in query", httpListener.getFirstRequest().url.getQuery().contains("request_id")); + } + + /** + * `clientId` remain the same if a request is retried to a fallback host + * Spec: RSC7c + */ + @Test + public void request_id_remain_same_retried_fallbacks() throws AblyException { + DebugOptions opts = new DebugOptions(testVars.keys[0].keyStr); + fillInOptions(opts); + + Helpers.RawHttpTracker httpListener = new Helpers.RawHttpTracker(); + opts.httpListener = httpListener; + opts.addRequestIds = true; + opts.environment = null; + opts.restHost = "invalid-host1.com"; + opts.fallbackHosts = new String[]{"invalid-host2.com", "invalid-host3.com"}; + AblyRest ably = new AblyRest(opts); + + try{ + ably.channels.get("test").publish("foo", "bar"); + } catch (AblyException e) { } + + /* verify client_id is a part of url query */ + assertTrue("Verify clientId is present in query", httpListener.getFirstRequest().url.getQuery().contains("request_id")); + String query = httpListener.getFirstRequest().url.getQuery(); + /* verify request was retried 3 times */ + assertEquals(3, httpListener.values().size()); + for (Helpers.RawHttpRequest rawHttpRequest : httpListener.values()) { + assertTrue("Verify clientId remain the same if a request is retried to a fallback host", rawHttpRequest.url.getQuery().contains(query)); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java index 215d8aa26..a3ac5e5f0 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestCryptoTest.java @@ -2,14 +2,15 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import java.security.NoSuchAlgorithmException; import java.util.HashMap; import javax.crypto.KeyGenerator; +import io.ably.lib.test.common.Helpers; import org.junit.Before; import org.junit.Test; @@ -33,9 +34,9 @@ public class RestCryptoTest extends ParameterizedTest { @Before public void setUpBefore() throws Exception { - ClientOptions opts = createOptions(testVars.keys[0].keyStr); + final ClientOptions opts = createOptions(testVars.keys[0].keyStr); ably = new AblyRest(opts); - ClientOptions opts_alt = createOptions(testVars.keys[0].keyStr); + final ClientOptions opts_alt = createOptions(testVars.keys[0].keyStr); opts_alt.useBinaryProtocol = testParams.useBinaryProtocol; ably_alt = new AblyRest(opts_alt); } @@ -44,85 +45,71 @@ public void setUpBefore() throws Exception { * Publish events with data of various datatypes using text protocol */ @Test - public void crypto_publish() { + public void crypto_publish() throws AblyException { /* first, publish some messages */ - Channel publish0; - try { - ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; }}; - publish0 = ably.channels.get("persisted:crypto_publish_" + testParams.name, channelOpts); - - publish0.publish("publish0", "This is a string message payload"); - publish0.publish("publish1", "This is a byte[] message payload".getBytes()); - } catch(AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; }}; + final Channel publish0 = ably.channels.get("persisted:crypto_publish_" + testParams.name, channelOpts); + + final Helpers.CompletionWaiter publishWaiter = new Helpers.CompletionWaiter(); + publish0.publishAsync("publish0", "This is a string message payload", publishWaiter); + publish0.publishAsync("publish1", "This is a byte[] message payload".getBytes(), publishWaiter); + assertNull(publishWaiter.waitFor(2)); + + // TODO find a way to know that the history call below will have data available already + // (i.e. that data has made it to the REST endpoint ... we know that we've waitied for our publish requests + // to succeed, but that doesn't necessarily mean the data is yet available to all clients) /* get the history for this channel */ - try { - PaginatedResult messages = publish0.history(null); - assertNotNull("Expected non-null messages", messages); - assertEquals("Expected 2 messages", messages.items().length, 2); - HashMap messageContents = new HashMap(); - /* verify message contents */ - for(Message message : messages.items()) - messageContents.put(message.name, message.data); - assertEquals("Expect publish0 to be expected String", messageContents.get("publish0"), "This is a string message payload"); - assertEquals("Expect publish1 to be expected byte[]", new String((byte[])messageContents.get("publish1")), "This is a byte[] message payload"); - } catch (AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final PaginatedResult messages = publish0.history(null); + assertNotNull(messages); + assertEquals(2, messages.items().length); + final HashMap messageContents = new HashMap(); + /* verify message contents */ + for (final Message message : messages.items()) + messageContents.put(message.name, message.data); + final Object payload0 = messageContents.get("publish0"); + final Object payload1 = messageContents.get("publish1"); + assertTrue("Unexpected " + payload0.getClass(), payload0 instanceof String); + assertTrue("Unexpected " + payload1.getClass(), payload1 instanceof byte[]); + assertEquals("This is a string message payload", payload0); + assertEquals("This is a byte[] message payload", new String((byte[])payload1)); } /** * Publish events with data of various datatypes using text protocol with a 256-bit key */ @Test - public void crypto_publish_256() { + public void crypto_publish_256() throws NoSuchAlgorithmException, AblyException { /* first, publish some messages */ - Channel publish0; - try { - /* create a key */ - KeyGenerator keygen = KeyGenerator.getInstance("AES"); - keygen.init(256); - byte[] key = keygen.generateKey().getEncoded(); - final CipherParams params = Crypto.getDefaultParams(key); - - /* create a channel */ - ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; this.cipherParams = params; }}; - publish0 = ably.channels.get("persisted:crypto_publish_256_" + testParams.name, channelOpts); - - publish0.publish("publish0", "This is a string message payload"); - publish0.publish("publish1", "This is a byte[] message payload".getBytes()); - } catch(AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - fail("init0: Unexpected exception generating key"); - return; - } + /* create a key */ + final KeyGenerator keygen = KeyGenerator.getInstance("AES"); + keygen.init(256); + byte[] key = keygen.generateKey().getEncoded(); + final CipherParams params = Crypto.getDefaultParams(key); + + /* create a channel */ + final ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; this.cipherParams = params; }}; + final Channel publish0 = ably.channels.get("persisted:crypto_publish_256_" + testParams.name, channelOpts); + + final Helpers.CompletionWaiter publishWaiter = new Helpers.CompletionWaiter(); + publish0.publishAsync("publish0", "This is a string message payload", publishWaiter); + publish0.publishAsync("publish1", "This is a byte[] message payload".getBytes(), publishWaiter); + assertNull(publishWaiter.waitFor(2)); + + // TODO find a way to know that the history call below will have data available already + // (i.e. that data has made it to the REST endpoint ... we know that we've waitied for our publish requests + // to succeed, but that doesn't necessarily mean the data is yet available to all clients) /* get the history for this channel */ - try { - PaginatedResult messages = publish0.history(null); - assertNotNull("Expected non-null messages", messages); - assertEquals("Expected 2 messages", messages.items().length, 2); - HashMap messageContents = new HashMap(); - /* verify message contents */ - for(Message message : messages.items()) - messageContents.put(message.name, message.data); - assertEquals("Expect publish0 to be expected String", messageContents.get("publish0"), "This is a string message payload"); - assertEquals("Expect publish1 to be expected byte[]", new String((byte[])messageContents.get("publish1")), "This is a byte[] message payload"); - } catch (AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final PaginatedResult messages = publish0.history(null); + assertNotNull(messages); + assertEquals(2, messages.items().length); + final HashMap messageContents = new HashMap(); + /* verify message contents */ + for (final Message message : messages.items()) + messageContents.put(message.name, message.data); + assertEquals("This is a string message payload", messageContents.get("publish0")); + assertEquals("This is a byte[] message payload", new String((byte[])messageContents.get("publish1"))); } /** @@ -131,44 +118,37 @@ public void crypto_publish_256() { * the default cipher params and verify correct receipt. */ @Test - public void crypto_publish_alt() { + public void crypto_publish_alt() throws AblyException { /* first, publish some messages */ - Channel tx_publish; - ChannelOptions channelOpts; - String channelName = "persisted:crypto_publish_alt_" + testParams.name; - try { - /* create a key */ - final CipherParams params = Crypto.getDefaultParams(); - - /* create a channel */ - channelOpts = new ChannelOptions() {{ encrypted = true; cipherParams = params; }}; - tx_publish = ably.channels.get(channelName, channelOpts); - - tx_publish.publish("publish0", "This is a string message payload"); - tx_publish.publish("publish1", "This is a byte[] message payload".getBytes()); - } catch(AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final String channelName = "persisted:crypto_publish_alt_" + testParams.name; + + /* create a key */ + final CipherParams params = Crypto.getDefaultParams(); + + /* create a channel */ + final ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; cipherParams = params; }}; + final Channel tx_publish = ably.channels.get(channelName, channelOpts); + + final Helpers.CompletionWaiter publishWaiter = new Helpers.CompletionWaiter(); + tx_publish.publishAsync("publish0", "This is a string message payload", publishWaiter); + tx_publish.publishAsync("publish1", "This is a byte[] message payload".getBytes(), publishWaiter); + assertNull(publishWaiter.waitFor(2)); + + // TODO find a way to know that the history call below will have data available already + // (i.e. that data has made it to the REST endpoint ... we know that we've waitied for our publish requests + // to succeed, but that doesn't necessarily mean the data is yet available to all clients) /* get the history for this channel */ - try { - Channel rx_publish = ably_alt.channels.get(channelName, channelOpts); - PaginatedResult messages = rx_publish.history(null); - assertNotNull("Expected non-null messages", messages); - assertEquals("Expected 2 messages", messages.items().length, 2); - HashMap messageContents = new HashMap(); - /* verify message contents */ - for(Message message : messages.items()) - messageContents.put(message.name, message.data); - assertEquals("Expect publish0 to be expected String", messageContents.get("publish0"), "This is a string message payload"); - assertEquals("Expect publish1 to be expected byte[]", new String((byte[])messageContents.get("publish1")), "This is a byte[] message payload"); - } catch (AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final Channel rx_publish = ably_alt.channels.get(channelName, channelOpts); + final PaginatedResult messages = rx_publish.history(null); + assertNotNull(messages); + assertEquals(2, messages.items().length); + final HashMap messageContents = new HashMap(); + /* verify message contents */ + for (final Message message : messages.items()) + messageContents.put(message.name, message.data); + assertEquals("This is a string message payload", messageContents.get("publish0")); + assertEquals("This is a byte[] message payload", new String((byte[])messageContents.get("publish1"))); } /** @@ -178,34 +158,30 @@ public void crypto_publish_alt() { * is noticed as bad recovered plaintext. */ @Test - public void crypto_publish_key_mismatch() { + public void crypto_publish_key_mismatch() throws AblyException { /* first, publish some messages */ - Channel tx_publish; - String channelName = "persisted:crypto_publish_key_mismatch_" + testParams.name; - try { - /* create a channel */ - ChannelOptions tx_channelOpts = new ChannelOptions() {{ encrypted = true; }}; - tx_publish = ably.channels.get(channelName, tx_channelOpts); - - tx_publish.publish("publish0", "This is a string message payload"); - tx_publish.publish("publish1", "This is a byte[] message payload".getBytes()); - } catch(AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final String channelName = "persisted:crypto_publish_key_mismatch_" + testParams.name; + + /* create a channel */ + final ChannelOptions tx_channelOpts = new ChannelOptions() {{ encrypted = true; }}; + final Channel tx_publish = ably.channels.get(channelName, tx_channelOpts); + + final Helpers.CompletionWaiter publishWaiter = new Helpers.CompletionWaiter(); + tx_publish.publishAsync("publish0", "This is a string message payload", publishWaiter); + tx_publish.publishAsync("publish1", "This is a byte[] message payload".getBytes(), publishWaiter); + assertNull(publishWaiter.waitFor(2)); + + // TODO find a way to know that the history call below will have data available already + // (i.e. that data has made it to the REST endpoint ... we know that we've waitied for our publish requests + // to succeed, but that doesn't necessarily mean the data is yet available to all clients) /* get the history for this channel */ - try { - ChannelOptions rx_channelOpts = new ChannelOptions() {{ encrypted = true; }}; - Channel rx_publish = ably.channels.get(channelName, rx_channelOpts); - - PaginatedResult messages = rx_publish.history(new Param[] { new Param("direction", "backwards"), new Param("limit", "2") }); - for (Message failedMessage: messages.items()) - assertTrue("Check decrypt failure", failedMessage.encoding.contains("cipher")); - } catch (AblyException e) { - fail("Didn't expect exception"); - } + final ChannelOptions rx_channelOpts = new ChannelOptions() {{ encrypted = true; }}; + final Channel rx_publish = ably.channels.get(channelName, rx_channelOpts); + + final PaginatedResult messages = rx_publish.history(new Param[] { new Param("direction", "backwards"), new Param("limit", "2") }); + for (final Message failedMessage: messages.items()) + assertTrue(failedMessage.encoding.contains("cipher")); } /** @@ -214,39 +190,35 @@ public void crypto_publish_key_mismatch() { * does not attempt to decrypt it. */ @Test - public void crypto_send_unencrypted() { - String channelName = "persisted:crypto_send_unencrypted_" + testParams.name; + public void crypto_send_unencrypted() throws AblyException { + final String channelName = "persisted:crypto_send_unencrypted_" + testParams.name; /* first, publish some messages */ - try { - /* create a channel */ - Channel tx_publish = ably.channels.get(channelName); - - tx_publish.publish("publish0", "This is a string message payload"); - tx_publish.publish("publish1", "This is a byte[] message payload".getBytes()); - } catch(AblyException e) { - e.printStackTrace(); - fail("crypto_send_unencrypted: Unexpected exception"); - return; - } + + /* create a channel */ + final Channel tx_publish = ably.channels.get(channelName); + + final Helpers.CompletionWaiter publishWaiter = new Helpers.CompletionWaiter(); + tx_publish.publishAsync("publish0", "This is a string message payload", publishWaiter); + tx_publish.publishAsync("publish1", "This is a byte[] message payload".getBytes(), publishWaiter); + assertNull(publishWaiter.waitFor(2)); + + // TODO find a way to know that the history call below will have data available already + // (i.e. that data has made it to the REST endpoint ... we know that we've waitied for our publish requests + // to succeed, but that doesn't necessarily mean the data is yet available to all clients) /* get the history for this channel */ - try { - ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; }}; - Channel rx_publish = ably.channels.get(channelName, channelOpts); - PaginatedResult messages = rx_publish.history(null); - assertNotNull("Expected non-null messages", messages); - assertEquals("Expected 2 messages", messages.items().length, 2); - HashMap messageContents = new HashMap(); - /* verify message contents */ - for(Message message : messages.items()) - messageContents.put(message.name, message.data); - assertEquals("Expect publish0 to be expected String", messageContents.get("publish0"), "This is a string message payload"); - assertEquals("Expect publish1 to be expected byte[]", new String((byte[])messageContents.get("publish1")), "This is a byte[] message payload"); - } catch (AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + final ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; }}; + final Channel rx_publish = ably.channels.get(channelName, channelOpts); + final PaginatedResult messages = rx_publish.history(null); + assertNotNull(messages); + assertEquals(2, messages.items().length); + final HashMap messageContents = new HashMap(); + + /* verify message contents */ + for (final Message message : messages.items()) + messageContents.put(message.name, message.data); + assertEquals("This is a string message payload", messageContents.get("publish0")); + assertEquals("This is a byte[] message payload", new String((byte[])messageContents.get("publish1"))); } /** @@ -255,38 +227,34 @@ public void crypto_send_unencrypted() { * is unable to decrypt it and leaves it as encoded cipher data */ @Test - public void crypto_send_encrypted_unhandled() { - String channelName = "persisted:crypto_send_encrypted_unhandled_" + testParams.name; + public void crypto_send_encrypted_unhandled() throws AblyException { + final String channelName = "persisted:crypto_send_encrypted_unhandled_" + testParams.name; + /* first, publish some messages */ - try { - /* create a channel */ - ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; }}; - Channel tx_publish = ably.channels.get(channelName, channelOpts); - - tx_publish.publish("publish0", "This is a string message payload"); - tx_publish.publish("publish1", "This is a byte[] message payload".getBytes()); - } catch(AblyException e) { - e.printStackTrace(); - fail("channelpublish_text: Unexpected exception"); - return; - } + + /* create a channel */ + final ChannelOptions channelOpts = new ChannelOptions() {{ encrypted = true; }}; + final Channel tx_publish = ably.channels.get(channelName, channelOpts); + + final Helpers.CompletionWaiter publishWaiter = new Helpers.CompletionWaiter(); + tx_publish.publishAsync("publish0", "This is a string message payload", publishWaiter); + tx_publish.publishAsync("publish1", "This is a byte[] message payload".getBytes(), publishWaiter); + assertNull(publishWaiter.waitFor(2)); + + // TODO find a way to know that the history call below will have data available already + // (i.e. that data has made it to the REST endpoint ... we know that we've waitied for our publish requests + // to succeed, but that doesn't necessarily mean the data is yet available to all clients) /* get the history for this channel */ - try { - Channel rx_publish = ably_alt.channels.get(channelName); - PaginatedResult messages = rx_publish.history(null); - assertNotNull("Expected non-null messages", messages); - assertEquals("Expected 2 messages", messages.items().length, 2); - HashMap messageContents = new HashMap(); - /* verify message contents */ - for(Message message : messages.items()) - messageContents.put(message.name, message); - assertTrue("Expect publish0 to be unprocessed CipherData", messageContents.get("publish0").encoding.contains("cipher")); - assertTrue("Expect publish1 to be unprocessed CipherData", messageContents.get("publish1").encoding.contains("cipher")); - } catch (AblyException e) { - e.printStackTrace(); - fail("crypto_send_encrypted_unhandled: Unexpected exception"); - return; - } + final Channel rx_publish = ably_alt.channels.get(channelName); + final PaginatedResult messages = rx_publish.history(null); + assertNotNull(messages); + assertEquals(2, messages.items().length); + final HashMap messageContents = new HashMap(); + /* verify message contents */ + for (final Message message : messages.items()) + messageContents.put(message.name, message); + assertTrue(messageContents.get("publish0").encoding.contains("cipher")); + assertTrue(messageContents.get("publish1").encoding.contains("cipher")); } } diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestErrorTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestErrorTest.java index 6021d850f..59cf3cdb0 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestErrorTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestErrorTest.java @@ -3,7 +3,9 @@ import fi.iki.elonen.NanoHTTPD; import io.ably.lib.rest.AblyRest; import io.ably.lib.test.common.ParameterizedTest; -import io.ably.lib.types.*; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Param; import io.ably.lib.util.Log; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -14,8 +16,7 @@ import java.util.Map; import java.util.Vector; -import static io.ably.lib.http.HttpUtils.encodeURIComponent; -import static org.junit.Assert.*; +import static org.junit.Assert.assertTrue; public class RestErrorTest extends ParameterizedTest { @@ -64,7 +65,7 @@ public void println(int severity, String tag, String msg, Throwable tr) { AblyRest ably = new AblyRest(opts); /* make a call that will generate an error */ - ably.stats(new Param[]{new Param("message", encodeURIComponent("Test message")), new Param("href", href(12345))}); + ably.stats(new Param[]{new Param("message", "Test message"), new Param("href", href(12345))}); } catch (AblyException e) { /* verify that the expected error message is present */ assertTrue(logMessages.get(0).contains(href(12345))); @@ -93,7 +94,7 @@ public void println(int severity, String tag, String msg, Throwable tr) { AblyRest ably = new AblyRest(opts); /* make a call that will generate an error */ - ably.stats(new Param[]{new Param("message", encodeURIComponent("Test message. See " + href(12345)))}); + ably.stats(new Param[]{new Param("message", "Test message. See " + href(12345))}); } catch (AblyException e) { /* verify that the expected error message is present */ assertTrue(logMessages.get(0).contains(href(12345))); @@ -122,7 +123,7 @@ public void println(int severity, String tag, String msg, Throwable tr) { AblyRest ably = new AblyRest(opts); /* make a call that will generate an error */ - ably.stats(new Param[]{new Param("message", encodeURIComponent("Test message")), new Param("code", "12345")}); + ably.stats(new Param[]{new Param("message", "Test message"), new Param("code", "12345")}); } catch (AblyException e) { /* verify that the expected error message is present */ assertTrue(logMessages.get(0).contains(href(12345))); @@ -136,7 +137,7 @@ private static class SessionHandlerNanoHTTPD extends NanoHTTPD { Map requestHeaders; Map requestParams; - public SessionHandlerNanoHTTPD(int port) { + SessionHandlerNanoHTTPD(int port) { super(port); } diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java index 577dc5561..ad807b569 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.util.Locale; import io.ably.lib.rest.AblyRest; import io.ably.lib.test.common.Setup; @@ -299,7 +300,7 @@ public void init_given_environment() { ClientOptions opts = new ClientOptions(testVars.keys[0].keyStr); opts.environment = givenEnvironment; AblyRest ably = new AblyRest(opts); - assertEquals("Unexpected host mismatch", String.format("%s-%s", givenEnvironment, Defaults.HOST_REST), ably.httpCore.getPrimaryHost()); + assertEquals("Unexpected host mismatch", String.format(Locale.ROOT, "%s-%s", givenEnvironment, Defaults.HOST_REST), ably.httpCore.getPrimaryHost()); } catch (AblyException e) { e.printStackTrace(); fail("init4: Unexpected exception instantiating library"); diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestJWTTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestJWTTest.java index 91787d4ba..a998924f7 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestJWTTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestJWTTest.java @@ -1,18 +1,26 @@ package io.ably.lib.test.rest; -import static org.junit.Assert.*; - -import io.ably.lib.http.*; -import io.ably.lib.test.common.Setup.Key; -import org.junit.Test; - +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpHelpers; import io.ably.lib.rest.AblyRest; -import io.ably.lib.types.*; -import io.ably.lib.rest.Auth.*; +import io.ably.lib.rest.Auth; import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.test.common.Setup.Key; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.PaginatedResult; +import io.ably.lib.types.Param; +import io.ably.lib.types.Stats; +import org.junit.Test; import java.io.UnsupportedEncodingException; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + public class RestJWTTest extends ParameterizedTest { private Key key = testVars.keys[0]; @@ -114,9 +122,9 @@ public void auth_jwt_request_authcallback() { try { final AblyRest restJWTRequester = new AblyRest(createOptions(testVars.keys[0].keyStr)); final boolean[] callbackCalled = new boolean[] { false }; - TokenCallback authCallback = new TokenCallback() { + Auth.TokenCallback authCallback = new Auth.TokenCallback() { @Override - public Object getTokenRequest(TokenParams params) throws AblyException { + public Object getTokenRequest(Auth.TokenParams params) throws AblyException { callbackCalled[0] = true; return restJWTRequester.auth.requestToken(params, null); } diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestPushTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestPushTest.java index 2d6215a81..d271e75a0 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestPushTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestPushTest.java @@ -1,25 +1,12 @@ package io.ably.lib.test.rest; import com.google.gson.JsonObject; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.fail; - -import org.junit.*; -import org.junit.rules.Timeout; - -import java.util.Arrays; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; - import io.ably.lib.debug.DebugOptions; +import io.ably.lib.push.PushBase.ChannelSubscription; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.CompletionListener; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.DeviceDetails; -import io.ably.lib.push.PushBase.ChannelSubscription; import io.ably.lib.test.common.Helpers; import io.ably.lib.test.common.Helpers.CompletionWaiter; import io.ably.lib.test.common.Helpers.MessageWaiter; @@ -31,6 +18,19 @@ import io.ably.lib.types.PaginatedResult; import io.ably.lib.types.Param; import io.ably.lib.util.JsonUtils; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.junit.Assert.assertEquals; public class RestPushTest extends ParameterizedTest { private static AblyRest rest; @@ -242,7 +242,7 @@ public void then(Helpers.AblyFunction get) throws AblyException { new Param("transportType", "ablyChannel"), new Param("channel", "pushenabled:push_admin_publish-ok"), new Param("ablyKey", testVars.keys[0].keyStr), - new Param("ablyUrl", String.format("%s%s:%d", rest.httpCore.scheme, rest.httpCore.getPrimaryHost(), rest.httpCore.port)), + new Param("ablyUrl", String.format(Locale.ROOT, "%s%s:%d", rest.httpCore.scheme, rest.httpCore.getPrimaryHost(), rest.httpCore.port)), }, testPayload, null)); @@ -496,7 +496,7 @@ class TestCase extends TestCases.Base { private final Param[] params; private final DeviceDetails[] expectedRemoved; - public TestCase(String name, String expectedError, Param[] params, DeviceDetails[] expectedRemoved) { + TestCase(String name, String expectedError, Param[] params, DeviceDetails[] expectedRemoved) { super(name, expectedError); this.params = Param.push(params, "fullWait", "true"); this.expectedRemoved = expectedRemoved; @@ -746,7 +746,7 @@ class TestCase extends TestCases.Base { private final Param[] params; private final ChannelSubscription[] expectedRemoved; - public TestCase(String name, String expectedError, Param[] params, ChannelSubscription[] expectedRemoved) { + TestCase(String name, String expectedError, Param[] params, ChannelSubscription[] expectedRemoved) { super(name, expectedError); this.params = Param.push(params, "fullWait", "true"); this.expectedRemoved = expectedRemoved; diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestTokenTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestTokenTest.java index f7b2d3ab7..2e3921d46 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestTokenTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestTokenTest.java @@ -1,11 +1,6 @@ package io.ably.lib.test.rest; -import static org.junit.Assert.*; - -import org.junit.Before; - import io.ably.lib.rest.AblyRest; -import io.ably.lib.rest.Auth; import io.ably.lib.rest.Auth.AuthOptions; import io.ably.lib.rest.Auth.TokenDetails; import io.ably.lib.rest.Auth.TokenParams; @@ -14,9 +9,14 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.Capability; import io.ably.lib.types.ClientOptions; - +import org.junit.Before; import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + public class RestTokenTest extends ParameterizedTest { private static String permitAll; diff --git a/lib/src/test/java/io/ably/lib/test/util/AblyCommonsReader.java b/lib/src/test/java/io/ably/lib/test/util/AblyCommonsReader.java new file mode 100644 index 000000000..aa8b30f32 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/test/util/AblyCommonsReader.java @@ -0,0 +1,52 @@ +package io.ably.lib.test.util; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +public class AblyCommonsReader { + private static final String BASE_URL = "https://raw.githubusercontent.com/ably/ably-common/refs/heads/main/"; + private static Gson gson = new Gson(); + + public static String readAsString(String path) throws Exception { + URL url = new URL(BASE_URL + path); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() != 200) { + throw new RuntimeException("Failed : HTTP error code : " + conn.getResponseCode()); + } + + BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream()))); + StringBuilder sb = new StringBuilder(); + String output; + while ((output = br.readLine()) != null) { + sb.append(output); + } + + conn.disconnect(); + + return sb.toString(); + } + + public static JsonObject readAsJsonObject(String path) { + try { + return JsonParser.parseString(readAsString(path)).getAsJsonObject(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static T read(String path, Class classOfT) { + try { + return gson.fromJson(readAsString(path), classOfT); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/test/util/EmptyPlatformAgentProvider.java b/lib/src/test/java/io/ably/lib/test/util/EmptyPlatformAgentProvider.java new file mode 100644 index 000000000..fbc3ea59b --- /dev/null +++ b/lib/src/test/java/io/ably/lib/test/util/EmptyPlatformAgentProvider.java @@ -0,0 +1,10 @@ +package io.ably.lib.test.util; + +import io.ably.lib.util.PlatformAgentProvider; + +public class EmptyPlatformAgentProvider implements PlatformAgentProvider { + @Override + public String createPlatformAgent() { + return null; + } +} diff --git a/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java b/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java index 0f59a2196..15a4cbfad 100644 --- a/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java +++ b/lib/src/test/java/io/ably/lib/test/util/MockWebsocketFactory.java @@ -1,10 +1,16 @@ package io.ably.lib.test.util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + import io.ably.lib.transport.ConnectionManager; import io.ably.lib.transport.ITransport; import io.ably.lib.transport.WebSocketTransport; import io.ably.lib.types.AblyException; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.PresenceMessage; import io.ably.lib.types.ProtocolMessage; /** @@ -17,6 +23,14 @@ enum SendBehaviour { block, fail } + + enum ReceiveBehaviour { + allow, + block, + blockAndQueue, + fail + } + enum ConnectBehaviour { allow, fail @@ -35,18 +49,22 @@ public interface HostTransform { } SendBehaviour sendBehaviour = SendBehaviour.allow; + ReceiveBehaviour receiveBehaviour = ReceiveBehaviour.allow; ConnectBehaviour connectBehaviour = ConnectBehaviour.allow; - MessageFilter messageFilter = null; + MessageFilter sendMessageFilter = null; + MessageFilter receiveMessageFilter = null; HostFilter hostFilter = null; HostTransform hostTransform = null; + final List blockedReceiveQueue = new ArrayList<>(); + public ITransport lastCreatedTransport = null; public static class TransformParams extends ITransport.TransportParams { private HostTransform hostTransform; TransformParams(ITransport.TransportParams src, HostTransform hostTransform) { - super(src.getClientOptions()); + super(src.getClientOptions(), new EmptyPlatformAgentProvider()); this.hostTransform = hostTransform; this.host = hostTransform.transformHost(src.getHost()); this.port = src.getPort(); @@ -63,26 +81,55 @@ public ITransport getTransport(final ITransport.TransportParams transportParams, return lastCreatedTransport; } + //only use this when you know when transport is created - just for tests + public MockWebsocketTransport getCreatedTransport() { + return (MockWebsocketTransport) lastCreatedTransport; + } + public void blockSend(MessageFilter filter) { - messageFilter = filter; + sendMessageFilter = filter; sendBehaviour = SendBehaviour.block; } + + /* + We cannot prevent server sending us messages from here so instead, this will block processing messages from this + point. That is they will not be triggering connection manager's onMessage which will help simulate some conditions + * */ + public void blockReceiveProcessing(MessageFilter filter) { + receiveMessageFilter = filter; + receiveBehaviour = ReceiveBehaviour.block; + } + + /* + We cannot prevent server sending us messages from here so instead, this will block processing messages from this + point. That is they will not be triggering connection manager's onMessage which will help simulate some conditions + * */ + public void blockReceiveProcessingAndQueueBlockedMessages(MessageFilter filter) { + receiveMessageFilter = filter; + receiveBehaviour = ReceiveBehaviour.blockAndQueue; + } + + public void allowReceiveProcessing(MessageFilter filter) { + receiveMessageFilter = filter; + receiveBehaviour = ReceiveBehaviour.allow; + } + public void blockSend() { blockSend(null); } public void allowSend(MessageFilter filter) { - messageFilter = filter; + sendMessageFilter = filter; sendBehaviour = SendBehaviour.allow; } public void allowSend() { allowSend(null);} public void failSend(MessageFilter filter) { - messageFilter = filter; + sendMessageFilter = filter; sendBehaviour = SendBehaviour.fail; } public void failSend() { failSend(null); } - public void setMessageFilter(MessageFilter filter) { - messageFilter = filter; + public void setSendMessageFilter(MessageFilter filter) { + sendMessageFilter = filter; } public void failConnect(HostFilter filter) { @@ -98,33 +145,62 @@ public void setHostTransform(HostTransform transform) { /* * Special transport class that allows blocking send() and other operations */ - private class MockWebsocketTransport extends WebSocketTransport { + public class MockWebsocketTransport extends WebSocketTransport { private final TransportParams givenTransportParams; private final TransportParams transformedTransportParams; + //Sent presence or normal messages + private final List sentMessages = new ArrayList<>(); private MockWebsocketTransport(TransportParams givenTransportParams, TransportParams transformedTransportParams, ConnectionManager connectionManager) { super(transformedTransportParams, connectionManager); this.givenTransportParams = givenTransportParams; this.transformedTransportParams = transformedTransportParams; + turnOffActivityCheckIfPingListenerIsNotSupported(); + } + + public List getSentMessages() { + return sentMessages; + } + + public List getPublishedMessages() { + return sentMessages.stream().filter(protocolMessage -> protocolMessage.action == ProtocolMessage.Action.message).collect(Collectors.toList()); + } + + public List getSentPresenceMessages() { + final List protocolMessages = sentMessages.stream() + .filter(protocolMessage -> protocolMessage.action == ProtocolMessage.Action.presence) + .collect(Collectors.toList()); + final List presenceMessages = new ArrayList<>(); + protocolMessages.forEach(protocolMessage -> { + Collections.addAll(presenceMessages, protocolMessage.presence); + }); + return presenceMessages; + } + + public void clearPublishedMessages() { + sentMessages.clear(); } @Override public void send(ProtocolMessage msg) throws AblyException { + if (msg.action == ProtocolMessage.Action.message || msg.action == ProtocolMessage.Action.presence){ + sentMessages.add(msg); + } switch (sendBehaviour) { case allow: - if (messageFilter == null || messageFilter.matches(msg)) { + if (sendMessageFilter == null || sendMessageFilter.matches(msg)) { super.send(msg); } break; case block: - if (messageFilter == null || messageFilter.matches(msg)) { + if (sendMessageFilter == null || sendMessageFilter.matches(msg)) { /* do nothing */ } else { super.send(msg); } break; case fail: - if (messageFilter == null || messageFilter.matches(msg)) { + if (sendMessageFilter == null || sendMessageFilter.matches(msg)) { throw AblyException.fromErrorInfo(new ErrorInfo("Mock", 40000)); } else { super.send(msg); @@ -133,6 +209,45 @@ public void send(ProtocolMessage msg) throws AblyException { } } + @Override + public void receive(ProtocolMessage msg) throws AblyException { + + switch (receiveBehaviour) { + case allow: + for (ProtocolMessage queuedMessage: blockedReceiveQueue) { + if (receiveMessageFilter == null || receiveMessageFilter.matches(queuedMessage)) { + super.receive(queuedMessage); + } + } + if (receiveMessageFilter == null || receiveMessageFilter.matches(msg)) { + super.receive(msg); + } + break; + case block: + if (receiveMessageFilter == null || receiveMessageFilter.matches(msg)) { + //process queued messages + } else { + super.receive(msg); + } + break; + case blockAndQueue: + if (receiveMessageFilter == null || receiveMessageFilter.matches(msg)) { + blockedReceiveQueue.add(msg); + } else { + super.receive(msg); + } + break; + case fail: + if (receiveMessageFilter == null || receiveMessageFilter.matches(msg)) { + throw AblyException.fromErrorInfo(new ErrorInfo("Mock", 40000)); + } else { + super.receive(msg); + } + break; + } + } + + @Override public void connect(ConnectListener connectListener) { String host = givenTransportParams.getHost(); diff --git a/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java b/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java index fb84338a6..8e5930d7f 100644 --- a/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java +++ b/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java @@ -3,7 +3,6 @@ import fi.iki.elonen.NanoHTTPD; import fi.iki.elonen.router.RouterNanoHTTPD; -import java.io.IOException; import java.io.InputStream; import java.util.Map; diff --git a/lib/src/test/java/io/ably/lib/test/util/TestCases.java b/lib/src/test/java/io/ably/lib/test/util/TestCases.java index 41b45a2ae..57aefe952 100644 --- a/lib/src/test/java/io/ably/lib/test/util/TestCases.java +++ b/lib/src/test/java/io/ably/lib/test/util/TestCases.java @@ -1,14 +1,10 @@ package io.ably.lib.test.util; -import java.util.ArrayList; -import java.util.regex.Pattern; - import io.ably.lib.test.common.Helpers; import io.ably.lib.types.AblyException; import io.ably.lib.util.Log; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import java.util.ArrayList; public class TestCases { final ArrayList testCases; diff --git a/lib/src/test/java/io/ably/lib/test/util/TokenServer.java b/lib/src/test/java/io/ably/lib/test/util/TokenServer.java index 6aa2fac74..0c99d7188 100644 --- a/lib/src/test/java/io/ably/lib/test/util/TokenServer.java +++ b/lib/src/test/java/io/ably/lib/test/util/TokenServer.java @@ -164,5 +164,5 @@ private static Response error2Response(ErrorInfo errorInfo) { } private final AblyRest ably; - private static final String MIME_JSON = "application/json"; + private static final String MIME_JSON = "application/json"; } diff --git a/lib/src/test/java/io/ably/lib/transport/DefaultsTest.java b/lib/src/test/java/io/ably/lib/transport/DefaultsTest.java index 3dcf45318..cdeec5dce 100644 --- a/lib/src/test/java/io/ably/lib/transport/DefaultsTest.java +++ b/lib/src/test/java/io/ably/lib/transport/DefaultsTest.java @@ -8,8 +8,8 @@ public class DefaultsTest { @Test - public void versions() { - assertThat(Defaults.ABLY_VERSION, is("1.2")); + public void protocol_version_CSV2() { + assertThat(Defaults.ABLY_PROTOCOL_VERSION, is("2")); } @Test diff --git a/lib/src/test/java/io/ably/lib/transport/SafeSSLSocketFactoryTest.java b/lib/src/test/java/io/ably/lib/transport/SafeSSLSocketFactoryTest.java new file mode 100644 index 000000000..7feae94cd --- /dev/null +++ b/lib/src/test/java/io/ably/lib/transport/SafeSSLSocketFactoryTest.java @@ -0,0 +1,73 @@ +package io.ably.lib.transport; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; + +import java.io.IOException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class SafeSSLSocketFactoryTest { + SafeSSLSocketFactory safeSSLSocketFactory; + + @Before + public void setup() throws NoSuchAlgorithmException, KeyManagementException { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + safeSSLSocketFactory = new SafeSSLSocketFactory(sslContext.getSocketFactory()); + } + + @Test + public void should_not_use_unsafe_tls_protocols() throws IOException { + // given + Set unsafeProtocols = new HashSet<>(Arrays.asList( + "SSLv3", + "TLSv1", + "TLSv1.1" + )); + + // when + SSLSocket sslSocket = (SSLSocket) safeSSLSocketFactory.createSocket(); + + // then + for (String enabledProtocol : sslSocket.getEnabledProtocols()) { + Assert.assertFalse( + "Protocol " + enabledProtocol + " is unsafe and should not be enabled", + unsafeProtocols.contains(enabledProtocol) + ); + + } + } + + @Test + public void should_use_at_least_one_safe_tls_protocol() throws IOException { + // given + Set safeProtocols = new HashSet<>(Arrays.asList( + "TLSv1.2", + "TLSv1.3" + )); + + // when + SSLSocket sslSocket = (SSLSocket) safeSSLSocketFactory.createSocket(); + + // then + boolean isUsingSafeProtocol = containsAnySafeProtocol(sslSocket.getEnabledProtocols(), safeProtocols); + Assert.assertTrue("No safe protocols are enabled", isUsingSafeProtocol); + } + + private boolean containsAnySafeProtocol(String[] protocols, Set safeProtocols) { + for (String protocol : protocols) { + if (safeProtocols.contains(protocol)) { + return true; + } + } + return false; + } +} diff --git a/lib/src/test/java/io/ably/lib/types/CapabilityTest.java b/lib/src/test/java/io/ably/lib/types/CapabilityTest.java new file mode 100644 index 000000000..74efbee91 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/types/CapabilityTest.java @@ -0,0 +1,22 @@ +package io.ably.lib.types; + +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class CapabilityTest { + + @Test + public void c14n_sendNull_returnsNull() throws AblyException { + String returnedValue = Capability.c14n(null); + + assertNull(returnedValue); + } + + @Test + public void c14n_sendEmptyString_returnsEmptyString() throws AblyException { + String returnedValue = Capability.c14n(""); + + assertNull(returnedValue); + } +} diff --git a/lib/src/test/java/io/ably/lib/types/MessageTest.java b/lib/src/test/java/io/ably/lib/types/MessageTest.java index 3abeb9fe6..18dcf81d7 100644 --- a/lib/src/test/java/io/ably/lib/types/MessageTest.java +++ b/lib/src/test/java/io/ably/lib/types/MessageTest.java @@ -6,7 +6,13 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.ably.lib.types.Message.Serializer; +import io.ably.lib.util.Serialisation; import org.junit.Test; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.ByteArrayOutputStream; +import java.util.HashMap; public class MessageTest { @@ -46,4 +52,182 @@ public void serialize_message_with_name_and_data() { assertEquals("test-data", serializedObject.get("data").getAsString()); assertEquals("test-name", serializedObject.get("name").getAsString()); } + + @Test + public void serialize_message_with_serial() { + // Given + Message message = new Message("test-name", "test-data"); + message.clientId = "test-client-id"; + message.connectionKey = "test-key"; + message.action = MessageAction.MESSAGE_CREATE; + message.serial = "01826232498871-001@abcdefghij:001"; + + // When + JsonElement serializedElement = serializer.serialize(message, null, null); + + // Then + JsonObject serializedObject = serializedElement.getAsJsonObject(); + assertEquals("test-client-id", serializedObject.get("clientId").getAsString()); + assertEquals("test-key", serializedObject.get("connectionKey").getAsString()); + assertEquals("test-data", serializedObject.get("data").getAsString()); + assertEquals("test-name", serializedObject.get("name").getAsString()); + assertEquals(0, serializedObject.get("action").getAsInt()); + assertEquals("01826232498871-001@abcdefghij:001", serializedObject.get("serial").getAsString()); + } + + @Test + public void deserialize_message_with_serial() throws Exception { + // Given + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("clientId", "test-client-id"); + jsonObject.addProperty("data", "test-data"); + jsonObject.addProperty("name", "test-name"); + jsonObject.addProperty("action", 0); + jsonObject.addProperty("serial", "01826232498871-001@abcdefghij:001"); + + // When + Message message = Message.fromEncoded(jsonObject, new ChannelOptions()); + + // Then + assertEquals("test-client-id", message.clientId); + assertEquals("test-data", message.data); + assertEquals("test-name", message.name); + assertEquals(MessageAction.MESSAGE_CREATE, message.action); + assertEquals("01826232498871-001@abcdefghij:001", message.serial); + } + + @Test + public void serialize_message_with_operation() { + // Given + Message message = new Message("test-name", "test-data"); + message.clientId = "test-client-id"; + message.connectionKey = "test-key"; + message.refSerial = "test-ref-serial"; + message.refType = "test-ref-type"; + Message.Operation operation = new Message.Operation(); + operation.clientId = "operation-client-id"; + operation.description = "operation-description"; + operation.metadata = new HashMap<>(); + operation.metadata.put("key1", "value1"); + operation.metadata.put("key2", "value2"); + message.operation = operation; + + // When + JsonElement serializedElement = serializer.serialize(message, null, null); + + // Then + JsonObject serializedObject = serializedElement.getAsJsonObject(); + assertEquals("test-client-id", serializedObject.get("clientId").getAsString()); + assertEquals("test-key", serializedObject.get("connectionKey").getAsString()); + assertEquals("test-data", serializedObject.get("data").getAsString()); + assertEquals("test-name", serializedObject.get("name").getAsString()); + assertEquals("test-ref-serial", serializedObject.get("refSerial").getAsString()); + assertEquals("test-ref-type", serializedObject.get("refType").getAsString()); + JsonObject operationObject = serializedObject.getAsJsonObject("operation"); + assertEquals("operation-client-id", operationObject.get("clientId").getAsString()); + assertEquals("operation-description", operationObject.get("description").getAsString()); + JsonObject metadataObject = operationObject.getAsJsonObject("metadata"); + assertEquals("value1", metadataObject.get("key1").getAsString()); + assertEquals("value2", metadataObject.get("key2").getAsString()); + } + + @Test + public void deserialize_message_with_operation() throws Exception { + // Given + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("clientId", "test-client-id"); + jsonObject.addProperty("data", "test-data"); + jsonObject.addProperty("name", "test-name"); + jsonObject.addProperty("refSerial", "test-ref-serial"); + jsonObject.addProperty("refType", "test-ref-type"); + jsonObject.addProperty("connectionKey", "test-key"); + JsonObject operationObject = new JsonObject(); + operationObject.addProperty("clientId", "operation-client-id"); + operationObject.addProperty("description", "operation-description"); + JsonObject metadataObject = new JsonObject(); + metadataObject.addProperty("key1", "value1"); + metadataObject.addProperty("key2", "value2"); + operationObject.add("metadata", metadataObject); + jsonObject.add("operation", operationObject); + + // When + Message message = Message.fromEncoded(jsonObject, new ChannelOptions()); + + // Then + assertEquals("test-client-id", message.clientId); + assertEquals("test-data", message.data); + assertEquals("test-name", message.name); + assertEquals("test-ref-serial", message.refSerial); + assertEquals("test-ref-type", message.refType); + assertEquals("test-key", message.connectionKey); + assertEquals("operation-client-id", message.operation.clientId); + assertEquals("operation-description", message.operation.description); + assertEquals("value1", message.operation.metadata.get("key1")); + assertEquals("value2", message.operation.metadata.get("key2")); + } + + @Test + public void deserialize_message_with_unknown_action() throws Exception { + // Given + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("clientId", "test-client-id"); + jsonObject.addProperty("data", "test-data"); + jsonObject.addProperty("name", "test-name"); + jsonObject.addProperty("action", 10); + jsonObject.addProperty("serial", "01826232498871-001@abcdefghij:001"); + + // When + Message message = Message.fromEncoded(jsonObject, new ChannelOptions()); + + // Then + assertEquals("test-client-id", message.clientId); + assertEquals("test-data", message.data); + assertEquals("test-name", message.name); + assertNull(message.action); + assertEquals("01826232498871-001@abcdefghij:001", message.serial); + } + + @Test + public void serialize_and_deserialize_with_msgpack() throws Exception { + // Given + Message message = new Message("test-name", "test-data"); + message.clientId = "test-client-id"; + message.connectionKey = "test-key"; + message.refSerial = "test-ref-serial"; + message.refType = "test-ref-type"; + message.action = MessageAction.MESSAGE_CREATE; + message.serial = "01826232498871-001@abcdefghij:001"; + Message.Operation operation = new Message.Operation(); + operation.clientId = "operation-client-id"; + operation.description = "operation-description"; + operation.metadata = new HashMap<>(); + operation.metadata.put("key1", "value1"); + operation.metadata.put("key2", "value2"); + message.operation = operation; + + // When Encode to MessagePack + ByteArrayOutputStream out = new ByteArrayOutputStream(); + MessagePacker packer = Serialisation.msgpackPackerConfig.newPacker(out); + message.writeMsgpack(packer); + packer.close(); + + // Decode from MessagePack + MessageUnpacker unpacker = Serialisation.msgpackUnpackerConfig.newUnpacker(out.toByteArray()); + Message unpacked = Message.fromMsgpack(unpacker); + unpacker.close(); + + // Then + assertEquals("test-client-id", unpacked.clientId); + assertEquals("test-key", unpacked.connectionKey); + assertEquals("test-data", unpacked.data); + assertEquals("test-name", unpacked.name); + assertEquals("test-ref-serial", unpacked.refSerial); + assertEquals("test-ref-type", unpacked.refType); + assertEquals(MessageAction.MESSAGE_CREATE, unpacked.action); + assertEquals("01826232498871-001@abcdefghij:001", unpacked.serial); + assertEquals("operation-client-id", unpacked.operation.clientId); + assertEquals("operation-description", unpacked.operation.description); + assertEquals("value1", unpacked.operation.metadata.get("key1")); + assertEquals("value2", unpacked.operation.metadata.get("key2")); + } } diff --git a/lib/src/test/java/io/ably/lib/types/RecoveryKeyContextTest.java b/lib/src/test/java/io/ably/lib/types/RecoveryKeyContextTest.java new file mode 100644 index 000000000..85d9e0127 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/types/RecoveryKeyContextTest.java @@ -0,0 +1,59 @@ +package io.ably.lib.types; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class RecoveryKeyContextTest { + + /** + * Spec: RTN16i, RTN16f, RTN16j + */ + @Test + public void should_encode_recovery_key_context_object() { + String expectedRecoveryKey = + "{\"connectionKey\":\"uniqueKey\",\"msgSerial\":1,\"channelSerials\":{\"channel1\":\"1\",\"channel2\":\"2\",\"channel3\":\"3\"}}"; + Map serials = new HashMap<>(); + serials.put("channel1", "1"); + serials.put("channel2", "2"); + serials.put("channel3", "3"); + RecoveryKeyContext recoveryKey = new RecoveryKeyContext("uniqueKey", 1, serials); + String encodedRecoveryKey = recoveryKey.encode(); + assertEquals(expectedRecoveryKey, encodedRecoveryKey); + } + + /** + * Spec: RTN16i, RTN16f, RTN16j + */ + @Test + public void should_decode_recoverykey_to_recoveryKeyContextObject() { + String recoveryKey = + "{\"connectionKey\":\"key2\",\"msgSerial\":5,\"channelSerials\":{\"channel1\":\"98\",\"channel2\":\"32\",\"channel3\":\"09\"}}"; + RecoveryKeyContext recoveryKeyContext = RecoveryKeyContext.decode(recoveryKey); + assertEquals("key2", recoveryKeyContext.getConnectionKey()); + assertEquals(5, recoveryKeyContext.getMsgSerial()); + Map expectedChannelSerials = new HashMap() + {{ + put("channel1", "98"); + put("channel2", "32"); + put("channel3", "09"); + }}; + assertEquals(expectedChannelSerials, recoveryKeyContext.getChannelSerials()); + } + + /** + * Spec: RTN16i, RTN16f, RTN16j + */ + @Test + public void should_return_null_recovery_context_while_decoding_faulty_recovery_key() { + String recoveryKey = + "{\"connectionKey\":\"key2\",\"msgSerial\":\"incorrectStringSerial\",\"channelSerials\":{\"channel1\":\"98\",\"channel2\":\"32\",\"channel3\":\"09\"}}"; + RecoveryKeyContext recoveryKeyContext = RecoveryKeyContext.decode(recoveryKey); + assertNull(recoveryKeyContext); + } + +} diff --git a/lib/src/test/java/io/ably/lib/types/SummaryTest.java b/lib/src/test/java/io/ably/lib/types/SummaryTest.java new file mode 100644 index 000000000..13490f395 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/types/SummaryTest.java @@ -0,0 +1,256 @@ +package io.ably.lib.types; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + + +public class SummaryTest { + + @Test + public void testAsSummaryUniqueV1_SingleEntry() { + JsonObject jsonObject = new JsonObject(); + JsonObject entryValue = new JsonObject(); + entryValue.addProperty("total", 5); + JsonArray clientIds = new JsonArray(); + clientIds.add("uniqueClient1"); + clientIds.add("uniqueClient2"); + clientIds.add("uniqueClient3"); + clientIds.add("uniqueClient4"); + clientIds.add("uniqueClient5"); + entryValue.add("clientIds", clientIds); + jsonObject.add("😄️️️", entryValue); + + Map result = Summary.asSummaryUniqueV1(jsonObject); + + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey("😄️️️")); + + SummaryClientIdList summary = result.get("😄️️️"); + assertNotNull(summary); + assertEquals(5, summary.total); + assertEquals(5, summary.clientIds.size()); + assertTrue(summary.clientIds.contains("uniqueClient1")); + assertTrue(summary.clientIds.contains("uniqueClient2")); + assertTrue(summary.clientIds.contains("uniqueClient3")); + assertTrue(summary.clientIds.contains("uniqueClient4")); + assertTrue(summary.clientIds.contains("uniqueClient5")); + } + + @Test + public void testAsSummaryUniqueV1_InvalidJsonStructure() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("invalidKey", "invalidValue"); + + try { + Summary.asSummaryUniqueV1(jsonObject); + fail("Should throw IllegalStateException"); + } catch (IllegalStateException exception) { + assertNotNull(exception); + } + } + + @Test + public void testAsSummaryDistinctV1_EmptyJsonObject() { + JsonObject jsonObject = new JsonObject(); + + Map result = Summary.asSummaryDistinctV1(jsonObject); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + public void testAsSummaryDistinctV1_SingleEntry() { + JsonObject jsonObject = new JsonObject(); + JsonObject entryValue = new JsonObject(); + entryValue.addProperty("total", 3); + JsonArray clientIds = new JsonArray(); + clientIds.add("client1"); + clientIds.add("client2"); + clientIds.add("client3"); + entryValue.add("clientIds", clientIds); + jsonObject.add("😄️️️", entryValue); + + Map result = Summary.asSummaryDistinctV1(jsonObject); + + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey("😄️️️")); + + SummaryClientIdList summary = result.get("😄️️️"); + assertNotNull(summary); + assertEquals(3, summary.total); + assertEquals(3, summary.clientIds.size()); + assertTrue(summary.clientIds.contains("client1")); + assertTrue(summary.clientIds.contains("client2")); + assertTrue(summary.clientIds.contains("client3")); + } + + @Test + public void testAsSummaryDistinctV1_InvalidJsonStructure() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("invalidKey", "invalidValue"); + + try { + Summary.asSummaryDistinctV1(jsonObject); + fail("Should throw ClassCastException"); + } catch (IllegalStateException exception) { + assertNotNull(exception); + } + } + + @Test + public void testAsSummaryFlagV1_SingleEntry() { + JsonObject entryValue = new JsonObject(); + entryValue.addProperty("total", 3); + JsonArray clientIds = new JsonArray(); + clientIds.add("client1"); + clientIds.add("client2"); + clientIds.add("client3"); + entryValue.add("clientIds", clientIds); + + SummaryClientIdList result = Summary.asSummaryFlagV1(entryValue); + + assertNotNull(result); + assertEquals(3, result.total); + assertEquals(3, result.clientIds.size()); + assertTrue(result.clientIds.contains("client1")); + assertTrue(result.clientIds.contains("client2")); + assertTrue(result.clientIds.contains("client3")); + } + + @Test + public void testAsSummaryMultipleV1_EmptyJsonObject() { + JsonObject jsonObject = new JsonObject(); + + Map result = Summary.asSummaryMultipleV1(jsonObject); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + public void testAsSummaryMultipleV1_SingleEntry() { + JsonObject jsonObject = new JsonObject(); + JsonObject entryValue = new JsonObject(); + entryValue.addProperty("total", 4); + JsonObject clientIds = new JsonObject(); + clientIds.addProperty("client1", 2); + clientIds.addProperty("client2", 1); + clientIds.addProperty("client3", 1); + entryValue.add("clientIds", clientIds); + jsonObject.add("😄️️️", entryValue); + + Map result = Summary.asSummaryMultipleV1(jsonObject); + + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey("😄️️️")); + + SummaryClientIdCounts summary = result.get("😄️️️"); + assertNotNull(summary); + assertEquals(4, summary.total); + assertEquals(3, summary.clientIds.size()); + assertEquals(2, summary.clientIds.get("client1").intValue()); + assertEquals(1, summary.clientIds.get("client2").intValue()); + assertEquals(1, summary.clientIds.get("client3").intValue()); + } + + @Test + public void testAsSummaryMultipleV1_MultipleEntries() { + JsonObject jsonObject = new JsonObject(); + + JsonObject entryValue1 = new JsonObject(); + entryValue1.addProperty("total", 5); + JsonObject clientIds1 = new JsonObject(); + clientIds1.addProperty("clientA", 3); + clientIds1.addProperty("clientB", 2); + entryValue1.add("clientIds", clientIds1); + jsonObject.add("😄️️️", entryValue1); + + JsonObject entryValue2 = new JsonObject(); + entryValue2.addProperty("total", 2); + JsonObject clientIds2 = new JsonObject(); + clientIds2.addProperty("clientX", 1); + clientIds2.addProperty("clientY", 1); + entryValue2.add("clientIds", clientIds2); + jsonObject.add("👍️️️️️️", entryValue2); + + Map result = Summary.asSummaryMultipleV1(jsonObject); + + assertNotNull(result); + assertEquals(2, result.size()); + + SummaryClientIdCounts summaryA = result.get("😄️️️"); + assertNotNull(summaryA); + assertEquals(5, summaryA.total); + assertEquals(2, summaryA.clientIds.size()); + assertEquals(3, (int) summaryA.clientIds.get("clientA")); + assertEquals(2, (int) summaryA.clientIds.get("clientB")); + + SummaryClientIdCounts summaryB = result.get("👍️️️️️️"); + assertNotNull(summaryB); + assertEquals(2, summaryB.total); + assertEquals(2, summaryB.clientIds.size()); + assertEquals(1, (int) summaryB.clientIds.get("clientX")); + assertEquals(1, (int) summaryB.clientIds.get("clientY")); + } + + @Test + public void testAsSummaryMultipleV1_InvalidJsonStructure() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("invalidKey", "invalidValue"); + + try { + Summary.asSummaryMultipleV1(jsonObject); + fail("Should throw IllegalStateException"); + } catch (IllegalStateException exception) { + assertNotNull(exception); + } + } + + @Test + public void testAsSummaryTotalV1_ValidJsonObject() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("total", 10); + + SummaryTotal result = Summary.asSummaryTotalV1(jsonObject); + + assertNotNull(result); + assertEquals(10, result.total); + } + + @Test + public void testAsSummaryTotalV1_EmptyJsonObject() { + JsonObject jsonObject = new JsonObject(); + + try { + Summary.asSummaryTotalV1(jsonObject); + fail("Should throw NullPointerException"); + } catch (NullPointerException exception) { + assertNotNull(exception); + } + } + + @Test + public void testAsSummaryTotalV1_InvalidJsonStructure() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("invalidKey", "invalidValue"); + + try { + Summary.asSummaryTotalV1(jsonObject); + fail("Should throw IllegalStateException"); + } catch (Exception exception) { + assertNotNull(exception); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/util/AgentHeaderCreatorTest.java b/lib/src/test/java/io/ably/lib/util/AgentHeaderCreatorTest.java new file mode 100644 index 000000000..71c926f33 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/util/AgentHeaderCreatorTest.java @@ -0,0 +1,110 @@ +package io.ably.lib.util; + +import io.ably.lib.test.util.EmptyPlatformAgentProvider; +import io.ably.lib.transport.Defaults; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertTrue; + +public class AgentHeaderCreatorTest { + private final static String PREDEFINED_AGENTS = Defaults.ABLY_AGENT_VERSION; + private final PlatformAgentProvider emptyPlatformAgentProvider = new EmptyPlatformAgentProvider(); + + @Test + public void should_create_default_header_if_there_are_no_additional_agents() { + // given + Map agents = new HashMap<>(); + + // when + String agentHeaderValue = AgentHeaderCreator.create(agents, emptyPlatformAgentProvider); + + // then + assertMatchingAgentHeaders(PREDEFINED_AGENTS, agentHeaderValue); + } + + @Test + public void should_create_default_header_if_additional_agents_are_null() { + // given + + // when + String agentHeaderValue = AgentHeaderCreator.create(null, emptyPlatformAgentProvider); + + // then + assertMatchingAgentHeaders(PREDEFINED_AGENTS, agentHeaderValue); + } + + @Test + public void should_create_header_with_appended_agents_if_they_are_provided() { + // given + Map agents = new HashMap<>(); + agents.put("library", "1.0.1"); + agents.put("other", "0.8.2"); + + // when + String agentHeaderValue = AgentHeaderCreator.create(agents, emptyPlatformAgentProvider); + + // then + assertMatchingAgentHeaders(PREDEFINED_AGENTS + " library/1.0.1 other/0.8.2", agentHeaderValue); + } + + @Test + public void should_create_header_with_appended_agents_without_versions() { + // given + Map agents = new HashMap<>(); + agents.put("library", "1.0.1"); + agents.put("no-version", null); + + // when + String agentHeaderValue = AgentHeaderCreator.create(agents, emptyPlatformAgentProvider); + + // then + assertMatchingAgentHeaders(PREDEFINED_AGENTS + " library/1.0.1 no-version", agentHeaderValue); + } + + @Test + public void should_create_header_with_platform_agent_if_it_is_provided() { + // given + Map agents = new HashMap<>(); + agents.put("library", "1.0.1"); + PlatformAgentProvider androidPlatformAgentProvider = new PlatformAgentProvider() { + @Override + public String createPlatformAgent() { + return "android/25"; + } + }; + + // when + String agentHeaderValue = AgentHeaderCreator.create(agents, androidPlatformAgentProvider); + + // then + assertMatchingAgentHeaders(PREDEFINED_AGENTS + " android/25 library/1.0.1", agentHeaderValue); + } + + private void assertMatchingAgentHeaders(String expectedAgentHeader, String actualAgentHeader) { + assertPredefinedAgentsAreAtTheStart(actualAgentHeader); + assertAllExpectedAgentsArePresentInActualAgents(expectedAgentHeader, actualAgentHeader); + } + + private void assertPredefinedAgentsAreAtTheStart(String actualAgentHeader) { + assertTrue( + actualAgentHeader + " does not start with the library predefined agents", + actualAgentHeader.startsWith(PREDEFINED_AGENTS) + ); + } + + private void assertAllExpectedAgentsArePresentInActualAgents(String expectedAgentHeader, String actualAgentHeader) { + List actualAgents = Arrays.asList(actualAgentHeader.split(" ")); + String[] expectedAgents = expectedAgentHeader.split(" "); + for (String expectedAgent : expectedAgents) { + assertTrue( + actualAgentHeader + " does not include " + expectedAgent, + actualAgents.contains(expectedAgent) + ); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/util/CryptoMessageTest.java b/lib/src/test/java/io/ably/lib/util/CryptoMessageTest.java index 0412bdaeb..702188cf9 100644 --- a/lib/src/test/java/io/ably/lib/util/CryptoMessageTest.java +++ b/lib/src/test/java/io/ably/lib/util/CryptoMessageTest.java @@ -9,19 +9,19 @@ import java.io.IOException; import java.security.NoSuchAlgorithmException; +import io.ably.lib.test.util.AblyCommonsReader; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; -import io.ably.lib.test.common.Setup; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import io.ably.lib.types.Message; -import io.ably.lib.util.Base64Coder; -import io.ably.lib.util.Crypto; import io.ably.lib.util.Crypto.CipherParams; +@Ignore("FIXME: Initialization is failing") @RunWith(Parameterized.class) public class CryptoMessageTest { public enum FixtureSet { @@ -33,7 +33,7 @@ public enum FixtureSet { private final String fileName; public final String cipherName; - private FixtureSet(final int keySize) { + FixtureSet(final int keySize) { if (keySize < 1) { throw new IllegalArgumentException("keySize"); } @@ -60,8 +60,8 @@ private FixtureSet(final int keySize) { } private CryptoTestData loadTestData() throws IOException { - return (CryptoTestData)Setup.loadJson( - "ably-common/test-resources/" + fileName + ".json", + return (CryptoTestData) AblyCommonsReader.read( + "test-resources/" + fileName + ".json", CryptoTestData.class); } } @@ -86,7 +86,9 @@ public void testDecrypt() throws NoSuchAlgorithmException, CloneNotSupportedExce final String algorithm = testData.algorithm; final CipherParams params = Crypto.getParams(algorithm, fixtureSet.key, fixtureSet.iv); - final ChannelOptions options = new ChannelOptions() {{encrypted = true; cipherParams = params;}}; + final ChannelOptions options = new ChannelOptions(); + options.encrypted = true; + options.cipherParams = params; for(final CryptoTestItem item : testData.items) { final Message plain = item.encoded; @@ -114,7 +116,10 @@ public void testEncrypt() throws NoSuchAlgorithmException, CloneNotSupportedExce final CipherParams params = Crypto.getParams(algorithm, fixtureSet.key, fixtureSet.iv); for(final CryptoTestItem item : testData.items) { - final ChannelOptions options = new ChannelOptions() {{encrypted = true; cipherParams = params;}}; + final ChannelOptions options = new ChannelOptions(); + options.encrypted = true; + options.cipherParams = params; + final Message plain = item.encoded; final Message encrypted = item.encrypted; assertThat(encrypted.encoding, endsWith(fixtureSet.cipherName + "/base64")); diff --git a/lib/src/test/java/io/ably/lib/util/CryptoTest.java b/lib/src/test/java/io/ably/lib/util/CryptoTest.java index 60673bbf1..d1af39cb7 100644 --- a/lib/src/test/java/io/ably/lib/util/CryptoTest.java +++ b/lib/src/test/java/io/ably/lib/util/CryptoTest.java @@ -10,6 +10,7 @@ import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import org.junit.Ignore; import org.junit.Test; import org.msgpack.core.MessagePack; import org.msgpack.core.MessagePacker; @@ -17,15 +18,14 @@ import com.google.gson.stream.JsonWriter; import io.ably.lib.types.AblyException; -import io.ably.lib.types.ChannelOptions; -import io.ably.lib.util.Crypto.ChannelCipher; import io.ably.lib.util.Crypto.CipherParams; +import io.ably.lib.util.Crypto.EncryptingChannelCipher; import io.ably.lib.util.CryptoMessageTest.FixtureSet; public class CryptoTest { /** * Test Crypto.getDefaultParams. - * @see RSE1 + * @see RSE1 */ @Test public void cipher_params() throws AblyException, NoSuchAlgorithmException { @@ -56,10 +56,10 @@ public void cipher_params() throws AblyException, NoSuchAlgorithmException { ); byte[] plaintext = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; - ChannelCipher channelCipher1 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params1; }}); - ChannelCipher channelCipher2 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params2; }}); - ChannelCipher channelCipher3 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params3; }}); - ChannelCipher channelCipher4 = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params4; }}); + EncryptingChannelCipher channelCipher1 = Crypto.createChannelEncipher(params1); + EncryptingChannelCipher channelCipher2 = Crypto.createChannelEncipher(params2); + EncryptingChannelCipher channelCipher3 = Crypto.createChannelEncipher(params3); + EncryptingChannelCipher channelCipher4 = Crypto.createChannelEncipher(params4); byte[] ciphertext1 = channelCipher1.encrypt(plaintext); byte[] ciphertext2 = channelCipher2.encrypt(plaintext); @@ -84,8 +84,9 @@ public void cipher_params() throws AblyException, NoSuchAlgorithmException { * * Equivalent to the following in ably-cocoa: * testEncryptAndDecrypt in Spec/CryptoTest.m - * @throws IOException + * @throws IOException */ + @Ignore("FIXME: NullPointerException should be fixed") @Test public void encryptAndDecrypt() throws NoSuchAlgorithmException, AblyException, IOException { final FixtureSet fixtureSet = FixtureSet.AES256; @@ -111,10 +112,10 @@ public void encryptAndDecrypt() throws NoSuchAlgorithmException, AblyException, writer.name("keyLength"); writer.value(256); - + writer.name("key"); writer.value(Base64Coder.encodeToString(fixtureSet.key)); - + writer.name("iv"); writer.value(Base64Coder.encodeToString(fixtureSet.iv)); @@ -125,18 +126,19 @@ public void encryptAndDecrypt() throws NoSuchAlgorithmException, AblyException, for (int i=1; i<=maxLength; i++) { // We need to create a new ChannelCipher for each message we encode, // so that our IV gets used (being start of CBC chain). - final ChannelCipher cipher = Crypto.getCipher(new ChannelOptions() {{ encrypted=true; cipherParams=params; }}); + final EncryptingChannelCipher encipher = Crypto.createChannelEncipher(params); + final Crypto.DecryptingChannelCipher decipher = Crypto.createChannelDecipher(params); // Encrypt i bytes from the start of the message data. final byte[] encoded = Arrays.copyOfRange(message, 0, i); - final byte[] encrypted = cipher.encrypt(encoded); + final byte[] encrypted = encipher.encrypt(encoded); // Add encryption result to results in format ready for fixture. writeResult(writer, "byte 1 to " + i, encoded, encrypted, fixtureSet.cipherName); // Decrypt the encrypted data and verify the result is the same as what // we submitted for encryption. - final byte[] verify = cipher.decrypt(encrypted); + final byte[] verify = decipher.decrypt(encrypted); assertArrayEquals(verify, encoded); } writer.endArray(); @@ -197,4 +199,10 @@ private static byte[] msgPacked(final String name, final byte[] data, final Stri return out.toByteArray(); } + + @Test + public void getRandomId() { + String randomId = Crypto.getRandomId(); + assertEquals(12, randomId.length()); + } } diff --git a/lib/src/test/java/io/ably/lib/util/ParamsUtilsTest.java b/lib/src/test/java/io/ably/lib/util/ParamsUtilsTest.java new file mode 100644 index 000000000..04df947c2 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/util/ParamsUtilsTest.java @@ -0,0 +1,53 @@ +package io.ably.lib.util; + +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Param; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ParamsUtilsTest { + + @Test + public void enrichParams_creates_params_if_original_are_null() throws AblyException { + ClientOptions opts = new ClientOptions("secret_key"); + opts.pushFullWait = true; + opts.addRequestIds = true; + + Param[] newParams = ParamsUtils.enrichParams(null, opts); + + assertEquals(2, newParams.length); + assertTrue(Param.containsKey(newParams, "fullWait")); + assertTrue(Param.containsKey(newParams, "request_id")); + } + + @Test + public void enrichParams_add_params_to_existing_ones() throws AblyException { + ClientOptions opts = new ClientOptions("secret_key"); + opts.pushFullWait = true; + opts.addRequestIds = true; + + Param[] originParams = Param.array(new Param("propertyName", "value")); + Param[] newParams = ParamsUtils.enrichParams(originParams, opts); + + assertEquals(3, newParams.length); + assertTrue(Param.containsKey(newParams, "propertyName")); + assertTrue(Param.containsKey(newParams, "fullWait")); + assertTrue(Param.containsKey(newParams, "request_id")); + } + + @Test + public void enrichParams_produce_only_requested_params() throws AblyException { + ClientOptions opts = new ClientOptions("secret_key"); + opts.addRequestIds = true; + + Param[] originParams = Param.array(new Param("propertyName", "value")); + Param[] newParams = ParamsUtils.enrichParams(originParams, opts); + + assertEquals(2,newParams.length); + assertTrue(Param.containsKey(newParams, "propertyName")); + assertTrue(Param.containsKey(newParams, "request_id")); + } +} diff --git a/lib/src/test/java/io/ably/lib/util/ReconnectionStrategyTest.java b/lib/src/test/java/io/ably/lib/util/ReconnectionStrategyTest.java new file mode 100644 index 000000000..4569d1790 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/util/ReconnectionStrategyTest.java @@ -0,0 +1,50 @@ +package io.ably.lib.util; + +import org.junit.Test; + +import java.util.Arrays; + +import static io.ably.lib.test.common.Helpers.assertTimeoutBetween; + +public class ReconnectionStrategyTest { + + @Test + public void calculateRetryTimeoutUsingIncrementalBackoffAndJitter() { + + int[] retryAttempts = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + int initialTimeoutValue = 15; // timeout value in seconds + + int[] retryTimeouts = Arrays.stream(retryAttempts).map(attempt -> ReconnectionStrategy.getRetryTime(initialTimeoutValue, attempt)).toArray(); + + assertTimeoutBetween(retryTimeouts[0], 12d, 15d); + assertTimeoutBetween(retryTimeouts[1], 16d, 20d); + assertTimeoutBetween(retryTimeouts[2], 20d, 25d); + + for (int i = 3; i < retryTimeouts.length; i++) { + assertTimeoutBetween(retryTimeouts[i], 24d, 30d); + } + + for (int retryAttempt : retryAttempts) { + + int retryTimeout = ReconnectionStrategy.getRetryTime(initialTimeoutValue, retryAttempt); + Bounds bounds = calculateRetryBounds(retryAttempt, initialTimeoutValue); + + assertTimeoutBetween(retryTimeout, bounds.lower, bounds.upper); + } + } + + public Bounds calculateRetryBounds(int retryAttempt, int initialTimeout) { + double upperBound = Math.min((retryAttempt + 2) / 3d, 2d) * initialTimeout; + double lowerBound = 0.8 * upperBound; + return new Bounds(lowerBound, upperBound); + } + + static class Bounds { + Double lower; + Double upper; + Bounds(Double lower, Double upper) { + this.lower = lower; + this.upper = upper; + } + } +} diff --git a/lib/src/test/resources/ably-common b/lib/src/test/resources/ably-common deleted file mode 160000 index b2eeb4e1e..000000000 --- a/lib/src/test/resources/ably-common +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b2eeb4e1efa8de83693649314c5d575a096fdb78 diff --git a/lib/src/test/resources/local/testAppSpec.json b/lib/src/test/resources/local/testAppSpec.json index 979c2cca9..5c6b7fb5c 100644 --- a/lib/src/test/resources/local/testAppSpec.json +++ b/lib/src/test/resources/local/testAppSpec.json @@ -19,8 +19,11 @@ }, { "capability": "{\"persisted:text_protocol:channel0\":[\"publish\",\"subscribe\",\"history\"],\"persisted:text_protocol:channel1\":[\"publish\",\"subscribe\",\"history\"],\"persisted:binary_protocol:channel0\":[\"publish\",\"subscribe\",\"history\"],\"persisted:binary_protocol:channel1\":[\"publish\",\"subscribe\",\"history\"],\"persisted:*\":[\"subscribe\",\"history\"]}" - } - ], + }, + { + "capability": "{ \"[*]*\":[\"*\"] }" + } + ], "namespaces": [ { "id": "persisted", @@ -29,7 +32,11 @@ { "id": "pushenabled", "pushEnabled": true - } + }, + { + "id": "mutable", + "mutableMessages": true + } ], "channels": [ { @@ -78,4 +85,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts new file mode 100644 index 000000000..5b45ce92e --- /dev/null +++ b/live-objects/build.gradle.kts @@ -0,0 +1,49 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + `java-library` + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.maven.publish) +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly(project(":java")) + implementation(libs.bundles.common) + implementation(libs.coroutine.core) + + testImplementation(project(":java")) + testImplementation(kotlin("test")) + testImplementation(libs.bundles.kotlin.tests) +} + +tasks.withType().configureEach { + testLogging { + exceptionFormat = TestExceptionFormat.FULL + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} + +tasks.register("runLiveObjectUnitTests") { + filter { + includeTestsMatching("io.ably.lib.objects.unit.*") + } +} + +tasks.register("runLiveObjectIntegrationTests") { + filter { + includeTestsMatching("io.ably.lib.objects.integration.*") + // Exclude the base integration test class + excludeTestsMatching("io.ably.lib.objects.integration.setup.IntegrationTest") + } +} + +kotlin { + explicitApi() +} diff --git a/live-objects/gradle.properties b/live-objects/gradle.properties new file mode 100644 index 000000000..29fa6bdb7 --- /dev/null +++ b/live-objects/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=live-objects +POM_NAME=Live Objects plugin for Ably Pub/Sub SDK +POM_DESCRIPTION=Live Objects plugin for Ably Pub/Sub SDK +POM_PACKAGING=jar diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultObjectsPlugin.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultObjectsPlugin.kt new file mode 100644 index 000000000..856c15a59 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultObjectsPlugin.kt @@ -0,0 +1,35 @@ +package io.ably.lib.objects + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ProtocolMessage +import java.util.concurrent.ConcurrentHashMap + +public class DefaultObjectsPlugin(private val adapter: ObjectsAdapter) : ObjectsPlugin { + + private val objects = ConcurrentHashMap() + + override fun getInstance(channelName: String): RealtimeObjects { + return objects.getOrPut(channelName) { DefaultRealtimeObjects(channelName, adapter) } + } + + override fun handle(msg: ProtocolMessage) { + val channelName = msg.channel + objects[channelName]?.handle(msg) + } + + override fun handleStateChange(channelName: String, state: ChannelState, hasObjects: Boolean) { + objects[channelName]?.handleStateChange(state, hasObjects) + } + + override fun dispose(channelName: String) { + objects.remove(channelName) + ?.dispose(clientError("Channel has been released using channels.release()")) + } + + override fun dispose() { + objects.values.forEach { + it.dispose(clientError("AblyClient has been closed using client.close()")) + } + objects.clear() + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt new file mode 100644 index 000000000..0b00a1680 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt @@ -0,0 +1,298 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.serialization.gson +import io.ably.lib.objects.state.ObjectsStateChange +import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.map.LiveMapValue +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.util.Log +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.concurrent.CancellationException + +/** + * Default implementation of RealtimeObjects interface. + * Provides the core functionality for managing live objects on a channel. + */ +internal class DefaultRealtimeObjects(internal val channelName: String, internal val adapter: ObjectsAdapter): RealtimeObjects { + private val tag = "DefaultRealtimeObjects" + /** + * @spec RTO3 - Objects pool storing all live objects by object ID + */ + internal val objectsPool = ObjectsPool(this) + + internal var state = ObjectsState.Initialized + + /** + * @spec RTO4 - Used for handling object messages and object sync messages + */ + private val objectsManager = ObjectsManager(this) + + /** + * Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. + */ + private val sequentialScope = + CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) + + /** + * Event bus for handling incoming object messages sequentially. + * Processes messages inside [incomingObjectsHandler] job created using [sequentialScope]. + */ + private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED) + private val incomingObjectsHandler: Job + + /** + * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. + */ + internal val asyncScope = ObjectsAsyncScope(channelName) + + init { + incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() + } + + override fun getRoot(): LiveMap = runBlocking { getRootAsync() } + + override fun createMap(): LiveMap = createMap(mutableMapOf()) + + override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) } + + override fun createCounter(): LiveCounter = createCounter(0) + + override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) } + + override fun getRootAsync(callback: ObjectsCallback) { + asyncScope.launchWithCallback(callback) { getRootAsync() } + } + + override fun createMapAsync(callback: ObjectsCallback) = createMapAsync(mutableMapOf(), callback) + + override fun createMapAsync(entries: MutableMap, callback: ObjectsCallback) { + asyncScope.launchWithCallback(callback) { createMapAsync(entries) } + } + + override fun createCounterAsync(callback: ObjectsCallback) = createCounterAsync(0, callback) + + override fun createCounterAsync(initialValue: Number, callback: ObjectsCallback) { + asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) } + } + + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = + objectsManager.on(event, listener) + + override fun off(listener: ObjectsStateChange.Listener) = objectsManager.off(listener) + + override fun offAll() = objectsManager.offAll() + + private suspend fun getRootAsync(): LiveMap = withContext(sequentialScope.coroutineContext) { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + adapter.ensureAttached(channelName) + objectsManager.ensureSynced(state) + objectsPool.get(ROOT_OBJECT_ID) as LiveMap + } + + private suspend fun createMapAsync(entries: MutableMap): LiveMap { + adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO11c, RTO11d, RTO11e + + if (entries.keys.any { it.isEmpty() }) { // RTO11f2 + throw invalidInputError("Map keys should not be empty") + } + + // RTO11f4 - Create initial value operation + val initialMapValue = DefaultLiveMap.initialValue(entries) + + // RTO11f5 - Create initial value JSON string + val initialValueJSONString = gson.toJson(initialMapValue) + + // RTO11f8 - Create object ID from initial value + val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString) + + // Create ObjectMessage with the operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = objectId, + map = initialMapValue.map, + nonce = nonce, + initialValue = initialValueJSONString, + ) + ) + + // RTO11g - Publish the message + publish(arrayOf(msg)) + + // RTO11h - Check if object already exists in pool, otherwise create a zero-value object using the sequential scope + return objectsPool.get(objectId) as? LiveMap ?: withContext(sequentialScope.coroutineContext) { + objectsPool.createZeroValueObjectIfNotExists(objectId) as LiveMap + } + } + + private suspend fun createCounterAsync(initialValue: Number): LiveCounter { + adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO12c, RTO12d, RTO12e + + // Validate input parameter + if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) { + throw invalidInputError("Counter value should be a valid number") + } + + // RTO12f2 + val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) + // RTO12f3 - Create initial value operation + val initialValueJSONString = gson.toJson(initialCounterValue) + + // RTO12f6- Create object ID from initial value + val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString) + + // Create ObjectMessage with the operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = objectId, + counter = initialCounterValue.counter, + nonce = nonce, + initialValue = initialValueJSONString + ) + ) + + // RTO12g - Publish the message + publish(arrayOf(msg)) + + // RTO12h - Check if object already exists in pool, otherwise create a zero-value object using the sequential scope + return objectsPool.get(objectId) as? LiveCounter ?: withContext(sequentialScope.coroutineContext) { + objectsPool.createZeroValueObjectIfNotExists(objectId) as LiveCounter + } + } + + /** + * Spec: RTO11f8, RTO12f6 + */ + private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair { + val nonce = generateNonce() + val msTimestamp = ServerTime.getCurrentTime(adapter) // RTO16 - Get server time for nonce generation + return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce) + } + + /** + * Spec: RTO15 + */ + internal suspend fun publish(objectMessages: Array) { + // RTO15b, RTL6c - Ensure that the channel is in a valid state for publishing + adapter.throwIfUnpublishableState(channelName) + adapter.ensureMessageSizeWithinLimit(objectMessages) + // RTO15e - Must construct the ProtocolMessage as per RTO15e1, RTO15e2, RTO15e3 + val protocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) + protocolMessage.state = objectMessages + // RTO15f, RTO15g - Send the ProtocolMessage using the adapter and capture success/failure + adapter.sendAsync(protocolMessage) + } + + /** + * Handles a ProtocolMessage containing proto action as `object` or `object_sync`. + * @spec RTL1 - Processes incoming object messages and object sync messages + */ + internal fun handle(protocolMessage: ProtocolMessage) { + // RTL15b - Set channel serial for OBJECT messages + adapter.setChannelSerial(channelName, protocolMessage) + + if (protocolMessage.state == null || protocolMessage.state.isEmpty()) { + Log.w(tag, "Received ProtocolMessage with null or empty objects, ignoring") + return + } + + objectsEventBus.tryEmit(protocolMessage) + } + + /** + * Initializes the handler for incoming object messages and object sync messages. + * Processes the messages sequentially to ensure thread safety and correct order of operations. + * + * @spec OM2 - Populates missing fields from parent protocol message + */ + private fun initializeHandlerForIncomingObjectMessages(): Job { + return sequentialScope.launch { + objectsEventBus.collect { protocolMessage -> + // OM2 - Populate missing fields from parent + val objects = protocolMessage.state.filterIsInstance() + .mapIndexed { index, objMsg -> + objMsg.copy( + connectionId = objMsg.connectionId ?: protocolMessage.connectionId, // OM2c + timestamp = objMsg.timestamp ?: protocolMessage.timestamp, // OM2e + id = objMsg.id ?: (protocolMessage.id + ':' + index) // OM2a + ) + } + + try { + when (protocolMessage.action) { + ProtocolMessage.Action.`object` -> objectsManager.handleObjectMessages(objects) + ProtocolMessage.Action.object_sync -> objectsManager.handleObjectSyncMessages( + objects, + protocolMessage.channelSerial + ) + else -> Log.w(tag, "Ignoring protocol message with unhandled action: ${protocolMessage.action}") + } + } catch (exception: Exception) { + // Skip current message if an error occurs, don't rethrow to avoid crashing the collector + Log.e(tag, "Error handling objects message with protocolMsg id ${protocolMessage.id}", exception) + } + } + } + } + + internal fun handleStateChange(state: ChannelState, hasObjects: Boolean) { + sequentialScope.launch { + when (state) { + ChannelState.attached -> { + Log.v(tag, "Objects.onAttached() channel=$channelName, hasObjects=$hasObjects") + + // RTO4a + val fromInitializedState = this@DefaultRealtimeObjects.state == ObjectsState.Initialized + if (hasObjects || fromInitializedState) { + // should always start a new sync sequence if we're in the initialized state, no matter the HAS_OBJECTS flag value. + // this guarantees we emit both "syncing" -> "synced" events in that order. + objectsManager.startNewSync(null) + } + + // RTO4b + if (!hasObjects) { + // if no HAS_OBJECTS flag received on attach, we can end sync sequence immediately and treat it as no objects on a channel. + // reset the objects pool to its initial state, and emit update events so subscribers to root object get notified about changes. + objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2 + objectsManager.clearSyncObjectsDataPool() // RTO4b3 + objectsManager.clearBufferedObjectOperations() // RTO4b5 + // defer the state change event until the next tick if we started a new sequence just now due to being in initialized state. + // this allows any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. + objectsManager.endSync(fromInitializedState) // RTO4b4 + } + } + ChannelState.detached, + ChannelState.failed -> { + // do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states + objectsPool.clearObjectsData(false) + objectsManager.clearSyncObjectsDataPool() + } + + else -> { + // No action needed for other states + } + } + } + } + + // Dispose of any resources associated with this RealtimeObjects instance + fun dispose(cause: AblyException) { + val disposeReason = CancellationException().apply { initCause(cause) } + incomingObjectsHandler.cancel(disposeReason) // objectsEventBus automatically garbage collected when collector is cancelled + objectsPool.dispose() + objectsManager.dispose() + // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines + sequentialScope.coroutineContext.cancelChildren(disposeReason) + asyncScope.cancel(disposeReason) // cancel all ongoing callbacks + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt new file mode 100644 index 000000000..17612b043 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt @@ -0,0 +1,19 @@ +package io.ably.lib.objects + +internal enum class ErrorCode(public val code: Int) { + BadRequest(40_000), + InternalError(50_000), + MaxMessageSizeExceeded(40_009), + InvalidObject(92_000), + // LiveMap specific error codes + InvalidInputParams(40_003), + MapValueDataTypeUnsupported(40_013), + // Channel mode and state validation error codes + ChannelModeRequired(40_024), + ChannelStateError(90_001), +} + +internal enum class HttpStatusCode(public val code: Int) { + BadRequest(400), + InternalServerError(500), +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt new file mode 100644 index 000000000..7b169ff8f --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt @@ -0,0 +1,184 @@ +package io.ably.lib.objects + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.ProtocolMessage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Spec: RTO15g + */ +internal suspend fun ObjectsAdapter.sendAsync(message: ProtocolMessage) = suspendCancellableCoroutine { continuation -> + try { + connectionManager.send(message, clientOptions.queueMessages, object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo) { + continuation.resumeWithException(ablyException(reason)) + } + }) + } catch (e: Exception) { + continuation.resumeWithException(e) + } +} + +internal suspend fun ObjectsAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation -> + try { + getChannel(channelName).attach(object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo) { + continuation.resumeWithException(ablyException(reason)) + } + }) + } catch (e: Exception) { + continuation.resumeWithException(e) + } +} + +/** + * Retrieves the channel modes for a specific channel. + * This method returns the modes that are set for the specified channel. + * + * @param channelName the name of the channel for which to retrieve the modes + * @return the array of channel modes for the specified channel, or null if the channel is not found + * Spec: RTO2a, RTO2b + */ +internal fun ObjectsAdapter.getChannelModes(channelName: String): Array? { + val channel = getChannel(channelName) + + // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set + channel.modes?.let { modes -> + if (modes.isNotEmpty()) { + return modes + } + } + + // RTO2b - otherwise as a best effort use user provided channel options + channel.options?.let { options -> + if (options.hasModes()) { + return options.modes + } + } + return null +} + +/** + * Spec: RTO15d + */ +internal fun ObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Array) { + val maximumAllowedSize = connectionManager.maxMessageSize + val objectsTotalMessageSize = objectMessages.sumOf { it.size() } + if (objectsTotalMessageSize > maximumAllowedSize) { + throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", + ErrorCode.MaxMessageSizeExceeded) + } +} + +internal fun ObjectsAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { + if (protocolMessage.action != ProtocolMessage.Action.`object`) return + val channelSerial = protocolMessage.channelSerial + if (channelSerial.isNullOrEmpty()) return + getChannel(channelName).properties.channelSerial = channelSerial +} + +internal suspend fun ObjectsAdapter.ensureAttached(channelName: String) { + val channel = getChannel(channelName) + when (val currentChannelStatus = channel.state) { + ChannelState.initialized -> attachAsync(channelName) + ChannelState.attached -> return + ChannelState.attaching -> { + val attachDeferred = CompletableDeferred() + getChannel(channelName).once { + when(it.current) { + ChannelState.attached -> attachDeferred.complete(Unit) + else -> { + val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " + + "error: ${it.reason}", ErrorCode.ChannelStateError) + attachDeferred.completeExceptionally(exception) + } + } + } + if (channel.state == ChannelState.attached) { + attachDeferred.complete(Unit) + } + attachDeferred.await() + } + else -> + throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ErrorCode.ChannelStateError) + } +} + +// Spec: RTLO4b1, RTLO4b2 +internal fun ObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) + throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) +} + +internal fun ObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { + throwIfEchoMessagesDisabled() + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) + throwIfMissingChannelMode(channelName, ChannelMode.object_publish) +} + +internal fun ObjectsAdapter.throwIfUnpublishableState(channelName: String) { + if (!connectionManager.isActive) { + throw ablyException(connectionManager.stateErrorInfo) + } + throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) +} + +// Spec: RTO2 +private fun ObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { + val channelModes = getChannelModes(channelName) + if (channelModes == null || !channelModes.contains(channelMode)) { + // Spec: RTO2a2, RTO2b2 + throw ablyException("\"${channelMode.name}\" channel mode must be set for this operation", ErrorCode.ChannelModeRequired) + } +} + +private fun ObjectsAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { + val currentState = getChannel(channelName).state + if (currentState == null || channelStates.contains(currentState)) { + throw ablyException("Channel is in invalid state: $currentState", ErrorCode.ChannelStateError) + } +} + +internal fun ObjectsAdapter.throwIfEchoMessagesDisabled() { + if (!clientOptions.echoMessages) { + throw clientError("\"echoMessages\" client option must be enabled for this operation") + } +} + +internal class Binary(val data: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Binary) return false + return data.contentEquals(other.data) + } + + override fun hashCode(): Int { + return data.contentHashCode() + } +} + +internal fun Binary.size(): Int { + return data.size +} + +internal data class CounterCreatePayload( + val counter: ObjectsCounter +) + +internal data class MapCreatePayload( + val map: ObjectsMap +) diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt new file mode 100644 index 000000000..64a040ddc --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt @@ -0,0 +1,79 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.type.ObjectType +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 + +internal class ObjectId private constructor( + internal val type: ObjectType, + private val hash: String, + private val timestampMs: Long +) { + /** + * Converts ObjectId to string representation. + * Spec: RTO6b1 + */ + override fun toString(): String { + return "${type.value}:$hash@$timestampMs" + } + + companion object { + + /** + * Spec: RTO14 + */ + internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId { + val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8) + // RTO14b - Hash the initial value and nonce to create a unique identifier + val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash) + val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes) + + return ObjectId(objectType, urlSafeHash, msTimeStamp) + } + + /** + * Creates ObjectId instance from hashed object id string. + */ + internal fun fromString(objectId: String): ObjectId { + if (objectId.isEmpty()) { + throw objectError("Invalid object id: $objectId") + } + + // Parse format: type:hash@msTimestamp + val parts = objectId.split(':') + if (parts.size != 2) { + throw objectError("Invalid object id: $objectId") + } + + val (typeStr, rest) = parts + + val type = when (typeStr) { + "map" -> ObjectType.Map + "counter" -> ObjectType.Counter + else -> throw objectError("Invalid object type in object id: $objectId") + } + + val hashAndTimestamp = rest.split('@') + if (hashAndTimestamp.size != 2) { + throw objectError("Invalid object id: $objectId") + } + + val hash = hashAndTimestamp[0] + + if (hash.isEmpty()) { + throw objectError("Invalid object id: $objectId") + } + + val msTimestampStr = hashAndTimestamp[1] + + val msTimestamp = try { + msTimestampStr.toLong() + } catch (e: NumberFormatException) { + throw objectError("Invalid object id: $objectId", e) + } + + return ObjectId(type, hash, msTimestamp) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt new file mode 100644 index 000000000..0415cc8d5 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt @@ -0,0 +1,459 @@ +package io.ably.lib.objects + +import com.google.gson.JsonObject + +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import io.ably.lib.objects.serialization.ObjectDataJsonSerializer +import io.ably.lib.objects.serialization.gson + +/** + * An enum class representing the different actions that can be performed on an object. + * Spec: OOP2 + */ +internal enum class ObjectOperationAction(val code: Int) { + MapCreate(0), + MapSet(1), + MapRemove(2), + CounterCreate(3), + CounterInc(4), + ObjectDelete(5), + Unknown(-1); // code for unknown value during deserialization +} + +/** + * An enum class representing the conflict-resolution semantics used by a Map object. + * Spec: OMP2 + */ +internal enum class ObjectsMapSemantics(val code: Int) { + LWW(0), + Unknown(-1); // code for unknown value during deserialization +} + +/** + * An ObjectData represents a value in an object on a channel. + * Spec: OD1 + */ +@JsonAdapter(ObjectDataJsonSerializer::class) +internal data class ObjectData( + /** + * A reference to another object, used to support composable object structures. + * Spec: OD2a + */ + val objectId: String? = null, + + /** + * String, number, boolean or binary - a concrete value of the object + * Spec: OD2c + */ + val value: ObjectValue? = null, +) + +/** + * Represents a value that can be a String, Number, Boolean, Binary, JsonObject or JsonArray. + * Provides compile-time type safety through sealed class pattern. + * Spec: OD2c + */ +internal sealed class ObjectValue { + abstract val value: Any + + data class String(override val value: kotlin.String) : ObjectValue() + data class Number(override val value: kotlin.Number) : ObjectValue() + data class Boolean(override val value: kotlin.Boolean) : ObjectValue() + data class Binary(override val value: io.ably.lib.objects.Binary) : ObjectValue() + data class JsonObject(override val value: com.google.gson.JsonObject) : ObjectValue() + data class JsonArray(override val value: com.google.gson.JsonArray) : ObjectValue() +} + +/** + * A MapOp describes an operation to be applied to a Map object. + * Spec: OMO1 + */ +internal data class ObjectsMapOp( + /** + * The key of the map entry to which the operation should be applied. + * Spec: OMO2a + */ + val key: String, + + /** + * The data that the map entry should contain if the operation is a MAP_SET operation. + * Spec: OMO2b + */ + val data: ObjectData? = null +) + +/** + * A CounterOp describes an operation to be applied to a Counter object. + * Spec: OCO1 + */ +internal data class ObjectsCounterOp( + /** + * The data value that should be added to the counter + * Spec: OCO2a + */ + val amount: Double? = null +) + +/** + * A MapEntry represents the value at a given key in a Map object. + * Spec: ME1 + */ +internal data class ObjectsMapEntry( + /** + * Indicates whether the map entry has been removed. + * Spec: OME2a + */ + val tombstone: Boolean? = null, + + /** + * The serial value of the latest operation that was applied to the map entry. + * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a null value for it + * and treat it as the "earliest possible" serial for comparison purposes. + * Spec: OME2b + */ + val timeserial: String? = null, + + /** + * A timestamp from the [timeserial] field. Only present if [tombstone] is `true` + * Spec: OME2d + */ + val serialTimestamp: Long? = null, + + /** + * The data that represents the value of the map entry. + * Spec: OME2c + */ + val data: ObjectData? = null +) + +/** + * An ObjectMap object represents a map of key-value pairs. + * Spec: OMP1 + */ +internal data class ObjectsMap( + /** + * The conflict-resolution semantics used by the map object. + * Spec: OMP3a + */ + val semantics: ObjectsMapSemantics? = null, + + /** + * The map entries, indexed by key. + * Spec: OMP3b + */ + val entries: Map? = null +) + +/** + * An ObjectCounter object represents an incrementable and decrementable value + * Spec: OCN1 + */ +internal data class ObjectsCounter( + /** + * The value of the counter + * Spec: OCN2a + */ + val count: Double? = null +) + +/** + * An ObjectOperation describes an operation to be applied to an object on a channel. + * Spec: OOP1 + */ +internal data class ObjectOperation( + /** + * Defines the operation to be applied to the object. + * Spec: OOP3a + */ + val action: ObjectOperationAction, + + /** + * The object ID of the object on a channel to which the operation should be applied. + * Spec: OOP3b + */ + val objectId: String, + + /** + * The payload for the operation if it is an operation on a Map object type. + * i.e. MAP_SET, MAP_REMOVE. + * Spec: OOP3c + */ + val mapOp: ObjectsMapOp? = null, + + /** + * The payload for the operation if it is an operation on a Counter object type. + * i.e. COUNTER_INC. + * Spec: OOP3d + */ + val counterOp: ObjectsCounterOp? = null, + + /** + * The payload for the operation if the operation is MAP_CREATE. + * Defines the initial value for the Map object. + * Spec: OOP3e + */ + val map: ObjectsMap? = null, + + /** + * The payload for the operation if the operation is COUNTER_CREATE. + * Defines the initial value for the Counter object. + * Spec: OOP3f + */ + val counter: ObjectsCounter? = null, + + /** + * The nonce, must be present on create operations. This is the random part + * that has been hashed with the type and initial value to create the object ID. + * Spec: OOP3g + */ + val nonce: String? = null, + + /** + * The initial value json string for the object. This value should be used along with the nonce + * and timestamp to create the object ID. Frontdoor will use this to verify the object ID. + * After verification the json string will be decoded into the Map or Counter objects and + * the initialValue and nonce will be removed. + * Spec: OOP3h + */ + val initialValue: String? = null, +) + +/** + * An ObjectState describes the instantaneous state of an object on a channel. + * Spec: OST1 + */ +internal data class ObjectState( + /** + * The identifier of the object. + * Spec: OST2a + */ + val objectId: String, + + /** + * A map of serials keyed by a {@link ObjectMessage.siteCode}, + * representing the last operations applied to this object + * Spec: OST2b + */ + val siteTimeserials: Map, + + /** + * True if the object has been tombstoned. + * Spec: OST2c + */ + val tombstone: Boolean, + + /** + * The operation that created the object. + * Can be missing if create operation for the object is not known at this point. + * Spec: OST2d + */ + val createOp: ObjectOperation? = null, + + /** + * The data that represents the result of applying all operations to a Map object + * excluding the initial value from the create operation if it is a Map object type. + * Spec: OST2e + */ + val map: ObjectsMap? = null, + + /** + * The data that represents the result of applying all operations to a Counter object + * excluding the initial value from the create operation if it is a Counter object type. + * Spec: OST2f + */ + val counter: ObjectsCounter? = null +) + +/** + * An @ObjectMessage@ represents an individual object message to be sent or received via the Ably Realtime service. + * Spec: OM1 + */ +internal data class ObjectMessage( + /** + * unique ID for this object message. This attribute is always populated for object messages received over REST. + * For object messages received over Realtime, if the object message does not contain an @id@, + * it should be set to @protocolMsgId:index@, where @protocolMsgId@ is the id of the @ProtocolMessage@ encapsulating it, + * and @index@ is the index of the object message inside the @state@ array of the @ProtocolMessage@ + * Spec: OM2a + */ + val id: String? = null, + + /** + * time in milliseconds since epoch. If an object message received from Ably does not contain a @timestamp@, + * it should be set to the @timestamp@ of the encapsulating @ProtocolMessage@ + * Spec: OM2e + */ + val timestamp: Long? = null, + + /** + * Spec: OM2b + */ + val clientId: String? = null, + + /** + * If an object message received from Ably does not contain a @connectionId@, + * it should be set to the @connectionId@ of the encapsulating @ProtocolMessage@ + * Spec: OM2c + */ + val connectionId: String? = null, + + /** + * JSON-encodable object, used to contain any arbitrary key value pairs which may also contain other primitive JSON types, + * JSON-encodable objects or JSON-encodable arrays. The @extras@ field is provided to contain message metadata and/or + * ancillary payloads in support of specific functionality. For 3.1 no specific functionality is specified for + * @extras@ in object messages. Unless otherwise specified, the client library should not attempt to do any filtering + * or validation of the @extras@ field itself, but should treat it opaquely, encoding it and passing it to realtime unaltered + * Spec: OM2d + */ + val extras: JsonObject? = null, + + /** + * Describes an operation to be applied to an object. + * Mutually exclusive with the `object` field. This field is only set on object messages if the `action` field of the + * `ProtocolMessage` encapsulating it is `OBJECT`. + * Spec: OM2f + */ + val operation: ObjectOperation? = null, + + /** + * Describes the instantaneous state of an object. + * Mutually exclusive with the `operation` field. This field is only set on object messages if the `action` field of + * the `ProtocolMessage` encapsulating it is `OBJECT_SYNC`. + * Spec: OM2g + */ + @SerializedName("object") + val objectState: ObjectState? = null, + + /** + * An opaque string that uniquely identifies this object message. + * Spec: OM2h + */ + val serial: String? = null, + + /** + * A timestamp from the [serial] field. + * Spec: OM2j + */ + val serialTimestamp: Long? = null, + + /** + * An opaque string used as a key to update the map of serial values on an object. + * Spec: OM2i + */ + val siteCode: String? = null +) + +/** + * Calculates the size of an ObjectMessage in bytes. + * Spec: OM3 + */ +internal fun ObjectMessage.size(): Int { + val clientIdSize = clientId?.length ?: 0 // Spec: OM3f + val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 + val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 + val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d + + return clientIdSize + operationSize + objectStateSize + extrasSize +} + +/** + * Calculates the size of an ObjectOperation in bytes. + * Spec: OOP4 + */ +private fun ObjectOperation.size(): Int { + val mapOpSize = mapOp?.size() ?: 0 // Spec: OOP4b, OMO3 + val counterOpSize = counterOp?.size() ?: 0 // Spec: OOP4c, OCO3 + val mapSize = map?.size() ?: 0 // Spec: OOP4d, OMP4 + val counterSize = counter?.size() ?: 0 // Spec: OOP4e, OCN3 + + return mapOpSize + counterOpSize + mapSize + counterSize +} + +/** + * Calculates the size of an ObjectState in bytes. + * Spec: OST3 + */ +private fun ObjectState.size(): Int { + val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4 + val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3 + val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4 + + return mapSize + counterSize + createOpSize +} + +/** + * Calculates the size of an ObjectMapOp in bytes. + * Spec: OMO3 + */ +private fun ObjectsMapOp.size(): Int { + val keySize = key.length // Spec: OMO3d - Size of the key + val dataSize = data?.size() ?: 0 // Spec: OMO3b - Size of the data, calculated per "OD3" + return keySize + dataSize +} + +/** + * Calculates the size of a CounterOp in bytes. + * Spec: OCO3 + */ +private fun ObjectsCounterOp.size(): Int { + // Size is 8 if amount is a number, 0 if amount is null or omitted + return if (amount != null) 8 else 0 // Spec: OCO3a, OCO3b +} + +/** + * Calculates the size of an ObjectMap in bytes. + * Spec: OMP4 + */ +private fun ObjectsMap.size(): Int { + // Calculate the size of all map entries in the map property + val entriesSize = entries?.entries?.sumOf { + it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 + } ?: 0 + + return entriesSize +} + +/** + * Calculates the size of an ObjectCounter in bytes. + * Spec: OCN3 + */ +private fun ObjectsCounter.size(): Int { + // Size is 8 if count is a number, 0 if count is null or omitted + return if (count != null) 8 else 0 +} + +/** + * Calculates the size of a MapEntry in bytes. + * Spec: OME3 + */ +private fun ObjectsMapEntry.size(): Int { + // The size is equal to the size of the data property, calculated per "OD3" + return data?.size() ?: 0 +} + +/** + * Calculates the size of an ObjectData in bytes. + * Spec: OD3 + */ +private fun ObjectData.size(): Int { + return value?.size() ?: 0 // Spec: OD3f +} + +/** + * Calculates the size of an ObjectValue in bytes. + * Spec: OD3* + */ +private fun ObjectValue.size(): Int { + return when (this) { + is ObjectValue.Boolean -> 1 // Spec: OD3b + is ObjectValue.Binary -> value.size() // Spec: OD3c + is ObjectValue.Number -> 8 // Spec: OD3d + is ObjectValue.String -> value.byteSize // Spec: OD3e + is ObjectValue.JsonObject, is ObjectValue.JsonArray -> value.toString().byteSize // Spec: OD3e + } +} + +internal fun ObjectData?.isInvalid(): Boolean { + return this?.objectId.isNullOrEmpty() && this?.value == null +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt new file mode 100644 index 000000000..2132c84e9 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -0,0 +1,247 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.ObjectUpdate +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.util.Log + +/** + * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences + * @spec RTO6 - Creates zero-value objects when needed + */ +internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObjects): ObjectsStateCoordinator() { + private val tag = "ObjectsManager" + /** + * @spec RTO5 - Sync objects data pool for collecting sync messages + */ + private val syncObjectsDataPool = mutableMapOf() + private var currentSyncId: String? = null + /** + * @spec RTO7 - Buffered object operations during sync + */ + private val bufferedObjectOperations = mutableListOf() // RTO7a + + /** + * Handles object messages (non-sync messages). + * + * @spec RTO8 - Buffers messages if not synced, applies immediately if synced + */ + internal fun handleObjectMessages(objectMessages: List) { + if (realtimeObjects.state != ObjectsState.Synced) { + // RTO7 - The client receives object messages in realtime over the channel concurrently with the sync sequence. + // Some of the incoming object messages may have already been applied to the objects described in + // the sync sequence, but others may not; therefore we must buffer these messages so that we can apply + // them to the objects once the sync is complete. + Log.v(tag, "Buffering ${objectMessages.size} object messages, state: ${realtimeObjects.state}") + bufferedObjectOperations.addAll(objectMessages) // RTO8a + return + } + + // Apply messages immediately if synced + applyObjectMessages(objectMessages) // RTO8b + } + + /** + * Handles object sync messages. + * + * @spec RTO5 - Parses sync channel serial and manages sync sequences + */ + internal fun handleObjectSyncMessages(objectMessages: List, syncChannelSerial: String?) { + val syncTracker = ObjectsSyncTracker(syncChannelSerial) + val isNewSync = syncTracker.hasSyncStarted(currentSyncId) + if (isNewSync) { + // RTO5a2 - new sync sequence started + startNewSync(syncTracker.syncId) + } + + // RTO5a3 - continue current sync sequence + applyObjectSyncMessages(objectMessages) // RTO5b + + // RTO5a4 - if this is the last (or only) message in a sequence of sync updates, end the sync + if (syncTracker.hasSyncEnded()) { + // defer the state change event until the next tick if this was a new sync sequence + // to allow any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. + endSync(isNewSync) + } + } + + /** + * Starts a new sync sequence. + * + * @spec RTO5 - Sync sequence initialization + */ + internal fun startNewSync(syncId: String?) { + Log.v(tag, "Starting new sync sequence: syncId=$syncId") + + // need to discard all buffered object operation messages on new sync start + bufferedObjectOperations.clear() // RTO5a2b + syncObjectsDataPool.clear() // RTO5a2a + currentSyncId = syncId + stateChange(ObjectsState.Syncing, false) + } + + /** + * Ends the current sync sequence. + * + * @spec RTO5c - Applies sync data and buffered operations + */ + internal fun endSync(deferStateEvent: Boolean) { + Log.v(tag, "Ending sync sequence") + applySync() + // should apply buffered object operations after we applied the sync. + // can use regular non-sync object.operation logic + applyObjectMessages(bufferedObjectOperations) // RTO5c6 + + bufferedObjectOperations.clear() // RTO5c5 + syncObjectsDataPool.clear() // RTO5c4 + currentSyncId = null // RTO5c3 + stateChange(ObjectsState.Synced, deferStateEvent) + } + + /** + * Clears the sync objects data pool. + * Used by DefaultRealtimeObjects.handleStateChange. + */ + internal fun clearSyncObjectsDataPool() { + syncObjectsDataPool.clear() + } + + /** + * Clears the buffered object operations. + * Used by DefaultRealtimeObjects.handleStateChange. + */ + internal fun clearBufferedObjectOperations() { + bufferedObjectOperations.clear() + } + + /** + * Applies sync data to objects pool. + * + * @spec RTO5c - Processes sync data and updates objects pool + */ + private fun applySync() { + if (syncObjectsDataPool.isEmpty()) { + return + } + + val receivedObjectIds = mutableSetOf() + // RTO5c1a2 - List to collect updates for existing objects + val existingObjectUpdates = mutableListOf>() + + // RTO5c1 + for ((objectId, objectMessage) in syncObjectsDataPool) { + val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5b + receivedObjectIds.add(objectId) + val existingObject = realtimeObjects.objectsPool.get(objectId) + + // RTO5c1a + if (existingObject != null) { + // Update existing object + val update = existingObject.applyObjectSync(objectMessage) // RTO5c1a1 + existingObjectUpdates.add(Pair(existingObject, update)) + } else { // RTO5c1b + // RTO5c1b1, RTO5c1b1a, RTO5c1b1b - Create new object and add it to the pool + val newObject = createObjectFromState(objectState) + newObject.applyObjectSync(objectMessage) + realtimeObjects.objectsPool.set(objectId, newObject) + } + } + + // RTO5c2 - need to remove realtimeObject instances from the ObjectsPool for which objectIds were not received during the sync sequence + realtimeObjects.objectsPool.deleteExtraObjectIds(receivedObjectIds) + + // RTO5c7 - call subscription callbacks for all updated existing objects + existingObjectUpdates.forEach { (obj, update) -> + obj.notifyUpdated(update) + } + } + + /** + * Applies object messages to objects. + * + * @spec RTO9 - Creates zero-value objects if they don't exist + */ + private fun applyObjectMessages(objectMessages: List) { + // RTO9a + for (objectMessage in objectMessages) { + if (objectMessage.operation == null) { + // RTO9a1 + Log.w(tag, "Object message received without operation field, skipping message: ${objectMessage.id}") + continue + } + + val objectOperation: ObjectOperation = objectMessage.operation // RTO9a2 + if (objectOperation.action == ObjectOperationAction.Unknown) { + // RTO9a2b - object operation action is unknown, skip the message + Log.w(tag, "Object operation action is unknown, skipping message: ${objectMessage.id}") + continue + } + // RTO9a2a - we can receive an op for an object id we don't have yet in the pool. instead of buffering such operations, + // we can create a zero-value object for the provided object id and apply the operation to that zero-value object. + // this also means that all objects are capable of applying the corresponding *_CREATE ops on themselves, + // since they need to be able to eventually initialize themselves from that *_CREATE op. + // so to simplify operations handling, we always try to create a zero-value object in the pool first, + // and then we can always apply the operation on the existing object in the pool. + val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1 + obj.applyObject(objectMessage) // RTO9a2a2, RTO9a2a3 + } + } + + /** + * Applies sync messages to sync data pool. + * + * @spec RTO5b - Collects object states during sync sequence + */ + private fun applyObjectSyncMessages(objectMessages: List) { + for (objectMessage in objectMessages) { + if (objectMessage.objectState == null) { + Log.w(tag, "Object message received during OBJECT_SYNC without object field, skipping message: ${objectMessage.id}") + continue + } + + val objectState: ObjectState = objectMessage.objectState + if (objectState.counter != null || objectState.map != null) { + syncObjectsDataPool[objectState.objectId] = objectMessage + } else { + // RTO5c1b1c - object state must contain either counter or map data + Log.w(tag, "Object state received without counter or map data, skipping message: ${objectMessage.id}") + } + } + } + + /** + * Creates an object from object state. + * + * @spec RTO5c1b - Creates objects from object state based on type + */ + private fun createObjectFromState(objectState: ObjectState): BaseRealtimeObject { + return when { + objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1a + objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1b + else -> throw clientError("Object state must contain either counter or map data") // RTO5c1b1c + } + } + + /** + * Changes the state and emits events. + * + * @spec RTO2 - Emits state change events for syncing and synced states + */ + private fun stateChange(newState: ObjectsState, deferEvent: Boolean) { + if (realtimeObjects.state == newState) { + return + } + Log.v(tag, "Objects state changed to: $newState from ${realtimeObjects.state}") + realtimeObjects.state = newState + + // deferEvent not needed since objectsStateChanged processes events in a sequential coroutine scope + objectsStateChanged(newState) + } + + internal fun dispose() { + syncObjectsDataPool.clear() + bufferedObjectOperations.clear() + disposeObjectsStateListeners() + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt new file mode 100644 index 000000000..a874d6dd6 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt @@ -0,0 +1,159 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.util.Log +import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Constants for ObjectsPool configuration + */ +internal object ObjectsPoolDefaults { + const val GC_INTERVAL_MS = 1000L * 60 * 5 // 5 minutes + /** + * Must be > 2 minutes to ensure we keep tombstones long enough to avoid the possibility of receiving an operation + * with an earlier serial that would not have been applied if the tombstone still existed. + * + * Applies both for map entries tombstones and object tombstones. + */ + const val GC_GRACE_PERIOD_MS = 1000L * 60 * 60 * 24 // 24 hours +} + +/** + * Root object ID constant + */ +internal const val ROOT_OBJECT_ID = "root" + +/** + * ObjectsPool manages a pool of live objects for a channel. + * + * @spec RTO3 - Maintains an objects pool for all live objects on the channel + */ +internal class ObjectsPool( + private val realtimeObjects: DefaultRealtimeObjects +) { + private val tag = "ObjectsPool" + + /** + * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveCounter. + * @spec RTO3a - Pool storing all live objects by object ID + */ + private val pool = ConcurrentHashMap() + + /** + * Coroutine scope for garbage collection + */ + private val gcScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var gcJob: Job // Job for the garbage collection coroutine + + init { + // RTO3b - Initialize pool with root object + pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, realtimeObjects) + // Start garbage collection coroutine + gcJob = startGCJob() + } + + /** + * Gets a live object from the pool by object ID. + */ + internal fun get(objectId: String): BaseRealtimeObject? { + return pool[objectId] + } + + /** + * Sets a realtime object in the pool. + */ + internal fun set(objectId: String, realtimeObject: BaseRealtimeObject) { + pool[objectId] = realtimeObject + } + + /** + * Removes all objects but root from the pool and clears the data for root. + * Does not create a new root object, so the reference to the root object remains the same. + */ + internal fun resetToInitialPool(emitUpdateEvents: Boolean) { + pool.entries.removeIf { (key, _) -> key != ROOT_OBJECT_ID } // only keep the root object + clearObjectsData(emitUpdateEvents) // RTO4b2a - clear the root object and emit update events + } + + + /** + * Deletes objects from the pool for which object ids are not found in the provided array of ids. + * Spec: RTO5c2 + */ + internal fun deleteExtraObjectIds(objectIds: MutableSet) { + pool.entries.removeIf { (key, _) -> key !in objectIds && key != ROOT_OBJECT_ID } // RTO5c2a - Keep root object + } + + /** + * Clears the data stored for all objects in the pool. + */ + internal fun clearObjectsData(emitUpdateEvents: Boolean) { + for (obj in pool.values) { + val update = obj.clearData() + if (emitUpdateEvents) obj.notifyUpdated(update) + } + } + + /** + * Creates a zero-value object if it doesn't exist in the pool. + * + * @spec RTO6 - Creates zero-value objects when needed + */ + internal fun createZeroValueObjectIfNotExists(objectId: String): BaseRealtimeObject { + val existingObject = get(objectId) + if (existingObject != null) { + return existingObject // RTO6a + } + + val parsedObjectId = ObjectId.fromString(objectId) // RTO6b + return when (parsedObjectId.type) { + ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, realtimeObjects) // RTO6b2 + ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, realtimeObjects) // RTO6b3 + }.apply { + set(objectId, this) // RTO6b4 - Add the zero-value object to the pool + } + } + + /** + * Garbage collection interval handler. + */ + private fun onGCInterval() { + pool.entries.removeIf { (_, obj) -> + if (obj.isEligibleForGc()) { true } // Remove from pool + else { + obj.onGCInterval() + false // Keep in pool + } + } + } + + /** + * Starts the garbage collection coroutine. + */ + private fun startGCJob() : Job { + return gcScope.launch { + while (isActive) { + try { + onGCInterval() + } catch (e: Exception) { + Log.e(tag, "Error during garbage collection", e) + } + delay(ObjectsPoolDefaults.GC_INTERVAL_MS) + } + } + } + + /** + * Disposes of the ObjectsPool, cleaning up resources. + * Should be called when the pool is no longer needed. + */ + fun dispose() { + gcJob.cancel() + gcScope.cancel() + pool.clear() + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt new file mode 100644 index 000000000..f56782613 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -0,0 +1,107 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.state.ObjectsStateChange +import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.util.EventEmitter +import io.ably.lib.util.Log +import kotlinx.coroutines.* + +/** + * @spec RTO2 - enum representing objects state + */ +internal enum class ObjectsState { + Initialized, + Syncing, + Synced +} + +/** + * Maps internal ObjectsState values to their corresponding public ObjectsStateEvent values. + * Used to determine which events should be emitted when state changes occur. + * INITIALIZED maps to null (no event), while SYNCING and SYNCED map to their respective events. + */ +private val objectsStateToEventMap = mapOf( + ObjectsState.Initialized to null, + ObjectsState.Syncing to ObjectsStateEvent.SYNCING, + ObjectsState.Synced to ObjectsStateEvent.SYNCED +) + +/** + * An interface for managing and communicating changes in the synchronization state of live objects. + * + * Implementations should ensure thread-safe event emission and proper synchronization + * between state change notifications. + */ +internal interface HandlesObjectsStateChange { + /** + * Handles changes in the state of live objects by notifying all registered listeners. + * Implementations should ensure thread-safe event emission to both internal and public listeners. + * Makes sure every event is processed in the order they were received. + * @param newState The new state of the objects, SYNCING or SYNCED. + */ + fun objectsStateChanged(newState: ObjectsState) + + /** + * Suspends the current coroutine until objects are synchronized. + * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event. + * + * @param currentState The current state of objects to determine if waiting is necessary + */ + suspend fun ensureSynced(currentState: ObjectsState) + + /** + * Disposes all registered state change listeners and cancels any pending operations. + * Should be called when the associated RealtimeObjects instance is no longer needed. + */ + fun disposeObjectsStateListeners() +} + + +internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObjectsStateChange { + private val tag = "ObjectsStateCoordinator" + private val internalObjectStateEmitter = ObjectsStateEmitter() + // related to RTC10, should have a separate EventEmitter for users of the library + private val externalObjectStateEmitter = ObjectsStateEmitter() + + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription { + externalObjectStateEmitter.on(event, listener) + return ObjectsSubscription { + externalObjectStateEmitter.off(event, listener) + } + } + + override fun off(listener: ObjectsStateChange.Listener) = externalObjectStateEmitter.off(listener) + + override fun offAll() = externalObjectStateEmitter.off() + + override fun objectsStateChanged(newState: ObjectsState) { + objectsStateToEventMap[newState]?.let { objectsStateEvent -> + internalObjectStateEmitter.emit(objectsStateEvent) + externalObjectStateEmitter.emit(objectsStateEvent) + } + } + + override suspend fun ensureSynced(currentState: ObjectsState) { + if (currentState != ObjectsState.Synced) { + val deferred = CompletableDeferred() + internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { + Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") + deferred.complete(Unit) + } + deferred.await() + } + } + + override fun disposeObjectsStateListeners() = offAll() +} + +private class ObjectsStateEmitter : EventEmitter() { + private val tag = "ObjectsStateEmitter" + override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) { + try { + listener?.onStateChanged(event!!) + } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing listener callback for event: $event", t) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt new file mode 100644 index 000000000..5c2a193d5 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt @@ -0,0 +1,63 @@ +package io.ably.lib.objects + +/** + * @spec RTO5 - SyncTracker class for tracking objects sync status + */ +internal class ObjectsSyncTracker(syncChannelSerial: String?) { + private val syncSerial: String? = syncChannelSerial + internal val syncId: String? + internal val syncCursor: String? + + init { + val parsed = parseSyncChannelSerial(syncChannelSerial) + syncId = parsed.first + syncCursor = parsed.second + } + + /** + * Checks if a new sync sequence has started. + * + * @param prevSyncId The previously stored sync ID + * @return true if a new sync sequence has started, false otherwise + * + * Spec: RTO5a5, RTO5a2 + */ + internal fun hasSyncStarted(prevSyncId: String?): Boolean { + return syncSerial.isNullOrEmpty() || prevSyncId != syncId + } + + /** + * Checks if the current sync sequence has ended. + * + * @return true if the sync sequence has ended, false otherwise + * + * Spec: RTO5a5, RTO5a4 + */ + internal fun hasSyncEnded(): Boolean { + return syncSerial.isNullOrEmpty() || syncCursor.isNullOrEmpty() + } + + companion object { + /** + * Parses sync channel serial to extract syncId and syncCursor. + * + * @param syncChannelSerial The sync channel serial to parse + * @return Pair of syncId and syncCursor, both null if parsing fails + */ + private fun parseSyncChannelSerial(syncChannelSerial: String?): Pair { + if (syncChannelSerial.isNullOrEmpty()) { + return Pair(null, null) + } + + // RTO5a1 - syncChannelSerial is a two-part identifier: : + val match = Regex("^([\\w-]+):(.*)$").find(syncChannelSerial) + return if (match != null) { + val syncId = match.groupValues[1] + val syncCursor = match.groupValues[2] + Pair(syncId, syncCursor) + } else { + Pair(null, null) + } + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt new file mode 100644 index 000000000..dfb1a12bc --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt @@ -0,0 +1,35 @@ +package io.ably.lib.objects + +import io.ably.lib.types.AblyException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.concurrent.Volatile + +/** + * ServerTime is a utility object that provides the current server time + * Spec: RTO16 + */ +internal object ServerTime { + @Volatile + private var serverTimeOffset: Long? = null + private val mutex = Mutex() + + /** + * Spec: RTO16a + */ + @Throws(AblyException::class) + internal suspend fun getCurrentTime(adapter: ObjectsAdapter): Long { + if (serverTimeOffset == null) { + mutex.withLock { + if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety + val serverTime: Long = withContext(Dispatchers.IO) { adapter.time } + serverTimeOffset = serverTime - System.currentTimeMillis() + return serverTime + } + } + } + return System.currentTimeMillis() + serverTimeOffset!! + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt new file mode 100644 index 000000000..3e136163e --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt @@ -0,0 +1,117 @@ +package io.ably.lib.objects + +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import io.ably.lib.util.Log +import kotlinx.coroutines.* +import java.nio.charset.StandardCharsets +import java.util.concurrent.CancellationException + +internal fun ablyException( + errorMessage: String, + errorCode: ErrorCode, + statusCode: HttpStatusCode = HttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) + return createAblyException(errorInfo, cause) +} + +internal fun ablyException( + errorInfo: ErrorInfo, + cause: Throwable? = null, +): AblyException = createAblyException(errorInfo, cause) + +private fun createErrorInfo( + errorMessage: String, + errorCode: ErrorCode, + statusCode: HttpStatusCode, +) = ErrorInfo(errorMessage, statusCode.code, errorCode.code) + +private fun createAblyException( + errorInfo: ErrorInfo, + cause: Throwable?, +) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } + ?: AblyException.fromErrorInfo(errorInfo) + +internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest) + +internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError) + +internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ErrorCode.InvalidObject, HttpStatusCode.InternalServerError, cause) +} + +internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ErrorCode.InvalidInputParams, HttpStatusCode.InternalServerError, cause) +} + +/** + * Calculates the byte size of a string. + * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. + * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. + */ +internal val String.byteSize: Int + get() = this.toByteArray(StandardCharsets.UTF_8).size + +/** + * A channel-specific coroutine scope for executing callbacks asynchronously in the RealtimeObjects system. + * Provides safe execution of suspend functions with results delivered via callbacks. + * Supports proper error handling and cancellation during DefaultRealtimeObjects disposal. + */ +internal class ObjectsAsyncScope(channelName: String) { + private val tag = "ObjectsCallbackScope-$channelName" + + private val scope = + CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob()) + + internal fun launchWithCallback(callback: ObjectsCallback, block: suspend () -> T) { + scope.launch { + try { + val result = block() + try { callback.onSuccess(result) } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) + } // catch and don't rethrow error from callback + } catch (throwable: Throwable) { + when (throwable) { + is AblyException -> { callback.onError(throwable) } + else -> { + val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) + callback.onError(ex) + } + } + } + } + } + + internal fun launchWithVoidCallback(callback: ObjectsCallback, block: suspend () -> Unit) { + scope.launch { + try { + block() + try { callback.onSuccess(null) } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) + } // catch and don't rethrow error from callback + } catch (throwable: Throwable) { + when (throwable) { + is AblyException -> { callback.onError(throwable) } + else -> { + val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) + callback.onError(ex) + } + } + } + } + } + + internal fun cancel(cause: CancellationException) { + scope.coroutineContext.cancelChildren(cause) + } +} + +/** + * Generates a random nonce string for object creation. + */ +internal fun generateNonce(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range + return (1..16).map { chars.random() }.joinToString("") +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt new file mode 100644 index 000000000..15c5fb587 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt @@ -0,0 +1,46 @@ +@file:Suppress("UNCHECKED_CAST") + +package io.ably.lib.objects.serialization + +import com.google.gson.* +import io.ably.lib.objects.* + +import io.ably.lib.objects.ObjectMessage +import org.msgpack.core.MessagePacker +import org.msgpack.core.MessageUnpacker + +/** + * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization + * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. + * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies. + */ +@Suppress("unused") // Used via reflection in ObjectsHelper +internal class DefaultObjectsSerializer : ObjectsSerializer { + + override fun readMsgpackArray(unpacker: MessageUnpacker): Array { + val objectMessagesCount = unpacker.unpackArrayHeader() + return Array(objectMessagesCount) { readObjectMessage(unpacker) } + } + + override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { + val objectMessages: Array = objects as Array + packer.packArrayHeader(objectMessages.size) + objectMessages.forEach { it.writeMsgpack(packer) } + } + + override fun readFromJsonArray(json: JsonArray): Array { + return json.map { element -> + if (element.isJsonObject) element.asJsonObject.toObjectMessage() + else throw JsonParseException("Expected JsonObject, but found: $element") + }.toTypedArray() + } + + override fun asJsonArray(objects: Array): JsonArray { + val objectMessages: Array = objects as Array + val jsonArray = JsonArray() + for (objectMessage in objectMessages) { + jsonArray.add(objectMessage.toJsonObject()) + } + return jsonArray + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt new file mode 100644 index 000000000..e610ddc6d --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt @@ -0,0 +1,86 @@ +package io.ably.lib.objects.serialization + +import com.google.gson.* +import io.ably.lib.objects.Binary +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectValue +import java.lang.reflect.Type +import java.util.* +import kotlin.enums.EnumEntries + +// Gson instance for JSON serialization/deserialization +internal val gson = GsonBuilder() + .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) + .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries)) + .create() + +internal fun ObjectMessage.toJsonObject(): JsonObject { + return gson.toJsonTree(this).asJsonObject +} + +internal fun JsonObject.toObjectMessage(): ObjectMessage { + return gson.fromJson(this, ObjectMessage::class.java) +} + +internal class EnumCodeTypeAdapter>( + private val getCode: (T) -> Int, + private val enumValues: EnumEntries +) : JsonSerializer, JsonDeserializer { + + override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonPrimitive(getCode(src)) + } + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { + val code = json.asInt + return enumValues.firstOrNull { getCode(it) == code } ?: enumValues.firstOrNull { getCode(it) == -1 } + ?: throw JsonParseException("Unknown enum code: $code and no Unknown fallback found") + } +} + +internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + val obj = JsonObject() + src.objectId?.let { obj.addProperty("objectId", it) } + + src.value?.let { v -> + when (v) { + is ObjectValue.Boolean -> obj.addProperty("boolean", v.value) + is ObjectValue.String -> obj.addProperty("string", v.value) + is ObjectValue.Number -> obj.addProperty("number", v.value.toDouble()) + is ObjectValue.Binary -> obj.addProperty("bytes", Base64.getEncoder().encodeToString(v.value.data)) + is ObjectValue.JsonObject, is ObjectValue.JsonArray -> obj.addProperty("json", v.value.toString()) // Spec: OD4c5 + } + } + return obj + } + + override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { + val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") + val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null + val value = when { + obj.has("boolean") -> ObjectValue.Boolean(obj.get("boolean").asBoolean) + obj.has("string") -> ObjectValue.String(obj.get("string").asString) + obj.has("number") -> ObjectValue.Number(obj.get("number").asDouble) + obj.has("bytes") -> ObjectValue.Binary(Binary(Base64.getDecoder().decode(obj.get("bytes").asString))) + obj.has("json") -> { + val jsonElement = JsonParser.parseString(obj.get("json").asString) + when { + jsonElement.isJsonObject -> ObjectValue.JsonObject(jsonElement.asJsonObject) + jsonElement.isJsonArray -> ObjectValue.JsonArray(jsonElement.asJsonArray) + else -> throw JsonParseException("Invalid JSON structure") + } + } + else -> { + if (objectId != null) + null + else + throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") + } + } + return ObjectData(objectId, value) + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt new file mode 100644 index 000000000..797977a39 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt @@ -0,0 +1,718 @@ +package io.ably.lib.objects.serialization + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.ably.lib.objects.* +import io.ably.lib.objects.Binary +import io.ably.lib.objects.ErrorCode +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectsCounter +import io.ably.lib.objects.ObjectsCounterOp +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectsMap +import io.ably.lib.objects.ObjectsMapEntry +import io.ably.lib.objects.ObjectsMapOp +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectValue +import io.ably.lib.util.Serialisation +import org.msgpack.core.MessageFormat +import org.msgpack.core.MessagePacker +import org.msgpack.core.MessageUnpacker + +/** + * Write ObjectMessage to MessagePacker + */ +internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (id != null) fieldCount++ + if (timestamp != null) fieldCount++ + if (clientId != null) fieldCount++ + if (connectionId != null) fieldCount++ + if (extras != null) fieldCount++ + if (operation != null) fieldCount++ + if (objectState != null) fieldCount++ + if (serial != null) fieldCount++ + if (serialTimestamp != null) fieldCount++ + if (siteCode != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (id != null) { + packer.packString("id") + packer.packString(id) + } + + if (timestamp != null) { + packer.packString("timestamp") + packer.packLong(timestamp) + } + + if (clientId != null) { + packer.packString("clientId") + packer.packString(clientId) + } + + if (connectionId != null) { + packer.packString("connectionId") + packer.packString(connectionId) + } + + if (extras != null) { + packer.packString("extras") + packer.writePayload(Serialisation.gsonToMsgpack(extras)) + } + + if (operation != null) { + packer.packString("operation") + operation.writeMsgpack(packer) + } + + if (objectState != null) { + packer.packString("object") + objectState.writeMsgpack(packer) + } + + if (serial != null) { + packer.packString("serial") + packer.packString(serial) + } + + if (serialTimestamp != null) { + packer.packString("serialTimestamp") + packer.packLong(serialTimestamp) + } + + if (siteCode != null) { + packer.packString("siteCode") + packer.packString(siteCode) + } +} + +/** + * Read an ObjectMessage from MessageUnpacker + */ +internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { + if (unpacker.nextFormat == MessageFormat.NIL) { + unpacker.unpackNil() + return ObjectMessage() // default/empty message + } + + val fieldCount = unpacker.unpackMapHeader() + + var id: String? = null + var timestamp: Long? = null + var clientId: String? = null + var connectionId: String? = null + var extras: JsonObject? = null + var operation: ObjectOperation? = null + var objectState: ObjectState? = null + var serial: String? = null + var serialTimestamp: Long? = null + var siteCode: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "id" -> id = unpacker.unpackString() + "timestamp" -> timestamp = unpacker.unpackLong() + "clientId" -> clientId = unpacker.unpackString() + "connectionId" -> connectionId = unpacker.unpackString() + "extras" -> extras = Serialisation.msgpackToGson(unpacker.unpackValue()) as? JsonObject + "operation" -> operation = readObjectOperation(unpacker) + "object" -> objectState = readObjectState(unpacker) + "serial" -> serial = unpacker.unpackString() + "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() + "siteCode" -> siteCode = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + + return ObjectMessage( + id = id, + timestamp = timestamp, + clientId = clientId, + connectionId = connectionId, + extras = extras, + operation = operation, + objectState = objectState, + serial = serial, + serialTimestamp = serialTimestamp, + siteCode = siteCode + ) +} + +/** + * Write ObjectOperation to MessagePacker + */ +private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { + var fieldCount = 1 // action is always required + require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" } + fieldCount++ + + if (mapOp != null) fieldCount++ + if (counterOp != null) fieldCount++ + if (map != null) fieldCount++ + if (counter != null) fieldCount++ + if (nonce != null) fieldCount++ + if (initialValue != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("action") + packer.packInt(action.code) + + // Always include objectId as per Objects protocol + packer.packString("objectId") + packer.packString(objectId) + + if (mapOp != null) { + packer.packString("mapOp") + mapOp.writeMsgpack(packer) + } + + if (counterOp != null) { + packer.packString("counterOp") + counterOp.writeMsgpack(packer) + } + + if (map != null) { + packer.packString("map") + map.writeMsgpack(packer) + } + + if (counter != null) { + packer.packString("counter") + counter.writeMsgpack(packer) + } + + if (nonce != null) { + packer.packString("nonce") + packer.packString(nonce) + } + + if (initialValue != null) { + packer.packString("initialValue") + packer.packString(initialValue) + } +} + +/** + * Read ObjectOperation from MessageUnpacker + */ +private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { + val fieldCount = unpacker.unpackMapHeader() + + var action: ObjectOperationAction? = null + var objectId: String = "" + var mapOp: ObjectsMapOp? = null + var counterOp: ObjectsCounterOp? = null + var map: ObjectsMap? = null + var counter: ObjectsCounter? = null + var nonce: String? = null + var initialValue: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "action" -> { + val actionCode = unpacker.unpackInt() + action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } + ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") + } + "objectId" -> objectId = unpacker.unpackString() + "mapOp" -> mapOp = readObjectMapOp(unpacker) + "counterOp" -> counterOp = readObjectCounterOp(unpacker) + "map" -> map = readObjectMap(unpacker) + "counter" -> counter = readObjectCounter(unpacker) + "nonce" -> nonce = unpacker.unpackString() + "initialValue" -> initialValue = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + + if (action == null) { + throw objectError("Missing required 'action' field in ObjectOperation") + } + + return ObjectOperation( + action = action, + objectId = objectId, + mapOp = mapOp, + counterOp = counterOp, + map = map, + counter = counter, + nonce = nonce, + initialValue = initialValue, + ) +} + +/** + * Write ObjectState to MessagePacker + */ +private fun ObjectState.writeMsgpack(packer: MessagePacker) { + var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required + + if (createOp != null) fieldCount++ + if (map != null) fieldCount++ + if (counter != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("objectId") + packer.packString(objectId) + + packer.packString("siteTimeserials") + packer.packMapHeader(siteTimeserials.size) + for ((key, value) in siteTimeserials) { + packer.packString(key) + packer.packString(value) + } + + packer.packString("tombstone") + packer.packBoolean(tombstone) + + if (createOp != null) { + packer.packString("createOp") + createOp.writeMsgpack(packer) + } + + if (map != null) { + packer.packString("map") + map.writeMsgpack(packer) + } + + if (counter != null) { + packer.packString("counter") + counter.writeMsgpack(packer) + } +} + +/** + * Read ObjectState from MessageUnpacker + */ +private fun readObjectState(unpacker: MessageUnpacker): ObjectState { + val fieldCount = unpacker.unpackMapHeader() + + var objectId = "" + var siteTimeserials = mapOf() + var tombstone = false + var createOp: ObjectOperation? = null + var map: ObjectsMap? = null + var counter: ObjectsCounter? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "objectId" -> objectId = unpacker.unpackString() + "siteTimeserials" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + val key = unpacker.unpackString() + val value = unpacker.unpackString() + tempMap[key] = value + } + siteTimeserials = tempMap + } + "tombstone" -> tombstone = unpacker.unpackBoolean() + "createOp" -> createOp = readObjectOperation(unpacker) + "map" -> map = readObjectMap(unpacker) + "counter" -> counter = readObjectCounter(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectState( + objectId = objectId, + siteTimeserials = siteTimeserials, + tombstone = tombstone, + createOp = createOp, + map = map, + counter = counter + ) +} + +/** + * Write ObjectMapOp to MessagePacker + */ +private fun ObjectsMapOp.writeMsgpack(packer: MessagePacker) { + var fieldCount = 1 // key is required + + if (data != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("key") + packer.packString(key) + + if (data != null) { + packer.packString("data") + data.writeMsgpack(packer) + } +} + +/** + * Read ObjectMapOp from MessageUnpacker + */ +private fun readObjectMapOp(unpacker: MessageUnpacker): ObjectsMapOp { + val fieldCount = unpacker.unpackMapHeader() + + var key = "" + var data: ObjectData? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "key" -> key = unpacker.unpackString() + "data" -> data = readObjectData(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectsMapOp(key = key, data = data) +} + +/** + * Write ObjectCounterOp to MessagePacker + */ +private fun ObjectsCounterOp.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (amount != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (amount != null) { + packer.packString("amount") + packer.packDouble(amount) + } +} + +/** + * Read ObjectCounterOp from MessageUnpacker + */ +private fun readObjectCounterOp(unpacker: MessageUnpacker): ObjectsCounterOp { + val fieldCount = unpacker.unpackMapHeader() + + var amount: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "amount" -> amount = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + + return ObjectsCounterOp(amount = amount) +} + +/** + * Write ObjectMap to MessagePacker + */ +private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (semantics != null) fieldCount++ + if (entries != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (semantics != null) { + packer.packString("semantics") + packer.packInt(semantics.code) + } + + if (entries != null) { + packer.packString("entries") + packer.packMapHeader(entries.size) + for ((key, value) in entries) { + packer.packString(key) + value.writeMsgpack(packer) + } + } +} + +/** + * Read ObjectMap from MessageUnpacker + */ +private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { + val fieldCount = unpacker.unpackMapHeader() + + var semantics: ObjectsMapSemantics? = null + var entries: Map? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "semantics" -> { + val semanticsCode = unpacker.unpackInt() + semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } + ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") + } + "entries" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + val key = unpacker.unpackString() + val value = readObjectMapEntry(unpacker) + tempMap[key] = value + } + entries = tempMap + } + else -> unpacker.skipValue() + } + } + + return ObjectsMap(semantics = semantics, entries = entries) +} + +/** + * Write ObjectCounter to MessagePacker + */ +private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (count != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (count != null) { + packer.packString("count") + packer.packDouble(count) + } +} + +/** + * Read ObjectCounter from MessageUnpacker + */ +private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { + val fieldCount = unpacker.unpackMapHeader() + + var count: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "count" -> count = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + + return ObjectsCounter(count = count) +} + +/** + * Write ObjectMapEntry to MessagePacker + */ +private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (tombstone != null) fieldCount++ + if (timeserial != null) fieldCount++ + if (serialTimestamp != null) fieldCount++ + if (data != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (tombstone != null) { + packer.packString("tombstone") + packer.packBoolean(tombstone) + } + + if (timeserial != null) { + packer.packString("timeserial") + packer.packString(timeserial) + } + + if (serialTimestamp != null) { + packer.packString("serialTimestamp") + packer.packLong(serialTimestamp) + } + + if (data != null) { + packer.packString("data") + data.writeMsgpack(packer) + } +} + +/** + * Read ObjectMapEntry from MessageUnpacker + */ +private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { + val fieldCount = unpacker.unpackMapHeader() + + var tombstone: Boolean? = null + var timeserial: String? = null + var serialTimestamp: Long? = null + var data: ObjectData? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "tombstone" -> tombstone = unpacker.unpackBoolean() + "timeserial" -> timeserial = unpacker.unpackString() + "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() + "data" -> data = readObjectData(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) +} + +/** + * Write ObjectData to MessagePacker + */ +private fun ObjectData.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (objectId != null) fieldCount++ + value?.let { + fieldCount++ + } + + packer.packMapHeader(fieldCount) + + if (objectId != null) { + packer.packString("objectId") + packer.packString(objectId) + } + + value?.let { v -> + when (v) { + is ObjectValue.Boolean -> { + packer.packString("boolean") + packer.packBoolean(v.value) + } + is ObjectValue.String -> { + packer.packString("string") + packer.packString(v.value) + } + is ObjectValue.Number -> { + packer.packString("number") + packer.packDouble(v.value.toDouble()) + } + is ObjectValue.Binary -> { + packer.packString("bytes") + packer.packBinaryHeader(v.value.data.size) + packer.writePayload(v.value.data) + } + is ObjectValue.JsonObject -> { + packer.packString("json") + packer.packString(v.value.toString()) + } + is ObjectValue.JsonArray -> { + packer.packString("json") + packer.packString(v.value.toString()) + } + } + } +} + +/** + * Read ObjectData from MessageUnpacker + */ +private fun readObjectData(unpacker: MessageUnpacker): ObjectData { + val fieldCount = unpacker.unpackMapHeader() + var objectId: String? = null + var value: ObjectValue? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "objectId" -> objectId = unpacker.unpackString() + "boolean" -> value = ObjectValue.Boolean(unpacker.unpackBoolean()) + "string" -> value = ObjectValue.String(unpacker.unpackString()) + "number" -> value = ObjectValue.Number(unpacker.unpackDouble()) + "bytes" -> { + val size = unpacker.unpackBinaryHeader() + val bytes = ByteArray(size) + unpacker.readPayload(bytes) + value = ObjectValue.Binary(Binary(bytes)) + } + "json" -> { + val jsonString = unpacker.unpackString() + val parsed = JsonParser.parseString(jsonString) + value = when { + parsed.isJsonObject -> ObjectValue.JsonObject(parsed.asJsonObject) + parsed.isJsonArray -> ObjectValue.JsonArray(parsed.asJsonArray) + else -> + throw ablyException("Invalid JSON string for json field", ErrorCode.MapValueDataTypeUnsupported, HttpStatusCode.InternalServerError) + } + } + else -> unpacker.skipValue() + } + } + + return ObjectData(objectId = objectId, value = value) +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt new file mode 100644 index 000000000..7d76e7b76 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt @@ -0,0 +1,205 @@ +package io.ably.lib.objects.type + +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectsPoolDefaults +import io.ably.lib.objects.objectError +import io.ably.lib.objects.type.livecounter.noOpCounterUpdate +import io.ably.lib.objects.type.livemap.noOpMapUpdate +import io.ably.lib.util.Log + +internal enum class ObjectType(val value: String) { + Map("map"), + Counter("counter") +} + +// Spec: RTLO4b4b +internal val ObjectUpdate.noOp get() = this.update == null + +/** + * Provides common functionality and base implementation for LiveMap and LiveCounter. + * + * @spec RTLO1/RTLO2 - Base class for LiveMap/LiveCounter object + * + * This should also be included in logging + */ +internal abstract class BaseRealtimeObject( + internal val objectId: String, // // RTLO3a + internal val objectType: ObjectType, +) { + + protected open val tag = "BaseRealtimeObject" + + internal val siteTimeserials = mutableMapOf() // RTLO3b + + internal var createOperationIsMerged = false // RTLO3c + + @Volatile + internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter + + private var tombstonedAt: Long? = null + + /** + * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object_sync` + * @return an update describing the changes + * + * @spec RTLM6/RTLC6 - Overrides ObjectMessage with object data state from sync to LiveMap/LiveCounter + */ + internal fun applyObjectSync(objectMessage: ObjectMessage): ObjectUpdate { + val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5b + validate(objectState) + // object's site serials are still updated even if it is tombstoned, so always use the site serials received from the operation. + // should default to empty map if site serials do not exist on the object state, so that any future operation may be applied to this object. + siteTimeserials.clear() + siteTimeserials.putAll(objectState.siteTimeserials) // RTLC6a, RTLM6a + + if (isTombstoned) { + // this object is tombstoned. this is a terminal state which can't be overridden. skip the rest of object state message processing + if (objectType == ObjectType.Map) { + return noOpMapUpdate + } + return noOpCounterUpdate + } + return applyObjectState(objectState, objectMessage) // RTLM6, RTLC6 + } + + /** + * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object` + * @return an update describing the changes + * + * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter + */ + internal fun applyObject(objectMessage: ObjectMessage) { + validateObjectId(objectMessage.operation?.objectId) + + val msgTimeSerial = objectMessage.serial + val msgSiteCode = objectMessage.siteCode + val objectOperation = objectMessage.operation as ObjectOperation + + if (!canApplyOperation(msgSiteCode, msgTimeSerial)) { + // RTLC7b, RTLM15b + Log.v( + tag, + "Skipping ${objectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + + "objectId=$objectId" + ) + return + } + // should update stored site serial immediately. doesn't matter if we successfully apply the op, + // as it's important to mark that the op was processed by the object + siteTimeserials[msgSiteCode!!] = msgTimeSerial!! // RTLC7c, RTLM15c + + if (isTombstoned) { + // this object is tombstoned so the operation cannot be applied + return; + } + applyObjectOperation(objectOperation, objectMessage) // RTLC7d + } + + /** + * Checks if an operation can be applied based on serial comparison. + * + * @spec RTLO4a - Serial comparison logic for LiveMap/LiveCounter operations + */ + internal fun canApplyOperation(siteCode: String?, timeSerial: String?): Boolean { + if (timeSerial.isNullOrEmpty()) { + throw objectError("Invalid serial: $timeSerial") // RTLO4a3 + } + if (siteCode.isNullOrEmpty()) { + throw objectError("Invalid site code: $siteCode") // RTLO4a3 + } + val existingSiteSerial = siteTimeserials[siteCode] // RTLO4a4 + return existingSiteSerial == null || timeSerial > existingSiteSerial // RTLO4a5, RTLO4a6 + } + + internal fun validateObjectId(objectId: String?) { + if (this.objectId != objectId) { + throw objectError("Invalid object: incoming objectId=${objectId}; $objectType objectId=$objectId") + } + } + + /** + * Marks the object as tombstoned. + */ + internal fun tombstone(serialTimestamp: Long?): ObjectUpdate { + if (serialTimestamp == null) { + Log.w(tag, "Tombstoning object $objectId without serial timestamp, using local timestamp instead") + } + isTombstoned = true + tombstonedAt = serialTimestamp?: System.currentTimeMillis() + val update = clearData() + // TODO: Emit BaseRealtimeObject lifecycle events + return update + } + + /** + * Checks if the object is eligible for garbage collection. + */ + internal fun isEligibleForGc(): Boolean { + val currentTime = System.currentTimeMillis() + return isTombstoned && tombstonedAt?.let { currentTime - it >= ObjectsPoolDefaults.GC_GRACE_PERIOD_MS } == true + } + + /** + * Validates that the provided object state is compatible with this live object. + * Checks object ID, type-specific validations, and any included create operations. + */ + abstract fun validate(state: ObjectState) + + /** + * Applies an object state received during synchronization to this live object. + * This method should update the internal data structure with the complete state + * received from the server. + * + * @param objectState The complete state to apply to this object + * @return A map describing the changes made to the object's data + * + */ + abstract fun applyObjectState(objectState: ObjectState, message: ObjectMessage): ObjectUpdate + + /** + * Applies an operation to this live object. + * This method handles the specific operation actions (e.g., update, remove) + * by modifying the underlying data structure accordingly. + * + * @param operation The operation containing the action and data to apply + * @param message The complete object message containing the operation + * + */ + abstract fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage) + + /** + * Clears the object's data and returns an update describing the changes. + * This is called during tombstoning and explicit clear operations. + * + * This method: + * 1. Calculates a diff between the current state and an empty state + * 2. Clears all entries from the underlying data structure + * 3. Returns a map containing metadata about what was cleared + * + * The returned map is used to notifying other components about what entries were removed. + * + * @return A map representing the diff of changes made + */ + abstract fun clearData(): ObjectUpdate + + /** + * Notifies subscribers about changes made to this live object. Propagates updates through the + * appropriate manager after converting the generic update map to type-specific update objects. + * Spec: RTLO4b4c + */ + abstract fun notifyUpdated(update: ObjectUpdate) + + /** + * Called during garbage collection intervals to clean up expired entries. + * + * This method should identify and remove entries that: + * - Have been marked as tombstoned + * - Have a tombstone timestamp older than the configured grace period + * + * Implementations typically use single-pass removal techniques to + * efficiently clean up expired data without creating temporary collections. + */ + abstract fun onGCInterval() +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt new file mode 100644 index 000000000..b34188b62 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt @@ -0,0 +1,136 @@ +package io.ably.lib.objects.type.livecounter + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.ObjectUpdate +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.counter.LiveCounterChange +import io.ably.lib.objects.type.counter.LiveCounterUpdate +import io.ably.lib.objects.type.noOp +import java.util.concurrent.atomic.AtomicReference +import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking + +/** + * @spec RTLC1/RTLC2 - LiveCounter implementation extends BaseRealtimeObject + */ +internal class DefaultLiveCounter private constructor( + objectId: String, + private val realtimeObjects: DefaultRealtimeObjects, +) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter) { + + override val tag = "LiveCounter" + + /** + * Thread-safe reference to hold the counter data value. + * Accessed from public API for LiveCounter and updated by LiveCounterManager. + */ + internal val data = AtomicReference(0.0) // RTLC3 + + /** + * liveCounterManager instance for managing LiveCounter operations + */ + private val liveCounterManager = LiveCounterManager(this) + + private val channelName = realtimeObjects.channelName + private val adapter: ObjectsAdapter get() = realtimeObjects.adapter + private val asyncScope get() = realtimeObjects.asyncScope + + override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) } + + override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) } + + override fun incrementAsync(amount: Number, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) } + } + + override fun decrementAsync(amount: Number, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) } + } + + override fun value(): Double { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + return data.get() + } + + override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + return liveCounterManager.subscribe(listener) + } + + override fun unsubscribe(listener: LiveCounterChange.Listener) = liveCounterManager.unsubscribe(listener) + + override fun unsubscribeAll() = liveCounterManager.unsubscribeAll() + + override fun validate(state: ObjectState) = liveCounterManager.validate(state) + + private suspend fun incrementAsync(amount: Double) { + // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // RTLC12e1 - Validate input parameter + if (amount.isNaN() || amount.isInfinite()) { + throw invalidInputError("Counter value increment should be a valid number") + } + + // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = objectId, + counterOp = ObjectsCounterOp(amount = amount) + ) + ) + + // RTLC12f - Publish the message + realtimeObjects.publish(arrayOf(msg)) + } + + override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate { + return liveCounterManager.applyState(objectState, message.serialTimestamp) + } + + override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage) { + liveCounterManager.applyOperation(operation, message.serialTimestamp) + } + + override fun clearData(): LiveCounterUpdate { + return liveCounterManager.calculateUpdateFromDataDiff(data.get(), 0.0).apply { data.set(0.0) } + } + + override fun notifyUpdated(update: ObjectUpdate) { + if (update.noOp) { + return + } + Log.v(tag, "Object $objectId updated: $update") + liveCounterManager.notify(update as LiveCounterUpdate) + } + + override fun onGCInterval() { + // Nothing to GC for a counter object + return + } + + companion object { + /** + * Creates a zero-value counter object. + * @spec RTLC4 - Returns LiveCounter with 0 value + */ + internal fun zeroValue(objectId: String, realtimeObjects: DefaultRealtimeObjects): DefaultLiveCounter { + return DefaultLiveCounter(objectId, realtimeObjects) + } + + /** + * Creates initial value operation for counter creation. + * Spec: RTO12f2 + */ + internal fun initialValue(count: Number): CounterCreatePayload { + return CounterCreatePayload( + counter = ObjectsCounter(count = count.toDouble()) + ) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt new file mode 100644 index 000000000..0ea58f389 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt @@ -0,0 +1,51 @@ +package io.ably.lib.objects.type.livecounter + +import io.ably.lib.objects.ObjectsSubscription +import io.ably.lib.objects.type.counter.LiveCounterChange +import io.ably.lib.objects.type.counter.LiveCounterUpdate +import io.ably.lib.util.EventEmitter +import io.ably.lib.util.Log + +internal val noOpCounterUpdate = LiveCounterUpdate() + +/** + * Interface for handling live counter changes by notifying subscribers of updates. + * Implementations typically propagate updates through event emission to registered listeners. + */ +internal interface HandlesLiveCounterChange { + /** + * Notifies all registered listeners about a counter update by propagating the change through the event system. + * This method is called when counter data changes and triggers the emission of update events to subscribers. + */ + fun notify(update: LiveCounterUpdate) +} + +internal abstract class LiveCounterChangeCoordinator: LiveCounterChange, HandlesLiveCounterChange { + private val counterChangeEmitter = LiveCounterChangeEmitter() + + override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { + counterChangeEmitter.on(listener) + return ObjectsSubscription { + counterChangeEmitter.off(listener) + } + } + + override fun unsubscribe(listener: LiveCounterChange.Listener) = counterChangeEmitter.off(listener) + + override fun unsubscribeAll() = counterChangeEmitter.off() + + override fun notify(update: LiveCounterUpdate) = counterChangeEmitter.emit(update) +} + +private class LiveCounterChangeEmitter : EventEmitter() { + private val tag = "LiveCounterChangeEmitter" + + override fun apply(listener: LiveCounterChange.Listener?, event: LiveCounterUpdate?, vararg args: Any?) { + try { + event?.let { listener?.onUpdated(it) } + ?: Log.w(tag, "Null event passed to listener callback") + } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing listener callback for event: $event", t) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt new file mode 100644 index 000000000..d96d65b64 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt @@ -0,0 +1,120 @@ +package io.ably.lib.objects.type.livecounter + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.objectError +import io.ably.lib.objects.type.counter.LiveCounterUpdate +import io.ably.lib.util.Log + +internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter): LiveCounterChangeCoordinator() { + + private val objectId = liveCounter.objectId + + private val tag = "LiveCounterManager" + + /** + * @spec RTLC6 - Overrides counter data with state from sync + */ + internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveCounterUpdate { + val previousData = liveCounter.data.get() + + if (objectState.tombstone) { + liveCounter.tombstone(serialTimestamp) + } else { + // override data for this object with data from the object state + liveCounter.createOperationIsMerged = false // RTLC6b + liveCounter.data.set(objectState.counter?.count ?: 0.0) // RTLC6c + + // RTLC6d + objectState.createOp?.let { createOp -> + mergeInitialDataFromCreateOperation(createOp) + } + } + + return calculateUpdateFromDataDiff(previousData, liveCounter.data.get()) + } + + /** + * @spec RTLC7 - Applies operations to LiveCounter + */ + internal fun applyOperation(operation: ObjectOperation, serialTimestamp: Long?) { + val update = when (operation.action) { + ObjectOperationAction.CounterCreate -> applyCounterCreate(operation) // RTLC7d1 + ObjectOperationAction.CounterInc -> { + if (operation.counterOp != null) { + applyCounterInc(operation.counterOp) // RTLC7d2 + } else { + throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}") + } + } + ObjectOperationAction.ObjectDelete -> liveCounter.tombstone(serialTimestamp) + else -> throw objectError("Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3 + } + + liveCounter.notifyUpdated(update) // RTLC7d1a, RTLC7d2a + } + + /** + * @spec RTLC8 - Applies counter create operation + */ + private fun applyCounterCreate(operation: ObjectOperation): LiveCounterUpdate { + if (liveCounter.createOperationIsMerged) { + // RTLC8b + // There can't be two different create operation for the same object id, because the object id + // fully encodes that operation. This means we can safely ignore any new incoming create operations + // if we already merged it once. + Log.v( + tag, + "Skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=$objectId" + ) + return noOpCounterUpdate // RTLC8c + } + + return mergeInitialDataFromCreateOperation(operation) // RTLC8c + } + + /** + * @spec RTLC9 - Applies counter increment operation + */ + private fun applyCounterInc(counterOp: ObjectsCounterOp): LiveCounterUpdate { + val amount = counterOp.amount ?: 0.0 + val previousValue = liveCounter.data.get() + liveCounter.data.set(previousValue + amount) // RTLC9b + return LiveCounterUpdate(amount) + } + + internal fun calculateUpdateFromDataDiff(prevData: Double, newData: Double): LiveCounterUpdate { + return LiveCounterUpdate(newData - prevData) + } + + /** + * @spec RTLC10 - Merges initial data from create operation + */ + private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveCounterUpdate { + // if a counter object is missing for the COUNTER_CREATE op, the initial value is implicitly 0 in this case. + // note that it is intentional to SUM the incoming count from the create op. + // if we got here, it means that current counter instance is missing the initial value in its data reference, + // which we're going to add now. + val count = operation.counter?.count ?: 0.0 + val previousValue = liveCounter.data.get() + liveCounter.data.set(previousValue + count) // RTLC10a + liveCounter.createOperationIsMerged = true // RTLC10b + return LiveCounterUpdate(count) + } + + internal fun validate(state: ObjectState) { + liveCounter.validateObjectId(state.objectId) + state.createOp?.let { createOp -> + liveCounter.validateObjectId(createOp.objectId) + validateCounterCreateAction(createOp.action) + } + } + + private fun validateCounterCreateAction(action: ObjectOperationAction) { + if (action != ObjectOperationAction.CounterCreate) { + throw objectError("Invalid create operation action $action for LiveCounter objectId=${objectId}") + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt new file mode 100644 index 000000000..8c2da8e6a --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt @@ -0,0 +1,248 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.ObjectUpdate +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.map.LiveMapChange +import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.type.map.LiveMapValue +import io.ably.lib.objects.type.noOp +import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap +import java.util.AbstractMap + +/** + * @spec RTLM1/RTLM2 - LiveMap implementation extends BaseRealtimeObject + */ +internal class DefaultLiveMap private constructor( + objectId: String, + private val realtimeObjects: DefaultRealtimeObjects, + internal val semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW +) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map) { + + override val tag = "LiveMap" + + /** + * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveMapManager. + */ + internal val data = ConcurrentHashMap() + + /** + * LiveMapManager instance for managing LiveMap operations + */ + private val liveMapManager = LiveMapManager(this) + + private val channelName = realtimeObjects.channelName + private val adapter: ObjectsAdapter get() = realtimeObjects.adapter + internal val objectsPool: ObjectsPool get() = realtimeObjects.objectsPool + private val asyncScope get() = realtimeObjects.asyncScope + + override fun get(keyName: String): LiveMapValue? { + adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c + if (isTombstoned) { + return null + } + data[keyName]?.let { liveMapEntry -> + return liveMapEntry.getResolvedValue(objectsPool) + } + return null // RTLM5d1 + } + + override fun entries(): Iterable> { + adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c + + return sequence> { + for ((key, entry) in data.entries) { + val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2 + value?.let { + yield(AbstractMap.SimpleImmutableEntry(key, it)) + } + } + }.asIterable() + } + + override fun keys(): Iterable { + val iterableEntries = entries() + return sequence { + for (entry in iterableEntries) { + yield(entry.key) // RTLM12b + } + }.asIterable() + } + + override fun values(): Iterable { + val iterableEntries = entries() + return sequence { + for (entry in iterableEntries) { + yield(entry.value) // RTLM13b + } + }.asIterable() + } + + override fun size(): Long { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d + } + + override fun set(keyName: String, value: LiveMapValue) = runBlocking { setAsync(keyName, value) } + + override fun remove(keyName: String) = runBlocking { removeAsync(keyName) } + + override fun setAsync(keyName: String, value: LiveMapValue, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) } + } + + override fun removeAsync(keyName: String, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) } + } + + override fun validate(state: ObjectState) = liveMapManager.validate(state) + + override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + return liveMapManager.subscribe(listener) + } + + override fun unsubscribe(listener: LiveMapChange.Listener) = liveMapManager.unsubscribe(listener) + + override fun unsubscribeAll() = liveMapManager.unsubscribeAll() + + private suspend fun setAsync(keyName: String, value: LiveMapValue) { + // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameters + if (keyName.isEmpty()) { + throw invalidInputError("Map key should not be empty") + } + + // RTLM20e - Create ObjectMessage with the MAP_SET operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = objectId, + mapOp = ObjectsMapOp( + key = keyName, + data = fromLiveMapValue(value) + ) + ) + ) + + // RTLM20f - Publish the message + realtimeObjects.publish(arrayOf(msg)) + } + + private suspend fun removeAsync(keyName: String) { + // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameter + if (keyName.isEmpty()) { + throw invalidInputError("Map key should not be empty") + } + + // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = objectId, + mapOp = ObjectsMapOp(key = keyName) + ) + ) + + // RTLM21f - Publish the message + realtimeObjects.publish(arrayOf(msg)) + } + + override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate { + return liveMapManager.applyState(objectState, message.serialTimestamp) + } + + override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage) { + liveMapManager.applyOperation(operation, message.serial, message.serialTimestamp) + } + + override fun clearData(): LiveMapUpdate { + return liveMapManager.calculateUpdateFromDataDiff(data.toMap(), emptyMap()) + .apply { data.clear() } + } + + override fun notifyUpdated(update: ObjectUpdate) { + if (update.noOp) { + return + } + Log.v(tag, "Object $objectId updated: $update") + liveMapManager.notify(update as LiveMapUpdate) + } + + override fun onGCInterval() { + data.entries.removeIf { (_, entry) -> entry.isEligibleForGc() } + } + + companion object { + /** + * Creates a zero-value map object. + * @spec RTLM4 - Returns LiveMap with empty map data + */ + internal fun zeroValue(objectId: String, objects: DefaultRealtimeObjects): DefaultLiveMap { + return DefaultLiveMap(objectId, objects) + } + + /** + * Creates an ObjectMap from map entries. + * Spec: RTO11f4 + */ + internal fun initialValue(entries: MutableMap): MapCreatePayload { + return MapCreatePayload( + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = entries.mapValues { (_, value) -> + ObjectsMapEntry( + tombstone = false, + data = fromLiveMapValue(value) + ) + } + ) + ) + } + + /** + * Spec: RTLM20e5 + */ + private fun fromLiveMapValue(value: LiveMapValue): ObjectData { + return when { + value.isLiveMap || value.isLiveCounter -> { + ObjectData(objectId = (value.value as BaseRealtimeObject).objectId) + } + value.isBoolean -> { + ObjectData(value = ObjectValue.Boolean(value.asBoolean)) + } + value.isBinary -> { + ObjectData(value = ObjectValue.Binary(Binary(value.asBinary))) + } + value.isNumber -> { + ObjectData(value = ObjectValue.Number(value.asNumber)) + } + value.isString -> { + ObjectData(value = ObjectValue.String(value.asString)) + } + value.isJsonObject -> { + ObjectData(value = ObjectValue.JsonObject(value.asJsonObject)) + } + value.isJsonArray -> { + ObjectData(value = ObjectValue.JsonArray(value.asJsonArray)) + } + else -> { + throw IllegalArgumentException("Unsupported value type") + } + } + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt new file mode 100644 index 000000000..0013f2388 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt @@ -0,0 +1,51 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.ObjectsSubscription +import io.ably.lib.objects.type.map.LiveMapChange +import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.util.EventEmitter +import io.ably.lib.util.Log + +internal val noOpMapUpdate = LiveMapUpdate() + +/** + * Interface for handling live map changes by notifying subscribers of updates. + * Implementations typically propagate updates through event emission to registered listeners. + */ +internal interface HandlesLiveMapChange { + /** + * Notifies all registered listeners about a map update by propagating the change through the event system. + * This method is called when map data changes and triggers the emission of update events to subscribers. + */ + fun notify(update: LiveMapUpdate) +} + +internal abstract class LiveMapChangeCoordinator: LiveMapChange, HandlesLiveMapChange { + private val mapChangeEmitter = LiveMapChangeEmitter() + + override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { + mapChangeEmitter.on(listener) + return ObjectsSubscription { + mapChangeEmitter.off(listener) + } + } + + override fun unsubscribe(listener: LiveMapChange.Listener) = mapChangeEmitter.off(listener) + + override fun unsubscribeAll() = mapChangeEmitter.off() + + override fun notify(update: LiveMapUpdate) = mapChangeEmitter.emit(update) +} + +private class LiveMapChangeEmitter : EventEmitter() { + private val tag = "LiveMapChangeEmitter" + + override fun apply(listener: LiveMapChange.Listener?, event: LiveMapUpdate?, vararg args: Any?) { + try { + event?.let { listener?.onUpdated(it) } + ?: Log.w(tag, "Null event passed to listener callback") + } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing listener callback for event: $event", t) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt new file mode 100644 index 000000000..4c32366e1 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt @@ -0,0 +1,85 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectsPool +import io.ably.lib.objects.ObjectsPoolDefaults +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.map.LiveMapValue + +/** + * @spec RTLM3 - Map data structure storing entries + */ +internal data class LiveMapEntry( + val isTombstoned: Boolean = false, + val tombstonedAt: Long? = null, + val timeserial: String? = null, + val data: ObjectData? = null +) + +/** + * Checks if entry is directly tombstoned or references a tombstoned object. Spec: RTLM14 + * @param objectsPool The object pool containing referenced DefaultRealtimeObjects + */ +internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Boolean { + if (isTombstoned) { + return true // RTLM14a + } + data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference + objectsPool.get(refId)?.let { refObject -> + if (refObject.isTombstoned) { + return true + } + } + } + return false // RTLM14b +} + +/** + * Returns value as is if object data stores a primitive type or + * a reference to another RealtimeObject from the pool if it stores an objectId. + */ +internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapValue? { + if (isTombstoned) { return null } // RTLM5d2a + + data?.value?.let { return fromObjectValue(it) } // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e + + data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference + objectsPool.get(refId)?.let { refObject -> + if (refObject.isTombstoned) { + return null // tombstoned objects must not be surfaced to the end users + } + return fromRealtimeObject(refObject) // RTLM5d2f2 + } + } + return null // RTLM5d2g, RTLM5d2f1 +} + +/** + * Extension function to check if a LiveMapEntry is expired and ready for garbage collection + */ +internal fun LiveMapEntry.isEligibleForGc(): Boolean { + val currentTime = System.currentTimeMillis() + return isTombstoned && tombstonedAt?.let { currentTime - it >= ObjectsPoolDefaults.GC_GRACE_PERIOD_MS } == true +} + +private fun fromObjectValue(objValue: ObjectValue): LiveMapValue { + return when (objValue) { + is ObjectValue.String -> LiveMapValue.of(objValue.value) + is ObjectValue.Number -> LiveMapValue.of(objValue.value) + is ObjectValue.Boolean -> LiveMapValue.of(objValue.value) + is ObjectValue.Binary -> LiveMapValue.of(objValue.value.data) + is ObjectValue.JsonObject -> LiveMapValue.of(objValue.value) + is ObjectValue.JsonArray -> LiveMapValue.of(objValue.value) + } +} + +private fun fromRealtimeObject(realtimeObject: BaseRealtimeObject): LiveMapValue { + return when (realtimeObject.objectType) { + ObjectType.Map -> LiveMapValue.of(realtimeObject as LiveMap) + ObjectType.Counter -> LiveMapValue.of(realtimeObject as LiveCounter) + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt new file mode 100644 index 000000000..19a6ef592 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt @@ -0,0 +1,334 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectsMapOp +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.isInvalid +import io.ably.lib.objects.objectError +import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.type.noOp +import io.ably.lib.util.Log + +internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChangeCoordinator() { + + private val objectId = liveMap.objectId + + private val tag = "LiveMapManager" + + /** + * @spec RTLM6 - Overrides object data with state from sync + */ + internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveMapUpdate { + val previousData = liveMap.data.toMap() + + if (objectState.tombstone) { + liveMap.tombstone(serialTimestamp) + } else { + // override data for this object with data from the object state + liveMap.createOperationIsMerged = false // RTLM6b + liveMap.data.clear() + + objectState.map?.entries?.forEach { (key, entry) -> + liveMap.data[key] = LiveMapEntry( + isTombstoned = entry.tombstone ?: false, + tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: System.currentTimeMillis() else null, + timeserial = entry.timeserial, + data = entry.data + ) + } // RTLM6c + + // RTLM6d + objectState.createOp?.let { createOp -> + mergeInitialDataFromCreateOperation(createOp) + } + } + + return calculateUpdateFromDataDiff(previousData, liveMap.data.toMap()) + } + + /** + * @spec RTLM15 - Applies operations to LiveMap + */ + internal fun applyOperation(operation: ObjectOperation, serial: String?, serialTimestamp: Long?) { + val update = when (operation.action) { + ObjectOperationAction.MapCreate -> applyMapCreate(operation) // RTLM15d1 + ObjectOperationAction.MapSet -> { + if (operation.mapOp != null) { + applyMapSet(operation.mapOp, serial) // RTLM15d2 + } else { + throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") + } + } + ObjectOperationAction.MapRemove -> { + if (operation.mapOp != null) { + applyMapRemove(operation.mapOp, serial, serialTimestamp) // RTLM15d3 + } else { + throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") + } + } + ObjectOperationAction.ObjectDelete -> liveMap.tombstone(serialTimestamp) + else -> throw objectError("Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4 + } + + liveMap.notifyUpdated(update) // RTLM15d1a, RTLM15d2a, RTLM15d3a + } + + /** + * @spec RTLM16 - Applies map create operation + */ + private fun applyMapCreate(operation: ObjectOperation): LiveMapUpdate { + if (liveMap.createOperationIsMerged) { + // RTLM16b + // There can't be two different create operation for the same object id, because the object id + // fully encodes that operation. This means we can safely ignore any new incoming create operations + // if we already merged it once. + Log.v( + tag, + "Skipping applying MAP_CREATE op on a map instance as it was already applied before; objectId=${objectId}" + ) + return noOpMapUpdate + } + + validateMapSemantics(operation.map?.semantics) // RTLM16c + + return mergeInitialDataFromCreateOperation(operation) // RTLM16d + } + + /** + * @spec RTLM7 - Applies MAP_SET operation to LiveMap + */ + private fun applyMapSet( + mapOp: ObjectsMapOp, // RTLM7d1 + timeSerial: String?, // RTLM7d2 + ): LiveMapUpdate { + val existingEntry = liveMap.data[mapOp.key] + + // RTLM7a + if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { + // RTLM7a1 - the operation's serial <= the entry's serial, ignore the operation + Log.v(tag, + "Skipping update for key=\"${mapOp.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial};" + + " objectId=${objectId}" + ) + return noOpMapUpdate + } + + if (mapOp.data.isInvalid()) { + throw objectError("Invalid object data for MAP_SET op on objectId=${objectId} on key=${mapOp.key}") + } + + // RTLM7c + mapOp.data?.objectId?.let { + // this MAP_SET op is setting a key to point to another object via its object id, + // but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it). + // we don't want to return undefined from this map's .get() method even if we don't have the object, + // so instead we create a zero-value object for that object id if it not exists. + liveMap.objectsPool.createZeroValueObjectIfNotExists(it) // RTLM7c1 + } + + if (existingEntry != null) { + // RTLM7a2 - Replace existing entry with new one instead of mutating + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = false, // RTLM7a2c + timeserial = timeSerial, // RTLM7a2b + data = mapOp.data // RTLM7a2a + ) + } else { + // RTLM7b, RTLM7b1 + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = false, // RTLM7b2 + timeserial = timeSerial, + data = mapOp.data + ) + } + + return LiveMapUpdate(mapOf(mapOp.key to LiveMapUpdate.Change.UPDATED)) + } + + /** + * @spec RTLM8 - Applies MAP_REMOVE operation to LiveMap + */ + private fun applyMapRemove( + mapOp: ObjectsMapOp, // RTLM8c1 + timeSerial: String?, // RTLM8c2 + timeStamp: Long?, // RTLM8c3 + ): LiveMapUpdate { + val existingEntry = liveMap.data[mapOp.key] + + // RTLM8a + if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { + // RTLM8a1 - the operation's serial <= the entry's serial, ignore the operation + Log.v( + tag, + "Skipping remove for key=\"${mapOp.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial}; " + + "objectId=${objectId}" + ) + return noOpMapUpdate + } + + val tombstonedAt = if (timeStamp != null) timeStamp else { + Log.w( + tag, + "No timestamp provided for MAP_REMOVE op on key=\"${mapOp.key}\"; using current time as tombstone time; " + + "objectId=${objectId}" + ) + System.currentTimeMillis() + } + + if (existingEntry != null) { + // RTLM8a2 - Replace existing entry with new one instead of mutating + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = true, // RTLM8a2c + tombstonedAt = tombstonedAt, + timeserial = timeSerial, // RTLM8a2b + data = null // RTLM8a2a + ) + } else { + // RTLM8b, RTLM8b1 + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = true, // RTLM8b2 + tombstonedAt = tombstonedAt, + timeserial = timeSerial + ) + } + + return LiveMapUpdate(mapOf(mapOp.key to LiveMapUpdate.Change.REMOVED)) + } + + /** + * For Lww CRDT semantics (the only supported LiveMap semantic) an operation + * Should only be applied if incoming serial is strictly greater than existing entry's serial. + * @spec RTLM9 - Serial comparison logic for map operations + */ + private fun canApplyMapOperation(existingMapEntrySerial: String?, timeSerial: String?): Boolean { + if (existingMapEntrySerial.isNullOrEmpty() && timeSerial.isNullOrEmpty()) { // RTLM9b + return false + } + if (existingMapEntrySerial.isNullOrEmpty()) { // RTLM9d - If true, means timeSerial is not empty based on previous checks + return true + } + if (timeSerial.isNullOrEmpty()) { // RTLM9c - Check reached here means existingMapEntrySerial is not empty + return false + } + return timeSerial > existingMapEntrySerial // RTLM9e - both are not empty + } + + /** + * @spec RTLM17 - Merges initial data from create operation + */ + private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveMapUpdate { + if (operation.map?.entries.isNullOrEmpty()) { // no map entries in MAP_CREATE op + return noOpMapUpdate + } + + val aggregatedUpdate = mutableListOf() + + // RTLM17a + // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys. + // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations. + operation.map?.entries?.forEach { (key, entry) -> + // for a MAP_CREATE operation we must use the serial value available on an entry, instead of a serial on a message + val opTimeserial = entry.timeserial + val update = if (entry.tombstone == true) { + // RTLM17a2 - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op + applyMapRemove(ObjectsMapOp(key), opTimeserial, entry.serialTimestamp) + } else { + // RTLM17a1 - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op + applyMapSet(ObjectsMapOp(key, entry.data), opTimeserial) + } + + // skip noop updates + if (update.noOp) { + return@forEach + } + + aggregatedUpdate.add(update) + } + + liveMap.createOperationIsMerged = true // RTLM17b + + return LiveMapUpdate( + aggregatedUpdate.map { it.update }.fold(emptyMap()) { acc, map -> acc + map } + ) + } + + internal fun calculateUpdateFromDataDiff( + prevData: Map, + newData: Map + ): LiveMapUpdate { + val update = mutableMapOf() + + // Check for removed entries + for ((key, prevEntry) in prevData) { + if (!prevEntry.isTombstoned && !newData.containsKey(key)) { + update[key] = LiveMapUpdate.Change.REMOVED + } + } + + // Check for added/updated entries + for ((key, newEntry) in newData) { + if (!prevData.containsKey(key)) { + // if property does not exist in current map, but new data has it as non-tombstoned property - got updated + if (!newEntry.isTombstoned) { + update[key] = LiveMapUpdate.Change.UPDATED + } + // otherwise, if new data has this prop tombstoned - do nothing, as property didn't exist anyway + continue + } + + // properties that exist both in current and new map data need to have their values compared to decide on update type + val prevEntry = prevData[key]!! + + // compare tombstones first + if (prevEntry.isTombstoned && !newEntry.isTombstoned) { + // prev prop is tombstoned, but new is not. it means prop was updated to a meaningful value + update[key] = LiveMapUpdate.Change.UPDATED + continue + } + if (!prevEntry.isTombstoned && newEntry.isTombstoned) { + // prev prop is not tombstoned, but new is. it means prop was removed + update[key] = LiveMapUpdate.Change.REMOVED + continue + } + if (prevEntry.isTombstoned && newEntry.isTombstoned) { + // props are tombstoned - treat as noop, as there is no data to compare + continue + } + + // both props exist and are not tombstoned, need to compare values to see if it was changed + val valueChanged = prevEntry.data != newEntry.data + if (valueChanged) { + update[key] = LiveMapUpdate.Change.UPDATED + continue + } + } + + return LiveMapUpdate(update) + } + + internal fun validate(state: ObjectState) { + liveMap.validateObjectId(state.objectId) + validateMapSemantics(state.map?.semantics) + state.createOp?.let { createOp -> + liveMap.validateObjectId(createOp.objectId) + validateMapCreateAction(createOp.action) + validateMapSemantics(createOp.map?.semantics) + } + } + + private fun validateMapCreateAction(action: ObjectOperationAction) { + if (action != ObjectOperationAction.MapCreate) { + throw objectError("Invalid create operation action $action for LiveMap objectId=${objectId}") + } + } + + private fun validateMapSemantics(semantics: ObjectsMapSemantics?) { + if (semantics != liveMap.semantics) { + throw objectError( + "Invalid object: incoming object map semantics=$semantics; current map semantics=${ObjectsMapSemantics.LWW}" + ) + } + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt new file mode 100644 index 000000000..a91f0e9cf --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt @@ -0,0 +1,65 @@ +package io.ably.lib.objects + +import java.lang.reflect.Field +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.lang.reflect.Method + +suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) { + withContext(Dispatchers.Default) { + withTimeout(timeoutInMs) { + do { + val success = block() + delay(100) + } while (!success) + } + } +} + +fun Any.setPrivateField(name: String, value: Any?) { + val valueField = javaClass.findField(name) + valueField.isAccessible = true + valueField.set(this, value) +} + +fun Any.getPrivateField(name: String): T { + val valueField = javaClass.findField(name) + valueField.isAccessible = true + @Suppress("UNCHECKED_CAST") + return valueField.get(this) as T +} + +private fun Class<*>.findField(name: String): Field { + var result = kotlin.runCatching { getDeclaredField(name) } + var currentClass = this + while (result.isFailure && currentClass.superclass != null) // stop when we got field or reached top of class hierarchy + { + currentClass = currentClass.superclass!! + result = kotlin.runCatching { currentClass.getDeclaredField(name) } + } + if (result.isFailure) { + throw result.exceptionOrNull() as Exception + } + return result.getOrNull() as Field +} + +suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?): T = suspendCancellableCoroutine { cont -> + val suspendMethod = javaClass.declaredMethods.find { it.name == methodName } + ?: error("Method '$methodName' not found") + suspendMethod.isAccessible = true + suspendMethod.invoke(this, *args, cont) +} + +fun Any.invokePrivateMethod(methodName: String, vararg args: Any?): T { + val method = javaClass.declaredMethods.find { it.name == methodName } ?: error("Method '$methodName' not found") + method.isAccessible = true + @Suppress("UNCHECKED_CAST") + return method.invoke(this, *args) as T +} + +fun Class<*>.findMethod(methodName: String): Method { + return methods.find { it.name.contains(methodName) } ?: error("Method '$methodName' not found") +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt new file mode 100644 index 000000000..79a99de32 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt @@ -0,0 +1,367 @@ +package io.ably.lib.objects.integration + +import io.ably.lib.objects.assertWaiter +import io.ably.lib.objects.integration.helpers.ObjectId +import io.ably.lib.objects.integration.helpers.fixtures.createUserEngagementMatrixMap +import io.ably.lib.objects.integration.helpers.fixtures.createUserMapWithCountersObject +import io.ably.lib.objects.integration.setup.IntegrationTest +import io.ably.lib.objects.type.map.LiveMapValue +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DefaultLiveCounterTest: IntegrationTest() { + /** + * Tests the synchronization process when a user map object with counters is initialized before channel attach. + * This includes checking the initial values of all counter objects and nested maps in the + * comprehensive user engagement counter structure. + */ + @Test + fun testLiveCounterSync() = runTest { + val channelName = generateChannelName() + val userMapObjectId = restObjects.createUserMapWithCountersObject(channelName) + restObjects.setMapRef(channelName, "root", "user", userMapObjectId) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Get the user map object from the root map + val userMap = rootMap.get("user")?.asLiveMap + assertNotNull(userMap, "User map should be synchronized") + assertEquals(7L, userMap.size(), "User map should contain 7 top-level entries") + + // Assert direct counter objects at the top level of the user map + // Test profileViews counter - should have initial value of 127 + val profileViewsCounter = userMap.get("profileViews")?.asLiveCounter + assertNotNull(profileViewsCounter, "Profile views counter should exist") + assertEquals(127.0, profileViewsCounter.value(), "Profile views counter should have initial value of 127") + + // Test postLikes counter - should have initial value of 45 + val postLikesCounter = userMap.get("postLikes")?.asLiveCounter + assertNotNull(postLikesCounter, "Post likes counter should exist") + assertEquals(45.0, postLikesCounter.value(), "Post likes counter should have initial value of 45") + + // Test commentCount counter - should have initial value of 23 + val commentCountCounter = userMap.get("commentCount")?.asLiveCounter + assertNotNull(commentCountCounter, "Comment count counter should exist") + assertEquals(23.0, commentCountCounter.value(), "Comment count counter should have initial value of 23") + + // Test followingCount counter - should have initial value of 89 + val followingCountCounter = userMap.get("followingCount")?.asLiveCounter + assertNotNull(followingCountCounter, "Following count counter should exist") + assertEquals(89.0, followingCountCounter.value(), "Following count counter should have initial value of 89") + + // Test followersCount counter - should have initial value of 156 + val followersCountCounter = userMap.get("followersCount")?.asLiveCounter + assertNotNull(followersCountCounter, "Followers count counter should exist") + assertEquals(156.0, followersCountCounter.value(), "Followers count counter should have initial value of 156") + + // Test loginStreak counter - should have initial value of 7 + val loginStreakCounter = userMap.get("loginStreak")?.asLiveCounter + assertNotNull(loginStreakCounter, "Login streak counter should exist") + assertEquals(7.0, loginStreakCounter.value(), "Login streak counter should have initial value of 7") + + // Assert the nested engagement metrics map + val engagementMetrics = userMap.get("engagementMetrics")?.asLiveMap + assertNotNull(engagementMetrics, "Engagement metrics map should exist") + assertEquals(4L, engagementMetrics.size(), "Engagement metrics map should contain 4 counter entries") + + // Assert counter objects within the engagement metrics map + // Test totalShares counter - should have initial value of 34 + val totalSharesCounter = engagementMetrics.get("totalShares")?.asLiveCounter + assertNotNull(totalSharesCounter, "Total shares counter should exist") + assertEquals(34.0, totalSharesCounter.value(), "Total shares counter should have initial value of 34") + + // Test totalBookmarks counter - should have initial value of 67 + val totalBookmarksCounter = engagementMetrics.get("totalBookmarks")?.asLiveCounter + assertNotNull(totalBookmarksCounter, "Total bookmarks counter should exist") + assertEquals(67.0, totalBookmarksCounter.value(), "Total bookmarks counter should have initial value of 67") + + // Test totalReactions counter - should have initial value of 189 + val totalReactionsCounter = engagementMetrics.get("totalReactions")?.asLiveCounter + assertNotNull(totalReactionsCounter, "Total reactions counter should exist") + assertEquals(189.0, totalReactionsCounter.value(), "Total reactions counter should have initial value of 189") + + // Test dailyActiveStreak counter - should have initial value of 12 + val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak")?.asLiveCounter + assertNotNull(dailyActiveStreakCounter, "Daily active streak counter should exist") + assertEquals(12.0, dailyActiveStreakCounter.value(), "Daily active streak counter should have initial value of 12") + + // Verify that all expected counter keys exist at the top level + val topLevelKeys = userMap.keys().toSet() + val expectedTopLevelKeys = setOf( + "profileViews", "postLikes", "commentCount", "followingCount", + "followersCount", "loginStreak", "engagementMetrics" + ) + assertEquals(expectedTopLevelKeys, topLevelKeys, "Top-level keys should match expected counter keys") + + // Verify that all expected counter keys exist in the engagement metrics map + val engagementKeys = engagementMetrics.keys().toSet() + val expectedEngagementKeys = setOf( + "totalShares", "totalBookmarks", "totalReactions", "dailyActiveStreak" + ) + assertEquals(expectedEngagementKeys, engagementKeys, "Engagement metrics keys should match expected counter keys") + + // Verify total counter values match expectations (useful for integration testing) + val totalUserCounterValues = listOf(127.0, 45.0, 23.0, 89.0, 156.0, 7.0).sum() + val totalEngagementCounterValues = listOf(34.0, 67.0, 189.0, 12.0).sum() + assertEquals(447.0, totalUserCounterValues, "Sum of user counter values should be 447") + assertEquals(302.0, totalEngagementCounterValues, "Sum of engagement counter values should be 302") + } + + /** + * Tests sequential counter operations including creation with initial value, incrementing by various amounts, + * decrementing by various amounts, and validates the resulting counter value after each operation. + */ + @Test + fun testLiveCounterOperations() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Step 1: Create a new counter with initial value of 10 + val testCounterObjectId = restObjects.createCounter(channelName, initialValue = 10.0) + restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId) + + // Wait for updated testCounter to be available in the root map + assertWaiter { rootMap.get("testCounter") != null } + + // Assert initial state after creation + val testCounter = rootMap.get("testCounter")?.asLiveCounter + assertNotNull(testCounter, "Test counter should be created and accessible") + assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") + + // Step 2: Increment counter by 5 (10 + 5 = 15) + restObjects.incrementCounter(channelName, testCounterObjectId, 5.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 15.0 } + + // Assert after first increment + assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") + + // Step 3: Increment counter by 3 (15 + 3 = 18) + restObjects.incrementCounter(channelName, testCounterObjectId, 3.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 18.0 } + + // Assert after second increment + assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") + + // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) + restObjects.incrementCounter(channelName, testCounterObjectId, 12.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 30.0 } + + // Assert after third increment + assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") + + // Step 5: Decrement counter by 7 (30 - 7 = 23) + restObjects.decrementCounter(channelName, testCounterObjectId, 7.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 23.0 } + + // Assert after first decrement + assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") + + // Step 6: Decrement counter by 4 (23 - 4 = 19) + restObjects.decrementCounter(channelName, testCounterObjectId, 4.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 19.0 } + + // Assert after second decrement + assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") + + // Step 7: Increment counter by 1 (19 + 1 = 20) + restObjects.incrementCounter(channelName, testCounterObjectId, 1.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 20.0 } + + // Assert after final increment + assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") + + // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) + restObjects.decrementCounter(channelName, testCounterObjectId, 15.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 5.0 } + + // Assert after large decrement + assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") + + // Final verification - test final increment to ensure counter still works + restObjects.incrementCounter(channelName, testCounterObjectId, 25.0) + assertWaiter { testCounter.value() == 30.0 } + + // Assert final state + assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") + + // Verify the counter object is still accessible and functioning + assertNotNull(testCounter, "Counter should still be accessible at the end") + + // Verify we can still access it from the root map + val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter + assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") + assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") + } + + @Test + fun testLiveCounterOperationsUsingRealtime() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + val rootMap = channel.objects.root + + // Step 1: Create a new counter with initial value of 10 + val testCounterObject = objects.createCounter( 10.0) + rootMap.set("testCounter", LiveMapValue.of(testCounterObject)) + + // Wait for updated testCounter to be available in the root map + assertWaiter { rootMap.get("testCounter") != null } + + // Assert initial state after creation + val testCounter = rootMap.get("testCounter")?.asLiveCounter + assertNotNull(testCounter, "Test counter should be created and accessible") + assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") + + // Step 2: Increment counter by 5 (10 + 5 = 15) + testCounter.increment(5.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 15.0 } + + // Assert after first increment + assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") + + // Step 3: Increment counter by 3 (15 + 3 = 18) + testCounter.increment(3.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 18.0 } + + // Assert after second increment + assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") + + // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) + testCounter.increment(12.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 30.0 } + + // Assert after third increment + assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") + + // Step 5: Decrement counter by 7 (30 - 7 = 23) + testCounter.decrement(7.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 23.0 } + + // Assert after first decrement + assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") + + // Step 6: Decrement counter by 4 (23 - 4 = 19) + testCounter.decrement(4.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 19.0 } + + // Assert after second decrement + assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") + + // Step 7: Increment counter by 1 (19 + 1 = 20) + testCounter.increment(1.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 20.0 } + + // Assert after final increment + assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") + + // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) + testCounter.decrement(15.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 5.0 } + + // Assert after large decrement + assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") + + // Final verification - test final increment to ensure counter still works + testCounter.increment(25.0) + assertWaiter { testCounter.value() == 30.0 } + + // Assert final state + assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") + + // Verify the counter object is still accessible and functioning + assertNotNull(testCounter, "Counter should still be accessible at the end") + + // Verify we can still access it from the root map + val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter + assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") + assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") + } + + @Test + fun testLiveCounterChangesUsingSubscription() = runTest { + val channelName = generateChannelName() + val userEngagementMapId = restObjects.createUserEngagementMatrixMap(channelName) + restObjects.setMapRef(channelName, "root", "userMatrix", userEngagementMapId) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + val userEngagementMap = rootMap.get("userMatrix")?.asLiveMap + assertEquals(4L, userEngagementMap!!.size(), "User engagement map should contain 4 top-level entries") + + val totalReactions = userEngagementMap.get("totalReactions")?.asLiveCounter + assertEquals(189.0, totalReactions!!.value(), "Total reactions counter should have initial value of 189") + + // Subscribe to changes on the totalReactions counter + val counterUpdates = mutableListOf() + val totalReactionsSubscription = totalReactions.subscribe { update -> + counterUpdates.add(update.update.amount) + } + + // Step 1: Increment the totalReactions counter by 10 (189 + 10 = 199) + restObjects.incrementCounter(channelName, totalReactions.ObjectId, 10.0) + + // Wait for the update to be received + assertWaiter { counterUpdates.isNotEmpty() } + + // Verify the increment update was received + assertEquals(1, counterUpdates.size, "Should receive one update for increment") + assertEquals(10.0, counterUpdates.first(), "Update should contain increment amount of 10") + assertEquals(199.0, totalReactions.value(), "Counter should be incremented to 199") + + // Step 2: Decrement the totalReactions counter by 5 (199 - 5 = 194) + counterUpdates.clear() + restObjects.decrementCounter(channelName, totalReactions.ObjectId, 5.0) + + // Wait for the second update + assertWaiter { counterUpdates.isNotEmpty() } + + // Verify the decrement update was received + assertEquals(1, counterUpdates.size, "Should receive one update for decrement") + assertEquals(-5.0, counterUpdates.first(), "Update should contain decrement amount of -5") + assertEquals(194.0, totalReactions.value(), "Counter should be decremented to 194") + + // Step 3: Increment the totalReactions counter by 15 (194 + 15 = 209) + counterUpdates.clear() + restObjects.incrementCounter(channelName, totalReactions.ObjectId, 15.0) + + // Wait for the third update + assertWaiter { counterUpdates.isNotEmpty() } + + // Verify the third increment update was received + assertEquals(1, counterUpdates.size, "Should receive one update for third increment") + assertEquals(15.0, counterUpdates.first(), "Update should contain increment amount of 15") + assertEquals(209.0, totalReactions.value(), "Counter should be incremented to 209") + + // Clean up subscription + counterUpdates.clear() + totalReactionsSubscription.unsubscribe() + + // No updates should be received after unsubscribing + restObjects.incrementCounter(channelName, totalReactions.ObjectId, 20.0) + + // Wait for a moment to ensure no updates are received + assertWaiter { totalReactions.value() == 229.0 } + + assertTrue(counterUpdates.isEmpty(), "No updates should be received after unsubscribing") + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt new file mode 100644 index 000000000..e3043abc1 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt @@ -0,0 +1,424 @@ +package io.ably.lib.objects.integration + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.integration.helpers.fixtures.createUserMapObject +import io.ably.lib.objects.integration.helpers.fixtures.createUserProfileMapObject +import io.ably.lib.objects.integration.setup.IntegrationTest +import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.type.map.LiveMapValue +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DefaultLiveMapTest: IntegrationTest() { + /** + * Tests the synchronization process when a user map object is initialized before channel attach. + * This includes checking the initial values of all nested maps, counters, and primitive data types + * in the comprehensive user map object structure. + */ + @Test + fun testLiveMapSync() = runTest { + val channelName = generateChannelName() + val userMapObjectId = restObjects.createUserMapObject(channelName) + restObjects.setMapRef(channelName, "root", "user", userMapObjectId) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Get the user map object from the root map + val userMap = rootMap.get("user")?.asLiveMap + assertNotNull(userMap, "User map should be synchronized") + assertEquals(5L, userMap.size(), "User map should contain 5 top-level entries") + + // Assert Counter Objects + // Test loginCounter - should have initial value of 5 + val loginCounter = userMap.get("loginCounter")?.asLiveCounter + assertNotNull(loginCounter, "Login counter should exist") + assertEquals(5.0, loginCounter.value(), "Login counter should have initial value of 5") + + // Test sessionCounter - should have initial value of 0 + val sessionCounter = userMap.get("sessionCounter")?.asLiveCounter + assertNotNull(sessionCounter, "Session counter should exist") + assertEquals(0.0, sessionCounter.value(), "Session counter should have initial value of 0") + + // Assert User Profile Map + val userProfile = userMap.get("userProfile")?.asLiveMap + assertNotNull(userProfile, "User profile map should exist") + assertEquals(6L, userProfile.size(), "User profile should contain 6 entries") + + // Assert user profile primitive values + assertEquals("user123", userProfile.get("userId")?.asString, "User ID should match expected value") + assertEquals("John Doe", userProfile.get("name")?.asString, "User name should match expected value") + assertEquals("john@example.com", userProfile.get("email")?.asString, "User email should match expected value") + assertEquals(true, userProfile.get("isActive")?.asBoolean, "User should be active") + + // Assert Preferences Map (nested within user profile) + val preferences = userProfile.get("preferences")?.asLiveMap + assertNotNull(preferences, "Preferences map should exist") + assertEquals(4L, preferences.size(), "Preferences should contain 4 entries") + assertEquals("dark", preferences.get("theme")?.asString, "Theme preference should be dark") + assertEquals(true, preferences.get("notifications")?.asBoolean, "Notifications should be enabled") + assertEquals("en", preferences.get("language")?.asString, "Language should be English") + assertEquals(3.0, preferences.get("maxRetries")?.asNumber, "Max retries should be 3") + + // Assert Metrics Map (nested within user profile) + val metrics = userProfile.get("metrics")?.asLiveMap + assertNotNull(metrics, "Metrics map should exist") + assertEquals(4L, metrics.size(), "Metrics should contain 4 entries") + assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime")?.asString, "Last login time should match") + assertEquals(42.0, metrics.get("profileViews")?.asNumber, "Profile views should be 42") + + // Test counter references within metrics map + val totalLoginsCounter = metrics.get("totalLogins")?.asLiveCounter + assertNotNull(totalLoginsCounter, "Total logins counter should exist") + assertEquals(5.0, totalLoginsCounter.value(), "Total logins should reference login counter with value 5") + + val activeSessionsCounter = metrics.get("activeSessions")?.asLiveCounter + assertNotNull(activeSessionsCounter, "Active sessions counter should exist") + assertEquals(0.0, activeSessionsCounter.value(), "Active sessions should reference session counter with value 0") + + // Assert direct references to maps from top-level user map + val preferencesMapRef = userMap.get("preferencesMap")?.asLiveMap + assertNotNull(preferencesMapRef, "Preferences map reference should exist") + assertEquals(4L, preferencesMapRef.size(), "Referenced preferences map should have 4 entries") + assertEquals("dark", preferencesMapRef.get("theme")?.asString, "Referenced preferences should match nested preferences") + + val metricsMapRef = userMap.get("metricsMap")?.asLiveMap + assertNotNull(metricsMapRef, "Metrics map reference should exist") + assertEquals(4L, metricsMapRef.size(), "Referenced metrics map should have 4 entries") + assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime")?.asString, "Referenced metrics should match nested metrics") + + // Verify that references point to the same objects + assertEquals(preferences.get("theme")?.asString, preferencesMapRef.get("theme")?.asString, "Preference references should point to same data") + assertEquals(metrics.get("profileViews")?.asNumber, metricsMapRef.get("profileViews")?.asNumber, "Metrics references should point to same data") + } + + /** + * Tests sequential map operations including creation with initial data, updating existing fields, + * adding new fields, and removing fields. Validates the resulting data after each operation. + */ + @Test + fun testLiveMapOperations() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Step 1: Create a new map with initial data + val testMapObjectId = restObjects.createMap( + channelName, + data = mapOf( + "name" to ObjectData(value = ObjectValue.String("Alice")), + "age" to ObjectData(value = ObjectValue.Number(30)), + "isActive" to ObjectData(value = ObjectValue.Boolean(true)) + ) + ) + restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId) + + // wait for updated testMap to be available in the root map + assertWaiter { rootMap.get("testMap") != null } + + // Assert initial state after creation + val testMap = rootMap.get("testMap")?.asLiveMap + assertNotNull(testMap, "Test map should be created and accessible") + assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") + assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") + assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") + + // Step 2: Update an existing field (name from "Alice" to "Bob") + restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectValue.String("Bob")) + // Wait for the map to be updated + assertWaiter { testMap.get("name")?.asString == "Bob" } + + // Assert after updating existing field + assertEquals(3L, testMap.size(), "Map size should remain the same after update") + assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + + // Step 3: Add a new field (email) + restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectValue.String("bob@example.com")) + // Wait for the map to be updated + assertWaiter { testMap.get("email")?.asString == "bob@example.com" } + + // Assert after adding new field + assertEquals(4L, testMap.size(), "Map size should increase after adding new field") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") + + // Step 4: Add another new field with different data type (score as number) + restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectValue.Number(85)) + // Wait for the map to be updated + assertWaiter { testMap.get("score")?.asNumber == 85.0 } + + // Assert after adding second new field + assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") + + // Step 5: Update the boolean field + restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectValue.Boolean(false)) + // Wait for the map to be updated + assertWaiter { testMap.get("isActive")?.asBoolean == false } + + // Assert after updating boolean field + assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") + + // Step 6: Remove a field (age) + restObjects.removeMapValue(channelName, testMapObjectId, "age") + // Wait for the map to be updated + assertWaiter { testMap.get("age") == null } + + // Assert after removing field + assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertNull(testMap.get("age"), "Age should be removed and return null") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") + + // Step 7: Remove another field (score) + restObjects.removeMapValue(channelName, testMapObjectId, "score") + // Wait for the map to be updated + assertWaiter { testMap.get("score") == null } + + // Assert final state after second removal + assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertNull(testMap.get("score"), "Score should be removed and return null") + assertNull(testMap.get("age"), "Age should remain null") + + // Final verification - ensure all expected keys exist and unwanted keys don't + assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") + + val finalKeys = testMap.keys().toSet() + assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") + + val finalValues = testMap.values().map { it.value }.toSet() + assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") + } + + /** + * Tests sequential map operations including creation with initial data, updating existing fields, + * adding new fields, and removing fields. Validates the resulting data after each operation. + */ + @Test + fun testLiveMapOperationsUsingRealtime() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + val rootMap = channel.objects.root + + // Step 1: Create a new map with initial data + val testMapObject = objects.createMap( + mapOf( + "name" to LiveMapValue.of("Alice"), + "age" to LiveMapValue.of(30), + "isActive" to LiveMapValue.of(true), + ) + ) + rootMap.set("testMap", LiveMapValue.of(testMapObject)) + + // wait for updated testMap to be available in the root map + assertWaiter { rootMap.get("testMap") != null } + + // Assert initial state after creation + val testMap = rootMap.get("testMap")?.asLiveMap + assertNotNull(testMap, "Test map should be created and accessible") + assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") + assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") + assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") + + // Step 2: Update an existing field (name from "Alice" to "Bob") + testMap.set("name", LiveMapValue.of("Bob")) + // Wait for the map to be updated + assertWaiter { testMap.get("name")?.asString == "Bob" } + + // Assert after updating existing field + assertEquals(3L, testMap.size(), "Map size should remain the same after update") + assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + + // Step 3: Add a new field (email) + testMap.set("email", LiveMapValue.of("bob@example.com")) + // Wait for the map to be updated + assertWaiter { testMap.get("email")?.asString == "bob@example.com" } + + // Assert after adding new field + assertEquals(4L, testMap.size(), "Map size should increase after adding new field") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") + + // Step 4: Add another new field with different data type (score as number) + testMap.set("score", LiveMapValue.of(85)) + // Wait for the map to be updated + assertWaiter { testMap.get("score")?.asNumber == 85.0 } + + // Assert after adding second new field + assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") + + // Step 5: Update the boolean field + testMap.set("isActive", LiveMapValue.of(false)) + // Wait for the map to be updated + assertWaiter { testMap.get("isActive")?.asBoolean == false } + + // Assert after updating boolean field + assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") + + // Step 6: Remove a field (age) + testMap.remove("age") + // Wait for the map to be updated + assertWaiter { testMap.get("age") == null } + + // Assert after removing field + assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertNull(testMap.get("age"), "Age should be removed and return null") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") + + // Step 7: Remove another field (score) + testMap.remove("score") + // Wait for the map to be updated + assertWaiter { testMap.get("score") == null } + + // Assert final state after second removal + assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertNull(testMap.get("score"), "Score should be removed and return null") + assertNull(testMap.get("age"), "Age should remain null") + + // Final verification - ensure all expected keys exist and unwanted keys don't + assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") + + val finalKeys = testMap.keys().toSet() + assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") + + val finalValues = testMap.values().map { it.value }.toSet() + assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") + } + + @Test + fun testLiveMapChangesUsingSubscription() = runTest { + val channelName = generateChannelName() + val userProfileObjectId = restObjects.createUserProfileMapObject(channelName) + restObjects.setMapRef(channelName, "root", "userProfile", userProfileObjectId) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Get the user profile map object from the root map + val userProfile = rootMap.get("userProfile")?.asLiveMap + assertNotNull(userProfile, "User profile should be synchronized") + assertEquals(4L, userProfile.size(), "User profile should contain 4 entries") + + // Verify initial values + assertEquals("user123", userProfile.get("userId")?.asString, "Initial userId should be user123") + assertEquals("John Doe", userProfile.get("name")?.asString, "Initial name should be John Doe") + assertEquals("john@example.com", userProfile.get("email")?.asString, "Initial email should be john@example.com") + assertEquals(true, userProfile.get("isActive")?.asBoolean, "Initial isActive should be true") + + // Subscribe to changes in the user profile map + val userProfileUpdates = mutableListOf() + val userProfileSubscription = userProfile.subscribe { update -> userProfileUpdates.add(update) } + + // Step 1: Update an existing field in the user profile map (change the name) + restObjects.setMapValue(channelName, userProfileObjectId, "name", ObjectValue.String("Bob Smith")) + + // Wait for the update to be received + assertWaiter { userProfileUpdates.isNotEmpty() } + + // Verify the update was received + assertEquals(1, userProfileUpdates.size, "Should receive one update") + val firstUpdateMap = userProfileUpdates.first().update + assertEquals(1, firstUpdateMap.size, "Should have one key change") + assertTrue(firstUpdateMap.containsKey("name"), "Update should contain name key") + assertEquals(LiveMapUpdate.Change.UPDATED, firstUpdateMap["name"], "name should be marked as UPDATED") + + // Verify the value was actually updated + assertEquals("Bob Smith", userProfile.get("name")?.asString, "Name should be updated to Bob Smith") + + // Step 2: Update another field in the user profile map (change the email) + userProfileUpdates.clear() + restObjects.setMapValue(channelName, userProfileObjectId, "email", ObjectValue.String("bob@example.com")) + + // Wait for the second update + assertWaiter { userProfileUpdates.isNotEmpty() } + + // Verify the second update + assertEquals(1, userProfileUpdates.size, "Should receive one update for the second change") + val secondUpdateMap = userProfileUpdates.first().update + assertEquals(1, secondUpdateMap.size, "Should have one key change") + assertTrue(secondUpdateMap.containsKey("email"), "Update should contain email key") + assertEquals(LiveMapUpdate.Change.UPDATED, secondUpdateMap["email"], "email should be marked as UPDATED") + + // Verify the value was actually updated + assertEquals("bob@example.com", userProfile.get("email")?.asString, "Email should be updated to bob@example.com") + + // Step 3: Remove an existing field from the user profile map (remove isActive) + userProfileUpdates.clear() + restObjects.removeMapValue(channelName, userProfileObjectId, "isActive") + + // Wait for the removal update + assertWaiter { userProfileUpdates.isNotEmpty() } + + // Verify the removal update + assertEquals(1, userProfileUpdates.size, "Should receive one update for removal") + val removalUpdateMap = userProfileUpdates.first().update + assertEquals(1, removalUpdateMap.size, "Should have one key change") + assertTrue(removalUpdateMap.containsKey("isActive"), "Update should contain isActive key") + assertEquals(LiveMapUpdate.Change.REMOVED, removalUpdateMap["isActive"], "isActive should be marked as REMOVED") + + // Verify final state of the user profile map + assertEquals(3L, userProfile.size(), "User profile should have 3 entries after removing isActive") + assertEquals("user123", userProfile.get("userId")?.asString, "userId should remain unchanged") + assertEquals("Bob Smith", userProfile.get("name")?.asString, "name should remain updated") + assertEquals("bob@example.com", userProfile.get("email")?.asString, "email should remain updated") + assertNull(userProfile.get("isActive"), "isActive should be removed") + + // Clean up subscription + userProfileUpdates.clear() + userProfileSubscription.unsubscribe() + // No updates should be received after unsubscribing + restObjects.setMapValue(channelName, userProfileObjectId, "country", ObjectValue.String("uk")) + + // Wait for a moment to ensure no updates are received + assertWaiter { userProfile.size() == 4L } + + assertTrue(userProfileUpdates.isEmpty(), "No updates should be received after unsubscribing") + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt new file mode 100644 index 000000000..a95954dc7 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt @@ -0,0 +1,234 @@ +package io.ably.lib.objects.integration + +import io.ably.lib.objects.* +import io.ably.lib.objects.integration.helpers.State +import io.ably.lib.objects.integration.helpers.fixtures.initializeRootMap +import io.ably.lib.objects.integration.helpers.simulateObjectDelete +import io.ably.lib.objects.integration.setup.IntegrationTest +import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.map.LiveMapUpdate +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.text.toByteArray + +class DefaultRealtimeObjectsTest : IntegrationTest() { + + @Test + fun testChannelObjects() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + assertNotNull(objects) + } + + @Test + fun testObjectsSyncEvents() = runTest { + val channelName = generateChannelName() + // Initialize the root map on the channel with initial data + restObjects.initializeRootMap(channelName) + + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + assertNotNull(objects) + + assertEquals(ObjectsState.Initialized, objects.State, "Initial state should be INITIALIZED") + + val syncStates = mutableListOf() + objects.on(ObjectsStateEvent.SYNCING) { + syncStates.add(it) + } + objects.on(ObjectsStateEvent.SYNCED) { + syncStates.add(it) + } + + channel.attach() + + assertWaiter { syncStates.size == 2 } // Wait for both SYNCING and SYNCED events + + assertEquals(ObjectsStateEvent.SYNCING, syncStates[0], "First event should be SYNCING") + assertEquals(ObjectsStateEvent.SYNCED, syncStates[1], "Second event should be SYNCED") + + val rootMap = objects.root + assertEquals(6, rootMap.size(), "Root map should have 6 entries after sync") + } + + /** + * This will test objects sync process when the root map is initialized before channel attach. + * This includes checking the initial values of counters, maps, and other data types. + */ + @Test + fun testObjectsSync() = runTest { + val channelName = generateChannelName() + // Initialize the root map on the channel with initial data + restObjects.initializeRootMap(channelName) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + assertNotNull(rootMap) + + // Assert Counter Objects + // Test emptyCounter - should have initial value of 0 + val emptyCounter = rootMap.get("emptyCounter")?.asLiveCounter + assertNotNull(emptyCounter) + assertEquals(0.0, emptyCounter.value()) + + // Test initialValueCounter - should have initial value of 10 + val initialValueCounter = rootMap.get("initialValueCounter")?.asLiveCounter + assertNotNull(initialValueCounter) + assertEquals(10.0, initialValueCounter.value()) + + // Test referencedCounter - should have initial value of 20 + val referencedCounter = rootMap.get("referencedCounter")?.asLiveCounter + assertNotNull(referencedCounter) + assertEquals(20.0, referencedCounter.value()) + + // Assert Map Objects + // Test emptyMap - should be an empty map + val emptyMap = rootMap.get("emptyMap")?.asLiveMap + assertNotNull(emptyMap) + assertEquals(0L, emptyMap.size()) + + // Test referencedMap - should contain one key "counterKey" pointing to referencedCounter + val referencedMap = rootMap.get("referencedMap")?.asLiveMap + assertNotNull(referencedMap) + assertEquals(1L, referencedMap.size()) + val referencedMapCounter = referencedMap.get("counterKey")?.asLiveCounter + assertNotNull(referencedMapCounter) + assertEquals(20.0, referencedMapCounter.value()) // Should point to the same counter with value 20 + + // Test valuesMap - should contain all primitive data types and one map reference + val valuesMap = rootMap.get("valuesMap")?.asLiveMap + assertNotNull(valuesMap) + assertEquals(13L, valuesMap.size()) // Should have 13 entries + + // Assert string values + assertEquals("stringValue", valuesMap.get("string")?.asString) + assertEquals("", valuesMap.get("emptyString")?.asString) + + // Assert binary values + val bytesValue = valuesMap.get("bytes")?.asBinary + assertNotNull(bytesValue) + val expectedBinary = "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray() + assertTrue(expectedBinary.contentEquals(bytesValue)) // Should contain encoded JSON data + + val emptyBytesValue = valuesMap.get("emptyBytes")?.asBinary + assertNotNull(emptyBytesValue) + assertEquals(0, emptyBytesValue.size) // Should be empty byte array + + // Assert numeric values + assertEquals(99999999.0, valuesMap.get("maxSafeNumber")?.asNumber) + assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")?.asNumber) + assertEquals(1.0, valuesMap.get("number")?.asNumber) + assertEquals(0.0, valuesMap.get("zero")?.asNumber) + + // Assert boolean values + assertEquals(true, valuesMap.get("true")?.asBoolean) + assertEquals(false, valuesMap.get("false")?.asBoolean) + + // Assert JSON object value - should contain {"foo": "bar"} + val jsonObjectValue = valuesMap.get("object")?.asJsonObject + assertNotNull(jsonObjectValue) + assertEquals("bar", jsonObjectValue.get("foo").asString) + + // Assert JSON array value - should contain ["foo", "bar", "baz"] + val jsonArrayValue = valuesMap.get("array")?.asJsonArray + assertNotNull(jsonArrayValue) + assertEquals(3, jsonArrayValue.size()) + assertEquals("foo", jsonArrayValue[0].asString) + assertEquals("bar", jsonArrayValue[1].asString) + assertEquals("baz", jsonArrayValue[2].asString) + + // Assert map reference - should point to the same referencedMap + val mapRefValue = valuesMap.get("mapRef")?.asLiveMap + assertNotNull(mapRefValue) + assertEquals(1L, mapRefValue.size()) + val mapRefCounter = mapRefValue.get("counterKey")?.asLiveCounter + assertNotNull(mapRefCounter) + assertEquals(20.0, mapRefCounter.value()) // Should point to the same counter with value 20 + } + + /** + * Server runs periodic garbage collection (GC) to remove orphaned objects and will send + * OBJECT_DELETE events for objects that are no longer referenced. + * So, we simulate the deletion of an object by sending an object delete ProtocolMessage. + * This does not actually delete the object from the server, only simulates the deletion locally. + * Spec: RTLO4e + */ + @Test + fun testObjectDelete() = runTest { + val channelName = generateChannelName() + // Initialize the root map on the channel with initial data + restObjects.initializeRootMap(channelName) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + assertEquals(6L, rootMap.size()) // Should have 6 entries initially + + // Remove the "referencedCounter" from the root map + val refCounter = rootMap.get("referencedCounter")?.asLiveCounter + assertNotNull(refCounter) + // Subscribe to counter updates to verify removal + val counterUpdates = mutableListOf() + refCounter.subscribe { event -> + counterUpdates.add(event.update.amount) + } + + // Simulate the deletion of the referencedCounter object + channel.objects.simulateObjectDelete(refCounter as DefaultLiveCounter) + + assertWaiter { rootMap.size() == 5L } // Wait for the removal to complete + assertNull(rootMap.get("referencedCounter")) // Should be null after removal + assertEquals(1, counterUpdates.size) // Should have received one update for deletion + assertEquals(-20.0, counterUpdates[0]) // The update should indicate counter was removed with value 20 + + // Remove the "referencedMap" from the root map + val referencedMap = rootMap.get("referencedMap")?.asLiveMap + assertNotNull(referencedMap) + // Subscribe to map updates to verify removal + val mapUpdates = mutableListOf>() + referencedMap.subscribe { event -> + mapUpdates.add(event.update) + } + + // Simulate the deletion of the referencedMap object + channel.objects.simulateObjectDelete(referencedMap as DefaultLiveMap) + + assertWaiter { rootMap.size() == 4L } // Wait for the removal to complete + assertNull(rootMap.get("referencedMap")) // Should be null after removal + assertEquals(1, mapUpdates.size) // Should have received one update for deletion + + val updatedMap = mapUpdates.first() + assertEquals(1, updatedMap.size) // Should have one change + assertEquals("counterKey", updatedMap.keys.first()) // The change should be for the "counterKey" + assertEquals(LiveMapUpdate.Change.REMOVED, updatedMap.values.first()) // Should indicate removal + + // Remove the "valuesMap" from the root map + val valuesMap = rootMap.get("valuesMap")?.asLiveMap + assertNotNull(valuesMap) + // Subscribe to map updates to verify removal + val valuesMapUpdates = mutableListOf>() + valuesMap.subscribe { event -> + valuesMapUpdates.add(event.update) + } + + // Simulate the deletion of the valuesMap object + channel.objects.simulateObjectDelete(valuesMap as DefaultLiveMap) + + assertWaiter { rootMap.size() == 3L } // Wait for the removal to complete + assertNull(rootMap.get("valuesMap")) // Should be null after removal + assertEquals(1, valuesMapUpdates.size) // Should have received one update for deletion + + val updatedValuesMap = valuesMapUpdates.first() + assertEquals(13, updatedValuesMap.size) // Should have 13 changes (one for each entry in valuesMap) + // Verify that all entries in valuesMap were marked as REMOVED + updatedValuesMap.values.forEach { change -> + assertEquals(LiveMapUpdate.Change.REMOVED, change) + } + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt new file mode 100644 index 000000000..283d11a4f --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt @@ -0,0 +1,130 @@ +package io.ably.lib.objects.integration.helpers + +import com.google.gson.JsonObject +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.generateNonce +import io.ably.lib.objects.serialization.gson + +internal object PayloadBuilder { + /** + * Action strings for REST API operations. + * Maps ObjectOperationAction enum values to their string representations. + */ + private val ACTION_STRINGS = mapOf( + ObjectOperationAction.MapCreate to "MAP_CREATE", + ObjectOperationAction.MapSet to "MAP_SET", + ObjectOperationAction.MapRemove to "MAP_REMOVE", + ObjectOperationAction.CounterCreate to "COUNTER_CREATE", + ObjectOperationAction.CounterInc to "COUNTER_INC", + ) + + /** + * Creates a MAP_CREATE operation payload for REST API. + * + * @param objectId Optional specific object ID + * @param data Optional initial data for the map + * @param nonce Optional nonce for deterministic object ID generation + */ + internal fun mapCreateRestOp( + objectId: String? = null, + data: Map? = null, + nonce: String? = null, + ): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapCreate]) + } + + if (data != null) { + opBody.add("data", gson.toJsonTree(data)) + } + + if (objectId != null) { + opBody.addProperty("objectId", objectId) + opBody.addProperty("nonce", nonce ?: generateNonce()) + } + + return opBody + } + + + /** + * Creates a MAP_SET operation payload for REST API. + */ + internal fun mapSetRestOp(objectId: String, key: String, value: ObjectData): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapSet]) + addProperty("objectId", objectId) + } + + val dataObj = JsonObject().apply { + addProperty("key", key) + add("value", gson.toJsonTree(value)) + } + opBody.add("data", dataObj) + + return opBody + } + + /** + * Creates a MAP_REMOVE operation payload for REST API. + */ + internal fun mapRemoveRestOp(objectId: String, key: String): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapRemove]) + addProperty("objectId", objectId) + } + + val dataObj = JsonObject().apply { + addProperty("key", key) + } + opBody.add("data", dataObj) + + return opBody + } + + /** + * Creates a COUNTER_CREATE operation payload for REST API. + * + * @param objectId Optional specific object ID + * @param nonce Optional nonce for deterministic object ID generation + * @param number Optional initial counter value + */ + internal fun counterCreateRestOp( + objectId: String? = null, + number: Double? = null, + nonce: String? = null, + ): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterCreate]) + } + + if (number != null) { + val dataObj = JsonObject().apply { + addProperty("number", number) + } + opBody.add("data", dataObj) + } + + if (objectId != null) { + opBody.addProperty("objectId", objectId) + opBody.addProperty("nonce", nonce ?: generateNonce()) + } + + return opBody + } + + /** + * Creates a COUNTER_INC operation payload for REST API. + */ + internal fun counterIncRestOp(objectId: String, number: Double): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc]) + addProperty("objectId", objectId) + add("data", JsonObject().apply { + addProperty("number", number) + }) + } + return opBody + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt new file mode 100644 index 000000000..165563bd2 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt @@ -0,0 +1,119 @@ +package io.ably.lib.objects.integration.helpers + +import com.google.gson.JsonObject +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue +import io.ably.lib.rest.AblyRest +import io.ably.lib.http.HttpUtils +import io.ably.lib.objects.integration.helpers.fixtures.DataFixtures +import io.ably.lib.types.ClientOptions + +/** + * Helper class to create pre-determined objects and modify them on channels using rest api. + */ +internal class RestObjects(options: ClientOptions) { + + private val ablyRest: AblyRest = AblyRest(options) + + /** + * Creates a new map object on the channel with optional initial data. + * @return The object ID of the created map + */ + internal fun createMap(channelName: String, data: Map? = null): String { + val mapCreateOp = PayloadBuilder.mapCreateRestOp(data = data) + return operationRequest(channelName, mapCreateOp).objectId ?: + throw Exception("Failed to create map: no objectId returned") + } + + /** + * Sets a value (primitives, JsonObject, JsonArray, etc.) at the specified key in an existing map. + */ + internal fun setMapValue(channelName: String, mapObjectId: String, key: String, value: ObjectValue) { + val data = ObjectData(value = value) + val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data) + operationRequest(channelName, mapCreateOp) + } + + /** + * Sets an object reference at the specified key in an existing map. + */ + internal fun setMapRef(channelName: String, mapObjectId: String, key: String, refMapObjectId: String) { + val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, DataFixtures.mapRef(refMapObjectId)) + operationRequest(channelName, mapCreateOp) + } + + /** + * Removes a key-value pair from an existing map. + */ + internal fun removeMapValue(channelName: String, mapObjectId: String, key: String) { + val mapRemoveOp = PayloadBuilder.mapRemoveRestOp(mapObjectId, key) + operationRequest(channelName, mapRemoveOp) + } + + /** + * Creates a new counter object with an optional initial value (defaults to 0). + * @return The object ID of the created counter + */ + internal fun createCounter(channelName: String, initialValue: Double? = null): String { + val counterCreateOp = PayloadBuilder.counterCreateRestOp(number = initialValue) + return operationRequest(channelName, counterCreateOp).objectId + ?: throw Exception("Failed to create counter: no objectId returned") + } + + /** + * Increments an existing counter by the specified amount. + */ + internal fun incrementCounter(channelName: String, counterObjectId: String, incrementBy: Double) { + val counterIncrementOp = PayloadBuilder.counterIncRestOp(counterObjectId, incrementBy) + operationRequest(channelName, counterIncrementOp) + } + + /** + * Decrements an existing counter by the specified amount. + */ + internal fun decrementCounter(channelName: String, counterObjectId: String, decrementBy: Double) { + val counterDecrementOp = PayloadBuilder.counterIncRestOp(counterObjectId, -decrementBy) + operationRequest(channelName, counterDecrementOp) + } + + /** + * Core method that executes object operations by sending POST requests to Ably's Objects REST API. + * All public methods delegate to this for actual API communication. + */ + private fun operationRequest(channelName: String, opBody: JsonObject): OperationResult { + try { + val path = "/channels/$channelName/objects" + val requestBody = HttpUtils.requestBodyFromGson(opBody, ablyRest.options.useBinaryProtocol) + + val response = ablyRest.request("POST", path, null, requestBody, null) + + if (!response.success) { + throw Exception("REST operation failed: HTTP ${response.statusCode} - ${response.errorMessage}") + } + + val responseItems = response.items() + if (responseItems.isEmpty()) { + return OperationResult(null, null, success = true) + } + + // Process first response item + responseItems[0].asJsonObject.let { firstItem -> + val objectIds = firstItem.get("objectIds")?.let { element -> + if (element.isJsonArray) element.asJsonArray.map { it.asString } else null + } + return OperationResult(objectIds?.firstOrNull(), objectIds, success = true) + } + } catch (e: Exception) { + throw Exception("Failed to execute operation request: ${e.message}", e) + } + } + + /** + * Result class for operation requests containing the response data and extracted object ID. + */ + private data class OperationResult( + val objectId: String?, + val objectIds: List? = null, // Seems only used for batch operations + val success: Boolean = true + ) +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt new file mode 100644 index 000000000..05b50b7dc --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt @@ -0,0 +1,40 @@ +package io.ably.lib.objects.integration.helpers + +import io.ably.lib.objects.* +import io.ably.lib.objects.DefaultRealtimeObjects +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.types.ProtocolMessage + +internal val LiveMap.ObjectId get() = (this as DefaultLiveMap).objectId + +internal val LiveCounter.ObjectId get() = (this as DefaultLiveCounter).objectId + +internal val RealtimeObjects.State get() = (this as DefaultRealtimeObjects).state + +/** + * Server runs periodic garbage collection (GC) to remove orphaned objects and will send + * OBJECT_DELETE events for objects that are no longer referenced. + * So, we simulate the deletion of an object by sending a ProtocolMessage. + */ +internal fun RealtimeObjects.simulateObjectDelete(baseObject: BaseRealtimeObject) { + val defaultRealtimeObjects = this as DefaultRealtimeObjects + val existingSiteCode = baseObject.siteTimeserials.keys.first() + val existingSiteSerial = baseObject.siteTimeserials[existingSiteCode]!! + + val deleteObjectProtoMsg = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) + deleteObjectProtoMsg.state = arrayOf(ObjectMessage( + siteCode = existingSiteCode, + serial = existingSiteSerial + "1", // Increment serial to accept new operation + operation = ObjectOperation( + action = ObjectOperationAction.ObjectDelete, + objectId = baseObject.objectId, + ) + )) + defaultRealtimeObjects.handle(deleteObjectProtoMsg) +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt new file mode 100644 index 000000000..a8135a9e4 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt @@ -0,0 +1,88 @@ +package io.ably.lib.objects.integration.helpers.fixtures + +import io.ably.lib.objects.integration.helpers.RestObjects + +/** + * Creates a comprehensive test fixture object tree focused on user-context counters. + * + * This method establishes a hierarchical structure of live counter objects for testing + * counter operations in a realistic user engagement context, creating various types of + * counters and establishing references between them through nested maps. + * + * **Object Tree Structure:** + * ``` + * userMap (Map) + * ├── profileViews → Counter(value=127) + * ├── postLikes → Counter(value=45) + * ├── commentCount → Counter(value=23) + * ├── followingCount → Counter(value=89) + * ├── followersCount → Counter(value=156) + * ├── loginStreak → Counter(value=7) + * └── engagementMetrics → Map{ + * ├── "totalShares" → Counter(value=34) + * ├── "totalBookmarks" → Counter(value=67) + * ├── "totalReactions" → Counter(value=189) + * └── "dailyActiveStreak" → Counter(value=12) + * } + * ``` + * + * @param channelName The channel where the counter object tree will be created + * @return The object ID of the root test map containing all counter references + */ +internal fun RestObjects.createUserMapWithCountersObject(channelName: String): String { + // Create the main test map first + val testMapObjectId = createMap(channelName) + + // Create various user-context relevant counters + val profileViewsCounterObjectId = createCounter(channelName, 127.0) + val postLikesCounterObjectId = createCounter(channelName, 45.0) + val commentCountCounterObjectId = createCounter(channelName, 23.0) + val followingCountCounterObjectId = createCounter(channelName, 89.0) + val followersCountCounterObjectId = createCounter(channelName, 156.0) + val loginStreakCounterObjectId = createCounter(channelName, 7.0) + + // Create engagement metrics nested map with counters + val engagementMetricsMapObjectId = createUserEngagementMatrixMap(channelName) + + // Set up the main test map structure with references to all created counters + setMapRef(channelName, testMapObjectId, "profileViews", profileViewsCounterObjectId) + setMapRef(channelName, testMapObjectId, "postLikes", postLikesCounterObjectId) + setMapRef(channelName, testMapObjectId, "commentCount", commentCountCounterObjectId) + setMapRef(channelName, testMapObjectId, "followingCount", followingCountCounterObjectId) + setMapRef(channelName, testMapObjectId, "followersCount", followersCountCounterObjectId) + setMapRef(channelName, testMapObjectId, "loginStreak", loginStreakCounterObjectId) + setMapRef(channelName, testMapObjectId, "engagementMetrics", engagementMetricsMapObjectId) + + return testMapObjectId +} + +/** + * Creates a user engagement matrix map object with counter references for testing. + * + * This method creates a simple engagement metrics map containing counter objects + * that track various user engagement metrics. The map contains references to + * counter objects representing different types of user interactions and activities. + * + * **Object Structure:** + * ``` + * userEngagementMatrixMap (Map) + * ├── "totalShares" → Counter(value=34) + * ├── "totalBookmarks" → Counter(value=67) + * ├── "totalReactions" → Counter(value=189) + * └── "dailyActiveStreak" → Counter(value=12) + * ``` + * + * @param channelName The channel where the user engagement matrix map will be created + * @return The object ID of the created user engagement matrix map + */ +internal fun RestObjects.createUserEngagementMatrixMap(channelName: String): String { + return createMap( + channelName, + data = mapOf( + "totalShares" to DataFixtures.mapRef(createCounter(channelName, 34.0)), + "totalBookmarks" to DataFixtures.mapRef(createCounter(channelName, 67.0)), + "totalReactions" to DataFixtures.mapRef(createCounter(channelName, 189.0)), + "dailyActiveStreak" to DataFixtures.mapRef(createCounter(channelName, 12.0)) + ) + ) +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt new file mode 100644 index 000000000..18928cd19 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt @@ -0,0 +1,84 @@ +package io.ably.lib.objects.integration.helpers.fixtures + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.objects.Binary +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue + +internal object DataFixtures { + + /** Test fixture for string value ("stringValue") data type */ + internal val stringData = ObjectData(value = ObjectValue.String("stringValue")) + + /** Test fixture for empty string data type */ + internal val emptyStringData = ObjectData(value = ObjectValue.String("")) + + /** Test fixture for binary data containing encoded JSON */ + internal val bytesData = ObjectData( + value = ObjectValue.Binary(Binary("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()))) + + /** Test fixture for empty binary data (zero-length byte array) */ + internal val emptyBytesData = ObjectData(value = ObjectValue.Binary(Binary(ByteArray(0)))) + + /** Test fixture for maximum safe number value */ + internal val maxSafeNumberData = ObjectData(value = ObjectValue.Number(99999999.0)) + + /** Test fixture for minimum safe number value */ + internal val negativeMaxSafeNumberData = ObjectData(value = ObjectValue.Number(-99999999.0)) + + /** Test fixture for positive number value (1) */ + internal val numberData = ObjectData(value = ObjectValue.Number(1.0)) + + /** Test fixture for zero number value */ + internal val zeroData = ObjectData(value = ObjectValue.Number(0.0)) + + /** Test fixture for boolean true value */ + internal val trueData = ObjectData(value = ObjectValue.Boolean(true)) + + /** Test fixture for boolean false value */ + internal val falseData = ObjectData(value = ObjectValue.Boolean(false)) + + /** Test fixture for JSON object value with single property */ + internal val objectData = ObjectData(value = ObjectValue.JsonObject(JsonObject().apply { addProperty("foo", "bar")})) + + /** Test fixture for JSON array value with three string elements */ + internal val arrayData = ObjectData( + value = ObjectValue.JsonArray(JsonArray().apply { + add("foo") + add("bar") + add("baz") + }) + ) + + /** + * Creates an ObjectData instance that references another map object. + * @param referencedMapObjectId The object ID of the referenced map + */ + internal fun mapRef(referencedMapObjectId: String) = ObjectData(objectId = referencedMapObjectId) + + /** + * Creates a test fixture map containing all supported data types and values. + * @param referencedMapObjectId The object ID to be used for the map reference entry + */ + internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { + val baseMap = mapOf( + "string" to stringData, + "emptyString" to emptyStringData, + "bytes" to bytesData, + "emptyBytes" to emptyBytesData, + "maxSafeNumber" to maxSafeNumberData, + "negativeMaxSafeNumber" to negativeMaxSafeNumberData, + "number" to numberData, + "zero" to zeroData, + "true" to trueData, + "false" to falseData, + "object" to objectData, + "array" to arrayData + ) + referencedMapObjectId?.let { + return baseMap + ("mapRef" to mapRef(it)) + } + return baseMap + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt new file mode 100644 index 000000000..8499eefc2 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt @@ -0,0 +1,184 @@ +package io.ably.lib.objects.integration.helpers.fixtures + +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.integration.helpers.RestObjects + +/** + * Initializes a comprehensive test fixture object tree on the specified channel. + * + * This method creates a predetermined object hierarchy rooted at a "root" map object, + * establishing references between different types of live objects to enable comprehensive testing. + * + * **Object Tree Structure:** + * ``` + * root (Map) + * ├── emptyCounter → Counter(value=0) + * ├── initialValueCounter → Counter(value=10) + * ├── referencedCounter → Counter(value=20) + * ├── emptyMap → Map{} + * ├── referencedMap → Map{ + * │ └── "counterKey" → referencedCounter + * │ } + * └── valuesMap → Map{ + * ├── "string" → "stringValue" + * ├── "emptyString" → "" + * ├── "bytes" → + * ├── "emptyBytes" → + * ├── "maxSafeInteger" → Long.MAX_VALUE + * ├── "negativeMaxSafeInteger" → Long.MIN_VALUE + * ├── "number" → 1 + * ├── "zero" → 0 + * ├── "true" → true + * ├── "false" → false + * ├── "object" → {"foo": "bar"} + * ├── "array" → ["foo", "bar", "baz"] + * └── "mapRef" → referencedMap + * } + * ``` + * + * @param channelName The channel where the object tree will be created + */ +internal fun RestObjects.initializeRootMap(channelName: String) { + // Create counters + val emptyCounterObjectId = createCounter(channelName) + setMapRef(channelName, "root", "emptyCounter", emptyCounterObjectId) + + val initialValueCounterObjectId = createCounter(channelName, 10.0) + setMapRef(channelName, "root", "initialValueCounter", initialValueCounterObjectId) + + val referencedCounterObjectId = createCounter(channelName, 20.0) + setMapRef(channelName, "root", "referencedCounter", referencedCounterObjectId) + + // Create maps + val emptyMapObjectId = createMap(channelName) + setMapRef(channelName, "root", "emptyMap", emptyMapObjectId) + + val referencedMapObjectId = createMap( + channelName, + data = mapOf("counterKey" to DataFixtures.mapRef(referencedCounterObjectId)) + ) + setMapRef(channelName, "root", "referencedMap", referencedMapObjectId) + + val valuesMapObjectId = createMap( + channelName, + data = DataFixtures.mapWithAllValues(referencedMapObjectId) + ) + setMapRef(channelName, "root", "valuesMap", valuesMapObjectId) +} + + +/** + * Creates a comprehensive test fixture object tree on the specified channel using + * + * This method establishes a hierarchical structure of live objects for testing map operations, + * creating various types of objects and establishing references between them. + * + * **Object Tree Structure:** + * ``` + * testMap (Map) + * ├── userProfile → Map{ + * │ ├── "userId" → "user123" + * │ ├── "name" → "John Doe" + * │ ├── "email" → "john@example.com" + * │ ├── "isActive" → true + * │ ├── "metrics" → metricsMap + * │ └── "preferences" → preferencesMap + * │ } + * ├── loginCounter → Counter(value=5) + * ├── sessionCounter → Counter(value=0) + * ├── preferencesMap → Map{ + * │ ├── "theme" → "dark" + * │ ├── "notifications" → true + * │ ├── "language" → "en" + * │ └── "maxRetries" → 3 + * │ } + * └── metricsMap → Map{ + * ├── "totalLogins" → loginCounter + * ├── "activeSessions" → sessionCounter + * ├── "lastLoginTime" → "2024-01-01T08:30:00Z" + * └── "profileViews" → 42 + * } + * ``` + * + * @param channelName The channel where the test object tree will be created + */ +internal fun RestObjects.createUserMapObject(channelName: String): String { + // Create the main test map first + val testMapObjectId = createMap(channelName) + + // Create counter objects for testing numeric operations + val loginCounterObjectId = createCounter(channelName, 5.0) + val sessionCounterObjectId = createCounter(channelName, 0.0) + + // Create a preferences map with various data types + val preferencesMapObjectId = createMap( + channelName, + data = mapOf( + "theme" to ObjectData(value = ObjectValue.String("dark")), + "notifications" to ObjectData(value = ObjectValue.Boolean(true)), + "language" to ObjectData(value = ObjectValue.String("en")), + "maxRetries" to ObjectData(value = ObjectValue.Number(3)) + ) + ) + + // Create a metrics map that tracks single user activity + val metricsMapObjectId = createMap( + channelName, + data = mapOf( + "totalLogins" to DataFixtures.mapRef(loginCounterObjectId), + "activeSessions" to DataFixtures.mapRef(sessionCounterObjectId), + "lastLoginTime" to ObjectData(value = ObjectValue.String("2024-01-01T08:30:00Z")), + "profileViews" to ObjectData(value = ObjectValue.Number(42)) + ) + ) + + // Create a user profile map with mixed data types and references + val userProfileMapObjectId = createUserProfileMapObject(channelName) + setMapRef(channelName, userProfileMapObjectId, "metrics", metricsMapObjectId) + setMapRef(channelName, userProfileMapObjectId, "preferences", preferencesMapObjectId) + + // Set up the main test map structure with references to all created objects + setMapRef(channelName, testMapObjectId, "userProfile", userProfileMapObjectId) + setMapRef(channelName, testMapObjectId, "loginCounter", loginCounterObjectId) + setMapRef(channelName, testMapObjectId, "sessionCounter", sessionCounterObjectId) + setMapRef(channelName, testMapObjectId, "preferencesMap", preferencesMapObjectId) + setMapRef(channelName, testMapObjectId, "metricsMap", metricsMapObjectId) + + return testMapObjectId +} + +/** + * Creates a user profile map object with basic user information for testing. + * + * This method creates a simple user profile map containing essential user data fields + * that are commonly used in user management systems. The map contains primitive data types + * representing basic user information. + * + * **Object Structure:** + * ``` + * userProfileMap (Map) + * ├── "userId" → "user123" + * ├── "name" → "John Doe" + * ├── "email" → "john@example.com" + * └── "isActive" → true + * ``` + * + * This structure provides a foundation for testing map operations on user profile data, + * including field updates, additions, and removals. The map contains a mix of string, + * boolean, and numeric data types to test various primitive value handling. + * + * @param channelName The channel where the user profile map will be created + * @return The object ID of the created user profile map + */ +internal fun RestObjects.createUserProfileMapObject(channelName: String): String { + return createMap( + channelName, + data = mapOf( + "userId" to ObjectData(value = ObjectValue.String("user123")), + "name" to ObjectData(value = ObjectValue.String("John Doe")), + "email" to ObjectData(value = ObjectValue.String("john@example.com")), + "isActive" to ObjectData(value = ObjectValue.Boolean(true)), + ) + ) +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt new file mode 100644 index 000000000..cb46f2f89 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt @@ -0,0 +1,97 @@ +package io.ably.lib.objects.integration.setup + +import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.rules.Timeout +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.util.UUID + +@RunWith(Parameterized::class) +abstract class IntegrationTest { + @Parameterized.Parameter + lateinit var testParams: String + + @JvmField + @Rule + val timeout: Timeout = Timeout.seconds(15) + + private val realtimeClients = mutableMapOf() + + /** + * Retrieves a realtime channel for the specified channel name and client ID + * If a client with the given clientID does not exist, a new client is created using the provided options. + * + * @param channelName Name of the channel + * @param clientId The ID of the client to use or create. Defaults to "client1". + * @return The realtime channel in the INITIALIZED state. + * @throws Exception If the client fails to connect. + */ + internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel { + val client = realtimeClients.getOrPut(clientId) { + sandbox.createRealtimeClient { + this.clientId = clientId + useBinaryProtocol = testParams == "msgpack_protocol" + }. apply { ensureConnected() } + } + val channelOpts = ChannelOptions().apply { + modes = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe) + } + return client.channels.get(channelName, channelOpts) + } + + /** + * Generates a unique channel name for testing purposes. + * This is mainly to avoid channel name/state/history collisions across tests in same file. + */ + internal fun generateChannelName(): String { + return "test-channel-${UUID.randomUUID()}" + } + + @After + fun afterEach() { + for (ablyRealtime in realtimeClients.values) { + for ((channelName, channel) in ablyRealtime.channels.entrySet()) { + channel.off() + ablyRealtime.channels.release(channelName) + } + ablyRealtime.close() + } + realtimeClients.clear() + } + + companion object { + private lateinit var sandbox: Sandbox + internal lateinit var restObjects: RestObjects + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Iterable { + return listOf("msgpack_protocol", "json_protocol") + } + + @JvmStatic + @BeforeClass + @Throws(Exception::class) + fun setUpBeforeClass() { + runBlocking { + sandbox = Sandbox.createInstance() + restObjects = sandbox.createRestObjects() + } + } + + @JvmStatic + @AfterClass + @Throws(Exception::class) + fun tearDownAfterClass() { + } + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt new file mode 100644 index 000000000..cfcd4ed2b --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt @@ -0,0 +1,94 @@ +package io.ably.lib.objects.integration.setup + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import io.ably.lib.objects.ablyException +import io.ably.lib.objects.integration.helpers.RestObjects +import io.ably.lib.realtime.* +import io.ably.lib.types.ClientOptions +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.network.sockets.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.* +import kotlinx.coroutines.CompletableDeferred + +private val client = HttpClient(CIO) { + install(HttpRequestRetry) { + maxRetries = 5 + retryIf { _, response -> + !response.status.isSuccess() + } + retryOnExceptionIf { _, cause -> + cause is ConnectTimeoutException || + cause is HttpRequestTimeoutException || + cause is SocketTimeoutException + } + exponentialDelay() + } +} + +class Sandbox private constructor(val appId: String, val apiKey: String) { + companion object { + private suspend fun loadAppCreationJson(): JsonElement = + JsonParser.parseString( + client.get("https://raw.githubusercontent.com/ably/ably-common/refs/heads/main/test-resources/test-app-setup.json") { + contentType(ContentType.Application.Json) + }.bodyAsText(), + ).asJsonObject.get("post_apps") + + internal suspend fun createInstance(): Sandbox { + val response: HttpResponse = client.post("https://sandbox.realtime.ably-nonprod.net/apps") { + contentType(ContentType.Application.Json) + setBody(loadAppCreationJson().toString()) + } + val body = JsonParser.parseString(response.bodyAsText()) + + return Sandbox( + appId = body.asJsonObject["appId"].asString, + // From JS chat repo at 7985ab7 — "The key we need to use is the one at index 5, which gives enough permissions to interact with Chat and Channels" + apiKey = body.asJsonObject["keys"].asJsonArray[0].asJsonObject["keyStr"].asString, + ) + } + } +} + + +internal fun Sandbox.createRealtimeClient(options: ClientOptions.() -> Unit): AblyRealtime { + val clientOptions = ClientOptions().apply { + apply(options) + key = apiKey + environment = "sandbox" + } + return AblyRealtime(clientOptions) +} + +internal fun Sandbox.createRestObjects(): RestObjects { + val options = ClientOptions().apply { + key = apiKey + environment = "sandbox" + useBinaryProtocol = false + } + return RestObjects(options) +} + +internal suspend fun AblyRealtime.ensureConnected() { + if (this.connection.state == ConnectionState.connected) { + return + } + val connectedDeferred = CompletableDeferred() + this.connection.on { + if (it.event == ConnectionEvent.connected) { + connectedDeferred.complete(Unit) + this.connection.off() + } else if (it.event != ConnectionEvent.connecting) { + connectedDeferred.completeExceptionally(ablyException(it.reason)) + this.connection.off() + this.close() + } + } + connectedDeferred.await() +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt new file mode 100644 index 000000000..4b6662636 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt @@ -0,0 +1,448 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.* +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ChannelStateListener +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.transport.ConnectionManager +import io.ably.lib.types.* +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test +import kotlin.test.assertFailsWith + +class HelpersTest { + + // sendAsync + @Test + fun testSendAsyncShouldQueueAccordingToClientOptions() = runTest { + val adapter = mockk(relaxed = true) + val connManager = mockk(relaxed = true) + val clientOptions = ClientOptions().apply { queueMessages = false } + + every { adapter.connectionManager } returns connManager + every { adapter.clientOptions } returns clientOptions + + every { connManager.send(any(), any(), any()) } answers { + val listener = thirdArg() + listener.onSuccess() + } + + val pm = ProtocolMessage(ProtocolMessage.Action.message) + adapter.sendAsync(pm) + verify(exactly = 1) { connManager.send(any(), false, any()) } + + clientOptions.queueMessages = true + adapter.sendAsync(pm) + verify(exactly = 1) { connManager.send(any(), true, any()) } + } + + @Test + fun testSendAsyncErrorPropagatesAblyException() = runTest { + val adapter = mockk(relaxed = true) + val connManager = mockk(relaxed = true) + val clientOptions = ClientOptions() + + every { adapter.connectionManager } returns connManager + every { adapter.clientOptions } returns clientOptions + + every { connManager.send(any(), any(), any()) } answers { + val listener = thirdArg() + listener.onError(clientError("boom").errorInfo) + } + + val ex = assertFailsWith { + adapter.sendAsync(ProtocolMessage(ProtocolMessage.Action.message)) + } + assertEquals(400, ex.errorInfo.statusCode) + assertEquals(40000, ex.errorInfo.code) + } + + @Test + fun testSendAsyncThrowsWhenConnectionManagerThrows() = runTest { + val adapter = mockk(relaxed = true) + val connManager = mockk(relaxed = true) + val clientOptions = ClientOptions() + + every { adapter.connectionManager } returns connManager + every { adapter.clientOptions } returns clientOptions + + every { connManager.send(any(), any(), any()) } throws RuntimeException("send failed hard") + + val ex = assertFailsWith { + adapter.sendAsync(ProtocolMessage(ProtocolMessage.Action.message)) + } + assertEquals("send failed hard", ex.message) + } + + // attachAsync + @Test + fun testAttachAsyncSuccess() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + every { channel.attach(any()) } answers { + val listener = firstArg() + listener.onSuccess() + } + + adapter.attachAsync("ch") + verify(exactly = 1) { channel.attach(any()) } + } + + @Test + fun testAttachAsyncError() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + every { channel.attach(any()) } answers { + val listener = firstArg() + listener.onError(serverError("attach failed").errorInfo) + } + + val ex = assertFailsWith { adapter.attachAsync("ch") } + assertEquals(50000, ex.errorInfo.code) + assertEquals(500, ex.errorInfo.statusCode) + } + + // getChannelModes + @Test + fun testGetChannelModesPrefersChannelModes() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + every { channel.modes } returns arrayOf(ChannelMode.object_publish) + every { channel.options } returns ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) } + + val modes = adapter.getChannelModes("ch") + assertArrayEquals(arrayOf(ChannelMode.object_publish), modes) + } + + @Test + fun testGetChannelModesFallsBackToOptions() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + every { channel.modes } returns emptyArray() + every { channel.options } returns ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) } + + val modes = adapter.getChannelModes("ch") + assertArrayEquals(arrayOf(ChannelMode.object_subscribe), modes) + } + + @Test + fun testGetChannelModesReturnsNullWhenNoModes() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + every { channel.modes } returns null + every { channel.options } returns ChannelOptions().apply { modes = null } + + val modes = adapter.getChannelModes("ch") + assertNull(modes) + } + + @Test + fun testGetChannelModesIgnoresEmptyModes() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + every { channel.modes } returns emptyArray() + every { channel.options } returns ChannelOptions().apply { modes = null } + + val modes = adapter.getChannelModes("ch") + assertNull(modes) + } + + // setChannelSerial + @Test + fun testSetChannelSerialSetsWhenObjectActionAndNonEmpty() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + val props = ChannelProperties() + channel.properties = props + every { adapter.getChannel("ch") } returns channel + + val msg = ProtocolMessage(ProtocolMessage.Action.`object`) + msg.channelSerial = "abc:123" + + adapter.setChannelSerial("ch", msg) + assertEquals("abc:123", props.channelSerial) + } + + @Test + fun testSetChannelSerialNoOpForNonObjectActionOrEmpty() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + val props = ChannelProperties() + channel.properties = props + every { adapter.getChannel("ch") } returns channel + + // Non-object action + val msg1 = ProtocolMessage(ProtocolMessage.Action.message) + msg1.channelSerial = "abc" + adapter.setChannelSerial("ch", msg1) + assertNull(props.channelSerial) + + // Object action but empty serial + val msg2 = ProtocolMessage(ProtocolMessage.Action.`object`) + msg2.channelSerial = "" + adapter.setChannelSerial("ch", msg2) + assertNull(props.channelSerial) + } + + // ensureAttached + @Test + fun testEnsureAttachedFromInitializedAttaches() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.initialized + + val attachCalled = slot() + every { channel.attach(capture(attachCalled)) } answers { + attachCalled.captured.onSuccess() + } + + adapter.ensureAttached("ch") + verify(exactly = 1) { channel.attach(any()) } + } + + @Test + fun testEnsureAttachedWhenAlreadyAttachedReturns() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attached + + adapter.ensureAttached("ch") + // no attach call + verify(exactly = 0) { channel.attach(any()) } + } + + @Test + fun testEnsureAttachedWaitsForAttachingThenAttached() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attaching + + every { channel.once(any()) } answers { + val listener = firstArg() + val stateChange = mockk(relaxed = true) { + setPrivateField("current", ChannelState.attached) + } + listener.onChannelStateChanged(stateChange) + } + + adapter.ensureAttached("ch") + verify(exactly = 1) { channel.once(any()) } + } + + @Test + fun testEnsureAttachedAttachingButReceivesNonAttachedEmitsError() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attaching + every { channel.once(any()) } answers { + val listener = firstArg() + val stateChange = mockk(relaxed = true) { + setPrivateField("current", ChannelState.suspended) + setPrivateField("reason", clientError("Not attached").errorInfo) + } + listener.onChannelStateChanged(stateChange) + } + val ex = assertFailsWith { adapter.ensureAttached("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + assertTrue(ex.errorInfo.message.contains("Not attached")) + verify(exactly = 1) { channel.once(any()) } + } + + @Test + fun testEnsureAttachedThrowsForInvalidState() = runTest { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.failed + + val ex = assertFailsWith { adapter.ensureAttached("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + } + + // throwIfInvalidAccessApiConfiguration + @Test + fun testThrowIfInvalidAccessApiConfigurationStateDetached() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.detached + + val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + } + + @Test + fun testThrowIfInvalidAccessApiConfigurationMissingMode() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attached + every { channel.modes } returns emptyArray() + every { channel.options } returns ChannelOptions().apply { modes = null } + + val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertTrue(ex.errorInfo.message.contains("object_subscribe")) + } + + // throwIfInvalidWriteApiConfiguration + @Test + fun testThrowIfInvalidWriteApiConfigurationEchoDisabled() { + val adapter = mockk(relaxed = true) + val clientOptions = ClientOptions().apply { echoMessages = false } + every { adapter.clientOptions } returns clientOptions + + val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } + assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) + assertTrue(ex.errorInfo.message.contains("echoMessages")) + } + + @Test + fun testThrowIfInvalidWriteApiConfigurationInvalidState() { + val adapter = mockk(relaxed = true) + every { adapter.clientOptions } returns ClientOptions() + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.suspended + + val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + } + + @Test + fun testThrowIfInvalidWriteApiConfigurationMissingMode() { + val adapter = mockk(relaxed = true) + every { adapter.clientOptions } returns ClientOptions() + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attached + every { channel.modes } returns emptyArray() + every { channel.options } returns ChannelOptions().apply { modes = null } + + val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertTrue(ex.errorInfo.message.contains("object_publish")) + } + + // throwIfUnpublishableState + @Test + fun testThrowIfUnpublishableStateInactiveConnection() { + val adapter = mockk(relaxed = true) + val connManager = mockk(relaxed = true) + every { adapter.connectionManager } returns connManager + every { connManager.isActive } returns false + every { connManager.stateErrorInfo } returns serverError("not active").errorInfo + + val ex = assertFailsWith { adapter.throwIfUnpublishableState("ch") } + assertEquals(500, ex.errorInfo.statusCode) + assertEquals(50000, ex.errorInfo.code) + } + + @Test + fun testThrowIfUnpublishableStateChannelFailed() { + val adapter = mockk(relaxed = true) + val connManager = mockk(relaxed = true) + every { adapter.connectionManager } returns connManager + every { connManager.isActive } returns true + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.failed + + val ex = assertFailsWith { adapter.throwIfUnpublishableState("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + } + + @Test + fun testAccessConfigThrowsWhenRequiredModeMissing() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attached + // No modes anywhere + every { channel.modes } returns null + every { channel.options } returns ChannelOptions().apply { modes = null } + + val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertTrue(ex.errorInfo.message.contains("object_subscribe")) + } + + @Test + fun testWriteConfigThrowsWhenRequiredModeMissing() { + val adapter = mockk(relaxed = true) + every { adapter.clientOptions } returns ClientOptions() // echo enabled + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.attached + every { channel.modes } returns emptyArray() + every { channel.options } returns ChannelOptions().apply { modes = null } + + val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) + assertTrue(ex.errorInfo.message.contains("object_publish")) + } + + @Test + fun testAccessConfigThrowsOnInvalidChannelState() { + val adapter = mockk(relaxed = true) + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + channel.state = ChannelState.detached + + val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) + } + + @Test + fun testWriteConfigThrowsOnInvalidChannelStates() { + val adapter = mockk(relaxed = true) + every { adapter.clientOptions } returns ClientOptions() + val channel = mockk(relaxed = true) + every { adapter.getChannel("ch") } returns channel + + // Suspended should be rejected for write config + channel.state = ChannelState.suspended + val ex1 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex1.errorInfo.code) + + // Failed should also be rejected + channel.state = ChannelState.failed + val ex2 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } + assertEquals(ErrorCode.ChannelStateError.code, ex2.errorInfo.code) + } + + // Binary utilities + @Test + fun testBinaryEqualityHashCodeAndSize() { + val data1 = byteArrayOf(1, 2, 3, 4) + val data2 = byteArrayOf(1, 2, 3, 4) + val data3 = byteArrayOf(4, 3, 2, 1) + + val b1 = Binary(data1) + val b2 = Binary(data2) + val b3 = Binary(data3) + + assertEquals(b1, b2) + assertEquals(b1.hashCode(), b2.hashCode()) + assertNotEquals(b1, b3) + + assertEquals(4, b1.size()) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt new file mode 100644 index 000000000..d8eaaf697 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt @@ -0,0 +1,75 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.ObjectId +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.types.AblyException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import kotlin.test.assertTrue + +class ObjectIdTest { + + @Test + fun testValidMapObjectId() { + val objectIdString = "map:abc123@1640995200000" + val objectId = ObjectId.fromString(objectIdString) + + assertEquals(ObjectType.Map, objectId.type) + assertEquals("map:abc123@1640995200000", objectId.toString()) + } + + @Test + fun testValidCounterObjectId() { + val objectIdString = "counter:def456@1640995200000" + val objectId = ObjectId.fromString(objectIdString) + + assertEquals(ObjectType.Counter, objectId.type) + assertEquals("counter:def456@1640995200000", objectId.toString()) + } + + @Test + fun testInvalidObjectType() { + val exception = assertThrows(AblyException::class.java) { + ObjectId.fromString("invalid:abc123@1640995200000") + } + assertAblyExceptionError(exception) + } + + @Test + fun testEmptyObjectId() { + val exception1 = assertThrows(AblyException::class.java) { + ObjectId.fromString("") + } + assertAblyExceptionError(exception1) + } + + private fun assertAblyExceptionError( + exception: AblyException + ) { + assertTrue(exception.errorInfo?.message?.contains("Invalid object id:") == true || + exception.errorInfo?.message?.contains("Invalid object type in object id:") == true) + assertEquals(92_000, exception.errorInfo?.code) + assertEquals(500, exception.errorInfo?.statusCode) + } + + @Test + fun testFromInitialValue() { + val objectType = ObjectType.Map + val initialValue = "test-value" + val nonce = "test-nonce" + val msTimestamp = 1640995200000L + + val objectId = ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp) + // Verify the string format follows the expected pattern: type:hash@timestamp + val objectIdString = objectId.toString() + assertTrue(objectIdString.startsWith("map:")) + assertTrue(objectIdString.contains("@")) + assertTrue(objectIdString.endsWith(msTimestamp.toString())) + + val expectedHash = "GSjv-adTaJPL8-382qF3JuIyE4TCc6QKIIqb577pz00" + // Verify the hash value matches expected + val hashPart = objectIdString.substring(4, objectIdString.indexOf("@")) + assertEquals(expectedHash, hashPart) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt new file mode 100644 index 000000000..de90b8648 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt @@ -0,0 +1,181 @@ +package io.ably.lib.objects.unit + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.unit.fixtures.* +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.ProtocolMessage.ActionSerializer +import io.ably.lib.types.ProtocolSerializer +import io.ably.lib.util.Serialisation +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ObjectMessageSerializationTest { + + private val objectMessages = arrayOf( + dummyObjectMessageWithStringData(), + dummyObjectMessageWithBinaryData(), + dummyObjectMessageWithNumberData(), + dummyObjectMessageWithBooleanData(), + dummyObjectMessageWithJsonObjectData(), + dummyObjectMessageWithJsonArrayData() + ) + + @Test + fun testObjectMessageMsgPackSerialization() = runTest { + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = objectMessages + + // Serialize the ProtocolMessage containing ObjectMessages to MsgPack format + val serializedProtoMsg = ProtocolSerializer.writeMsgpack(protocolMessage) + assertNotNull(serializedProtoMsg) + + // Deserialize back to ProtocolMessage + val deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedProtoMsg) + assertNotNull(deserializedProtoMsg) + + deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> + assertEquals(expected, actual as? ObjectMessage) + } + } + + @Test + fun testObjectMessageJsonSerialization() = runTest { + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = objectMessages + + // Serialize the ProtocolMessage containing ObjectMessages to Json format + val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) + assertNotNull(serializedProtoMsg) + + // Deserialize back to ProtocolMessage + val deserializedProtoMsg = ProtocolSerializer.fromJSON(serializedProtoMsg) + assertNotNull(deserializedProtoMsg) + + deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> + assertEquals(expected, (actual as? ObjectMessage)) + } + } + + @Test + fun testOmitNullsInObjectMessageSerialization() = runTest { + val objectMessage = dummyObjectMessageWithStringData() + val objectMessageWithNullFields = objectMessage.copy( + id = null, + timestamp = null, + clientId = "test-client", + connectionId = "test-connection", + extras = null, + operation = null, + objectState = null, + serial = null, + siteCode = null + ) + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = arrayOf(objectMessageWithNullFields) + + // check if Gson/Msgpack serialization omits null fields + fun assertSerializedObjectMessage(serializedProtoMsg: String) { + val deserializedProtoMsg = Gson().fromJson(serializedProtoMsg, JsonElement::class.java).asJsonObject + val serializedObjectMessage = deserializedProtoMsg.get("state").asJsonArray[0].asJsonObject.toString() + assertEquals("""{"clientId":"test-client","connectionId":"test-connection"}""", serializedObjectMessage) + } + + // Serialize using Gson + val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) + assertSerializedObjectMessage(serializedProtoMsg) + + // Serialize using MsgPack + val serializedMsgpackBytes = ProtocolSerializer.writeMsgpack(protocolMessage) + val serializedJsonStringFromMsgpackBytes = Serialisation.msgpackToGson(serializedMsgpackBytes).toString() + assertSerializedObjectMessage(serializedJsonStringFromMsgpackBytes) + } + + @Test + fun testSerializeEnumsIntoOrdinalValues() = runTest { + val objectMessage = dummyObjectMessageWithStringData() + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = arrayOf(objectMessage) + + fun assertSerializedObjectMessage(serializedProtoMsg: String) { + val deserializedProtoMsg = Gson().fromJson(serializedProtoMsg, JsonElement::class.java).asJsonObject + val serializedObjectMessage = deserializedProtoMsg.get("state").asJsonArray[0].asJsonObject + val operation = serializedObjectMessage.get("operation").asJsonObject + assertTrue(operation.has("action")) + assertEquals(0, operation.get("action").asInt) // Check if action is serialized as code + } + + // Serialize using Gson + val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) + assertSerializedObjectMessage(serializedProtoMsg) + // Serialize using MsgPack + val serializedMsgpackBytes = ProtocolSerializer.writeMsgpack(protocolMessage) + val serializedJsonStringFromMsgpackBytes = Serialisation.msgpackToGson(serializedMsgpackBytes).toString() + assertSerializedObjectMessage(serializedJsonStringFromMsgpackBytes) + } + + @Test + fun testHandleNullsInObjectMessageDeserialization() = runTest { + val protocolMessage = ProtocolMessage() + protocolMessage.id = "id" + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = null + + // Serialize using Gson with serializeNulls enabled + val gsonBuilderCreatingNulls = GsonBuilder() + .registerTypeAdapter(ProtocolMessage.Action::class.java, ActionSerializer()) + .serializeNulls().create() + + var protoMsgJsonObject = gsonBuilderCreatingNulls.toJsonTree(protocolMessage).asJsonObject + assertTrue(protoMsgJsonObject.has("state")) + assertEquals(JsonNull.INSTANCE, protoMsgJsonObject.get("state")) + + var deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) + assertNull(deserializedProtoMsg.state) + + var serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) + deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) + assertNull(deserializedProtoMsg.state) + + // Create ObjectMessage and serialize in a way that resulting string/bytes include null fields + val objectMessage = dummyObjectMessageWithStringData() + val objectMessageWithNullFields = objectMessage.copy( + id = null, + timestamp = null, + clientId = "test-client", + connectionId = "test-connection", + extras = null, + operation = objectMessage.operation?.copy( + initialValue = null, // initialValue set to null + mapOp = objectMessage.operation.mapOp?.copy( + data = null // objectData set to null + ) + ), + objectState = null, + serial = null, + siteCode = null + ) + protocolMessage.state = arrayOf(objectMessageWithNullFields) + protoMsgJsonObject = gsonBuilderCreatingNulls.toJsonTree(protocolMessage).asJsonObject + + // Check if gson deserialization works correctly + deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) + + // Check if msgpack deserialization works correctly + serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) + deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt new file mode 100644 index 000000000..32a51069a --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt @@ -0,0 +1,174 @@ +package io.ably.lib.objects.unit + +import com.google.gson.JsonObject +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectsMapOp +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.ensureMessageSizeWithinLimit +import io.ably.lib.objects.size +import io.ably.lib.transport.Defaults +import io.ably.lib.types.AblyException +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ObjectMessageSizeTest { + + @Test + fun testObjectMessageSizeWithinLimit() = runTest { + val mockAdapter = mockk(relaxed = true) + mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb + assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) + + // ObjectMessage with all size-contributing fields + val objectMessage = ObjectMessage( + id = "msg_12345", // Not counted in size calculation + timestamp = 1699123456789L, // Not counted in size calculation + clientId = "test-client", // Size: 11 bytes (UTF-8 byte length) + connectionId = "conn_98765", // Not counted in size calculation + extras = JsonObject().apply { // Size: JSON serialization byte length + addProperty("meta", "data") // JSON: {"meta":"data","count":42} + addProperty("count", 42) + }, // Total extras size: 26 bytes (verified by gson.toJson().length) + operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "obj_54321", // Not counted in operation size + + // MapOp contributes to operation size + mapOp = ObjectsMapOp( + key = "mapKey", // Size: 6 bytes (UTF-8 byte length) + data = ObjectData( + objectId = "ref_obj", // Not counted in data size + value = ObjectValue.String("sample") // Size: 6 bytes (UTF-8 byte length) + ) // Total ObjectData size: 6 bytes + ), // Total ObjectMapOp size: 6 + 6 = 12 bytes + + // CounterOp contributes to operation size + counterOp = ObjectsCounterOp( + amount = 10.0 // Size: 8 bytes (number is always 8 bytes) + ), // Total ObjectCounterOp size: 8 bytes + + // Map contributes to operation size (for MAP_CREATE operations) + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, // Not counted in size + entries = mapOf( + "entry1" to ObjectsMapEntry( // Key size: 6 bytes + tombstone = false, // Not counted in entry size + timeserial = "ts_123", // Not counted in entry size + data = ObjectData( + value = ObjectValue.String("value1") // Size: 6 bytes + ) // ObjectMapEntry size: 6 bytes + ), // Total for this entry: 6 (key) + 6 (entry) = 12 bytes + "entry2" to ObjectsMapEntry( // Key size: 6 bytes + data = ObjectData( + value = ObjectValue.Number(42) // Size: 8 bytes (number) + ) // ObjectMapEntry size: 8 bytes + ) // Total for this entry: 6 (key) + 8 (entry) = 14 bytes + ) // Total entries size: 12 + 14 = 26 bytes + ), // Total ObjectMap size: 26 bytes + + // Counter contributes to operation size (for COUNTER_CREATE operations) + counter = ObjectsCounter( + count = 100.0 // Size: 8 bytes (number is always 8 bytes) + ), // Total ObjectCounter size: 8 bytes + + nonce = "nonce123", // Not counted in operation size + initialValue = "some-value", // Not counted in operation size + ), // Total ObjectOperation size: 12 + 8 + 26 + 8 = 54 bytes + + objectState = ObjectState( + objectId = "state_obj", // Not counted in state size + siteTimeserials = mapOf("site1" to "serial1"), // Not counted in state size + tombstone = false, // Not counted in state size + + // createOp contributes to state size + createOp = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "create_obj", + mapOp = ObjectsMapOp( + key = "createKey", // Size: 9 bytes + data = ObjectData( + value = ObjectValue.String("createValue") // Size: 11 bytes + ) // ObjectData size: 11 bytes + ) // ObjectMapOp size: 9 + 11 = 20 bytes + ), // Total createOp size: 20 bytes + + // map contributes to state size + map = ObjectsMap( + entries = mapOf( + "stateKey" to ObjectsMapEntry( // Key size: 8 bytes + data = ObjectData( + value = ObjectValue.String("stateValue") // Size: 10 bytes + ) // ObjectMapEntry size: 10 bytes + ) // Total: 8 + 10 = 18 bytes + ) + ), // Total ObjectMap size: 18 bytes + + // counter contributes to state size + counter = ObjectsCounter( + count = 50.0 // Size: 8 bytes + ) // Total ObjectCounter size: 8 bytes + ), // Total ObjectState size: 20 + 18 + 8 = 46 bytes + + serial = "serial_123", // Not counted in size calculation + siteCode = "site_abc" // Not counted in size calculation + ) + + // clientId: 11 bytes + operation: 54 bytes + objectState: 46 bytes + extras: 26 bytes = 137 bytes + val messageSize = objectMessage.size() + assertEquals(137, messageSize) + + // Verify the message doesn't exceed the maxMessageSize limit + mockAdapter.ensureMessageSizeWithinLimit(arrayOf(objectMessage)) + } + + @Test + fun testObjectMessageSizeForUnicodeCharacters() = runTest { + val objectMessage = ObjectMessage( + operation = ObjectOperation( + objectId = "", + action = ObjectOperationAction.MapCreate, + mapOp = ObjectsMapOp( + key = "", + data = ObjectData( + value = ObjectValue.String("你😊") // 你 -> 3 bytes, 😊 -> 4 bytes + ), + ), + ) + ) + assertEquals(7, objectMessage.size()) + } + + @Test + fun testObjectMessageSizeAboveLimit() = runTest { + val mockAdapter = mockk(relaxed = true) + mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb + assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) + + // Create ObjectMessage with dummy data that results in size 60kb + val objectMessage1 = ObjectMessage( + clientId = CharArray(60 * 1024) { ('a'..'z').random() }.concatToString() + ) + assertEquals(60 * 1024, objectMessage1.size()) + + // Create ObjectMessage with dummy data that results in size 5kb + val objectMessage2 = ObjectMessage( + clientId = CharArray(5 * 1024) { ('a'..'z').random() }.concatToString() + ) + assertEquals(5 * 1024, objectMessage2.size()) + + val exception = assertFailsWith { + mockAdapter.ensureMessageSizeWithinLimit(arrayOf(objectMessage1, objectMessage2)) // sum size = 65kb exceeds limit + } + // Assert on error code and message + assertEquals(40009, exception.errorInfo.code) + val expectedMessage = "ObjectMessages size 66560 exceeds maximum allowed size of 65536 bytes" + assertEquals(expectedMessage, exception.errorInfo.message) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt new file mode 100644 index 000000000..3f63a2d82 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt @@ -0,0 +1,65 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.ObjectsSyncTracker +import org.junit.Test +import org.junit.Assert.* + +class ObjectsSyncTrackerTest { + + @Test + fun `(RTO5a, RTO5a1, RTO5a2) Should parse valid sync channel serial with syncId and cursor`() { + val syncTracker = ObjectsSyncTracker("sync-123:cursor-456") + + assertEquals("sync-123", syncTracker.syncId) + assertFalse(syncTracker.hasSyncStarted("sync-123")) + assertTrue(syncTracker.hasSyncStarted(null)) + assertTrue(syncTracker.hasSyncStarted("sync-124")) + + assertEquals("cursor-456", syncTracker.syncCursor) + assertFalse(syncTracker.hasSyncEnded()) + } + + @Test + fun `(RTO5a5) Should handle null sync channel serial`() { + val syncTracker = ObjectsSyncTracker(null) + + assertNull(syncTracker.syncId) + assertTrue(syncTracker.hasSyncStarted(null)) + + assertNull(syncTracker.syncCursor) + assertTrue(syncTracker.hasSyncEnded()) + } + + @Test + fun `(RTO5a5) Should handle empty sync channel serial`() { + val syncTracker = ObjectsSyncTracker("") + + assertNull(syncTracker.syncId) + assertTrue(syncTracker.hasSyncStarted(null)) + + assertNull(syncTracker.syncCursor) + assertTrue(syncTracker.hasSyncEnded()) + } + + @Test + fun `should handle sync channel serial with special characters`() { + val syncTracker = ObjectsSyncTracker("sync_123-456:cursor_789-012") + + assertEquals("sync_123-456", syncTracker.syncId) + + assertEquals("cursor_789-012", syncTracker.syncCursor) + assertFalse(syncTracker.hasSyncEnded()) + } + + @Test + fun `(RTO5a4) should detect sync sequence ended when sync cursor is empty`() { + val syncTracker = ObjectsSyncTracker("sync-123:") + + assertEquals("sync-123", syncTracker.syncId) + assertTrue(syncTracker.hasSyncStarted(null)) + assertTrue(syncTracker.hasSyncStarted("")) + + assertEquals("", syncTracker.syncCursor) + assertTrue(syncTracker.hasSyncEnded()) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt new file mode 100644 index 000000000..ec8824e1a --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt @@ -0,0 +1,14 @@ +package io.ably.lib.objects.unit + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertNotNull + +class RealtimeObjectsTest { + @Test + fun testChannelObjectGetterTest() = runTest { + val channel = getMockRealtimeChannel("test-channel") + val objects = channel.objects + assertNotNull(objects) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt new file mode 100644 index 000000000..94354fcf9 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt @@ -0,0 +1,161 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.* +import io.ably.lib.objects.DefaultRealtimeObjects +import io.ably.lib.objects.ObjectsManager +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livecounter.LiveCounterManager +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livemap.LiveMapManager +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ClientOptions +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk + +internal fun getMockRealtimeChannel( + channelName: String, + clientId: String = "client1", + channelModes: Array = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe)): Channel { + val client = AblyRealtime(ClientOptions().apply { + autoConnect = false + key = "keyName:Value" + this.clientId = clientId + }) + val channelOpts = ChannelOptions().apply { modes = channelModes } + val channel = client.channels.get(channelName, channelOpts) + return spyk(channel) { + every { attach() } answers { + state = ChannelState.attached + } + every { detach() } answers { + state = ChannelState.detached + } + every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true) + every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true) + every { subscribe(any()) } returns mockk(relaxUnitFun = true) + }.apply { + state = ChannelState.attached + } +} + +internal fun getMockObjectsAdapter(): ObjectsAdapter { + val mockkAdapter = mockk(relaxed = true) + every { mockkAdapter.getChannel(any()) } returns getMockRealtimeChannel("testChannelName") + return mockkAdapter +} + +internal fun getMockObjectsPool(): ObjectsPool { + return mockk(relaxed = true) +} + +internal fun ObjectsPool.size(): Int { + val pool = this.getPrivateField>("pool") + return pool.size +} + +internal val BaseRealtimeObject.TombstonedAt: Long? + get() = this.getPrivateField("tombstonedAt") + +/** + * ====================================== + * START - DefaultRealtimeObjects dep mocks + * ====================================== + */ +internal val ObjectsManager.SyncObjectsDataPool: Map + get() = this.getPrivateField("syncObjectsDataPool") + +internal val ObjectsManager.BufferedObjectOperations: List + get() = this.getPrivateField("bufferedObjectOperations") + +internal var DefaultRealtimeObjects.ObjectsManager: ObjectsManager + get() = this.getPrivateField("objectsManager") + set(value) = this.setPrivateField("objectsManager", value) + +internal var DefaultRealtimeObjects.ObjectsPool: ObjectsPool + get() = this.objectsPool + set(value) = this.setPrivateField("objectsPool", value) + +internal fun getDefaultRealtimeObjectsWithMockedDeps( + channelName: String = "testChannelName", + relaxed: Boolean = false +): DefaultRealtimeObjects { + val defaultRealtimeObjects = DefaultRealtimeObjects(channelName, getMockObjectsAdapter()) + // mock objectsPool to allow verification of method calls + if (relaxed) { + defaultRealtimeObjects.ObjectsPool = mockk(relaxed = true) + } else { + defaultRealtimeObjects.ObjectsPool = spyk(defaultRealtimeObjects.objectsPool, recordPrivateCalls = true) + } + // mock objectsManager to allow verification of method calls + if (relaxed) { + defaultRealtimeObjects.ObjectsManager = mockk(relaxed = true) + } else { + defaultRealtimeObjects.ObjectsManager = spyk(defaultRealtimeObjects.ObjectsManager, recordPrivateCalls = true) + } + return defaultRealtimeObjects +} +/** + * ====================================== + * END - DefaultRealtimeObjects dep mocks + * ====================================== + */ + +/** + * ====================================== + * START - DefaultLiveCounter dep mocks + * ====================================== + */ +internal var DefaultLiveCounter.LiveCounterManager: LiveCounterManager + get() = this.getPrivateField("liveCounterManager") + set(value) = this.setPrivateField("liveCounterManager", value) + +internal fun getDefaultLiveCounterWithMockedDeps( + objectId: String = "counter:testCounter@1", + relaxed: Boolean = false +): DefaultLiveCounter { + val defaultLiveCounter = DefaultLiveCounter.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) + if (relaxed) { + defaultLiveCounter.LiveCounterManager = mockk(relaxed = true) + } else { + defaultLiveCounter.LiveCounterManager = spyk(defaultLiveCounter.LiveCounterManager, recordPrivateCalls = true) + } + return defaultLiveCounter +} +/** + * ====================================== + * END - DefaultLiveCounter dep mocks + * ====================================== + */ + +/** + * ====================================== + * START - DefaultLiveMap dep mocks + * ====================================== + */ +internal var DefaultLiveMap.LiveMapManager: LiveMapManager + get() = this.getPrivateField("liveMapManager") + set(value) = this.setPrivateField("liveMapManager", value) + +internal fun getDefaultLiveMapWithMockedDeps( + objectId: String = "map:testMap@1", + relaxed: Boolean = false +): DefaultLiveMap { + val defaultLiveMap = DefaultLiveMap.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) + if (relaxed) { + defaultLiveMap.LiveMapManager = mockk(relaxed = true) + } else { + defaultLiveMap.LiveMapManager = spyk(defaultLiveMap.LiveMapManager, recordPrivateCalls = true) + } + return defaultLiveMap +} +/** + * ====================================== + * END - DefaultLiveMap dep mocks + * ====================================== + */ diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt new file mode 100644 index 000000000..a6cd9bcf8 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt @@ -0,0 +1,301 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.* +import io.ably.lib.objects.assertWaiter +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.junit.Test +import org.junit.Assert.* +import java.util.concurrent.CancellationException + +class UtilsTest { + + @Test + fun testGenerateNonce() { + // Test basic functionality + val nonce1 = generateNonce() + val nonce2 = generateNonce() + + assertEquals(16, nonce1.length) + assertEquals(16, nonce2.length) + assertNotEquals(nonce1, nonce2) // Should be random + + // Test character set + val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val nonce = generateNonce() + nonce.forEach { char -> + assertTrue("Nonce should only contain valid characters", validChars.contains(char)) + } + } + + @Test + fun testStringByteSize() { + // Test ASCII strings + assertEquals(5, "Hello".byteSize) + assertEquals(0, "".byteSize) + assertEquals(1, "A".byteSize) + + // Test non-ASCII strings + assertEquals(3, "你".byteSize) // Chinese character + assertEquals(4, "😊".byteSize) // Emoji + assertEquals(6, "你好".byteSize) // Two Chinese characters + } + + @Test + fun testErrorCreationFunctions() { + // Test clientError + val clientEx = clientError("Bad request") + assertEquals("Bad request", clientEx.errorInfo.message) + assertEquals(ErrorCode.BadRequest.code, clientEx.errorInfo.code) + assertEquals(HttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) + + // Test serverError + val serverEx = serverError("Internal error") + assertEquals("Internal error", serverEx.errorInfo.message) + assertEquals(ErrorCode.InternalError.code, serverEx.errorInfo.code) + assertEquals(HttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) + + // Test objectError + val objectEx = objectError("Invalid object") + assertEquals("Invalid object", objectEx.errorInfo.message) + assertEquals(ErrorCode.InvalidObject.code, objectEx.errorInfo.code) + assertEquals(HttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) + + // Test objectError with cause + val cause = RuntimeException("Original error") + val objectExWithCause = objectError("Invalid object", cause) + assertEquals("Invalid object", objectExWithCause.errorInfo.message) + assertEquals(cause, objectExWithCause.cause) + } + + @Test + fun testAblyExceptionCreation() { + // Test with error message and codes + val ex = ablyException("Test error", ErrorCode.BadRequest, HttpStatusCode.BadRequest) + assertEquals("Test error", ex.errorInfo.message) + assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(HttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) + + // Test with ErrorInfo + val errorInfo = ErrorInfo("Custom error", 400, 40000) + val ex2 = ablyException(errorInfo) + assertEquals("Custom error", ex2.errorInfo.message) + assertEquals(400, ex2.errorInfo.statusCode) + assertEquals(40000, ex2.errorInfo.code) + + // Test with cause + val cause = RuntimeException("Cause") + val ex3 = ablyException(errorInfo, cause) + assertEquals(cause, ex3.cause) + } + + @Test + fun testObjectsAsyncScopeLaunchWithCallback() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callbackExecuted = false + var resultReceived: String? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + callbackExecuted = true + resultReceived = result + } + + override fun onError(exception: AblyException) { + fail("Should not call onError for successful execution") + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) // Simulate async work + "test result" + } + + // Wait for callback to be executed + assertWaiter { callbackExecuted } + + assertTrue("Callback should be executed", callbackExecuted) + assertEquals("test result", resultReceived) + } + + @Test + fun testObjectsAsyncScopeLaunchWithCallbackError() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived: AblyException? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess for error case") + } + + override fun onError(exception: AblyException) { + errorReceived = exception + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) + throw AblyException.fromErrorInfo(ErrorInfo("Test error", 400, 40000)) + } + + // Wait for error to be received + assertWaiter { errorReceived != null } + + assertNotNull("Error should be received", errorReceived) + assertEquals("Test error", errorReceived?.errorInfo?.message) + assertEquals(400, errorReceived?.errorInfo?.statusCode) + } + + @Test + fun testObjectsAsyncScopeLaunchWithVoidCallback() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callbackExecuted = false + + val callback = object : ObjectsCallback { + override fun onSuccess(result: Void?) { + callbackExecuted = true + } + + override fun onError(exception: AblyException) { + fail("Should not call onError for successful execution") + } + } + + asyncScope.launchWithVoidCallback(callback) { + delay(10) // Simulate async work + } + + // Wait for callback to be executed + assertWaiter { callbackExecuted } + + assertTrue("Callback should be executed", callbackExecuted) + } + + @Test + fun testObjectsAsyncScopeLaunchWithVoidCallbackError() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived: AblyException? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: Void?) { + fail("Should not call onSuccess for error case") + } + + override fun onError(exception: AblyException) { + errorReceived = exception + } + } + + asyncScope.launchWithVoidCallback(callback) { + delay(10) + throw AblyException.fromErrorInfo(ErrorInfo("Test error", 500, 50000)) + } + + // Wait for error to be received + assertWaiter { errorReceived != null } + + assertNotNull("Error should be received", errorReceived) + assertEquals("Test error", errorReceived?.errorInfo?.message) + assertEquals(500, errorReceived?.errorInfo?.statusCode) + } + + @Test + fun testObjectsAsyncScopeCallbackExceptionHandling() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callback1Called = false + var callback2Called = false + + val callback1 = object : ObjectsCallback { + override fun onSuccess(result: String) { + callback1Called = true + throw RuntimeException("Callback exception") + } + + override fun onError(exception: AblyException) { + fail("Should not call onError when onSuccess throws") + } + } + + asyncScope.launchWithCallback(callback1) { "test result" } + // Wait for callback to be called + assertWaiter { callback1Called } + + val callback2 = object : ObjectsCallback { + override fun onSuccess(result: String) { + callback2Called = true + } + + override fun onError(exception: AblyException) { + fail("Should not call onError when onSuccess throws") + } + } + + asyncScope.launchWithCallback(callback2) { "test result" } + // Callback 2 should be called even if callback 1 throws an exception + assertWaiter { callback2Called } + } + + @Test + fun testObjectsAsyncScopeCancel() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived = false + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess") + } + + override fun onError(exception: AblyException) { + errorReceived = true + } + } + + asyncScope.launchWithCallback(callback) { + delay(10000) // Long delay + "test result" + } + + // Cancel immediately + asyncScope.cancel(CancellationException("Test cancellation")) + + // Wait a bit to ensure cancellation takes effect + assertWaiter { errorReceived } + } + + @Test + fun testObjectsAsyncScopeNonAblyException() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived = false + var error: AblyException? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess for error case") + } + + override fun onError(exception: AblyException) { + errorReceived = true + error = exception + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) + throw RuntimeException("Non-Ably exception") + } + + // Wait for error to be received + assertWaiter { errorReceived } + + // Non-Ably exceptions should be wrapped in AblyException + assertNotNull("Non-Ably exceptions should be wrapped in AblyException", error) + assertEquals("Error executing operation", error?.errorInfo?.message) + assertEquals(ErrorCode.BadRequest.code, error?.errorInfo?.code) + assertEquals(HttpStatusCode.BadRequest.code, error?.errorInfo?.statusCode) + + assertTrue(error?.cause is RuntimeException) + assertEquals("Non-Ably exception", error?.cause?.message) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt new file mode 100644 index 000000000..e09101ac0 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt @@ -0,0 +1,176 @@ +package io.ably.lib.objects.unit.fixtures + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.objects.* +import io.ably.lib.objects.Binary +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectValue + +internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", ObjectValue.String("dummy string")) + +internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", ObjectValue.Binary(Binary(byteArrayOf(1, 2, 3)))) + +internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", ObjectValue.Number(42.0)) + +internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", ObjectValue.Boolean(true)) + +val dummyJsonObject = JsonObject().apply { addProperty("foo", "bar") } +internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", ObjectValue.JsonObject(dummyJsonObject)) + +val dummyJsonArray = JsonArray().apply { add(1); add(2); add(3) } +internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", ObjectValue.JsonArray(dummyJsonArray)) + +internal val dummyObjectsMapEntry = ObjectsMapEntry( + tombstone = false, + timeserial = "dummy-timeserial", + data = dummyObjectDataStringValue +) + +internal val dummyObjectsMap = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf("dummy-key" to dummyObjectsMapEntry) +) + +internal val dummyObjectsCounter = ObjectsCounter( + count = 123.0 +) + +internal val dummyObjectsMapOp = ObjectsMapOp( + key = "dummy-key", + data = dummyObjectDataStringValue +) + +internal val dummyObjectsCounterOp = ObjectsCounterOp( + amount = 10.0 +) + +internal val dummyObjectOperation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "dummy-object-id", + mapOp = dummyObjectsMapOp, + counterOp = dummyObjectsCounterOp, + map = dummyObjectsMap, + counter = dummyObjectsCounter, + nonce = "dummy-nonce", + initialValue = "{\"foo\":\"bar\"}" +) + +internal val dummyObjectState = ObjectState( + objectId = "dummy-object-id", + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + createOp = dummyObjectOperation, + map = dummyObjectsMap, + counter = dummyObjectsCounter +) + +internal val dummyObjectMessage = ObjectMessage( + id = "dummy-id", + timestamp = 1234567890L, + clientId = "dummy-client-id", + connectionId = "dummy-connection-id", + extras = JsonObject().apply { addProperty("meta", "data") }, + operation = dummyObjectOperation, + objectState = dummyObjectState, + serial = "dummy-serial", + siteCode = "dummy-site-code" +) + +internal fun dummyObjectMessageWithStringData(): ObjectMessage { + return dummyObjectMessage +} + +internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { + val binaryObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBinaryObjectValue) + val binaryObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) + val binaryObjectMapOp = dummyObjectsMapOp.copy(data = dummyBinaryObjectValue) + val binaryObjectOperation = dummyObjectOperation.copy( + mapOp = binaryObjectMapOp, + map = binaryObjectMap + ) + val binaryObjectState = dummyObjectState.copy( + map = binaryObjectMap, + createOp = binaryObjectOperation + ) + return dummyObjectMessage.copy( + operation = binaryObjectOperation, + objectState = binaryObjectState + ) +} + +internal fun dummyObjectMessageWithNumberData(): ObjectMessage { + val numberObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyNumberObjectValue) + val numberObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) + val numberObjectMapOp = dummyObjectsMapOp.copy(data = dummyNumberObjectValue) + val numberObjectOperation = dummyObjectOperation.copy( + mapOp = numberObjectMapOp, + map = numberObjectMap + ) + val numberObjectState = dummyObjectState.copy( + map = numberObjectMap, + createOp = numberObjectOperation + ) + return dummyObjectMessage.copy( + operation = numberObjectOperation, + objectState = numberObjectState + ) +} + +internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { + val booleanObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBooleanObjectValue) + val booleanObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) + val booleanObjectMapOp = dummyObjectsMapOp.copy(data = dummyBooleanObjectValue) + val booleanObjectOperation = dummyObjectOperation.copy( + mapOp = booleanObjectMapOp, + map = booleanObjectMap + ) + val booleanObjectState = dummyObjectState.copy( + map = booleanObjectMap, + createOp = booleanObjectOperation + ) + return dummyObjectMessage.copy( + operation = booleanObjectOperation, + objectState = booleanObjectState + ) +} + +internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { + val jsonObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonObjectValue) + val jsonObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) + val jsonObjectMapOp = dummyObjectsMapOp.copy(data = dummyJsonObjectValue) + val jsonObjectOperation = dummyObjectOperation.copy( + action = ObjectOperationAction.MapSet, + mapOp = jsonObjectMapOp, + map = jsonObjectMap + ) + val jsonObjectState = dummyObjectState.copy( + map = jsonObjectMap, + createOp = jsonObjectOperation + ) + return dummyObjectMessage.copy( + operation = jsonObjectOperation, + objectState = jsonObjectState + ) +} + +internal fun dummyObjectMessageWithJsonArrayData(): ObjectMessage { + val jsonArrayMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonArrayValue) + val jsonArrayMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonArrayMapEntry)) + val jsonArrayMapOp = dummyObjectsMapOp.copy(data = dummyJsonArrayValue) + val jsonArrayOperation = dummyObjectOperation.copy( + action = ObjectOperationAction.MapSet, + mapOp = jsonArrayMapOp, + map = jsonArrayMap + ) + val jsonArrayState = dummyObjectState.copy( + map = jsonArrayMap, + createOp = jsonArrayOperation + ) + return dummyObjectMessage.copy( + operation = jsonArrayOperation, + objectState = jsonArrayState + ) +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt new file mode 100644 index 000000000..40565cabe --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt @@ -0,0 +1,235 @@ +package io.ably.lib.objects.unit.objects + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectsCounterOp +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectsState +import io.ably.lib.objects.ROOT_OBJECT_ID +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livemap.LiveMapEntry +import io.ably.lib.objects.unit.BufferedObjectOperations +import io.ably.lib.objects.unit.ObjectsManager +import io.ably.lib.objects.unit.SyncObjectsDataPool +import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps +import io.ably.lib.objects.unit.size +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ProtocolMessage +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import io.mockk.every + +class DefaultRealtimeObjectsTest { + + @Test + fun `(RTO4, RTO4a) When channel ATTACHED with HAS_OBJECTS flag true should start sync sequence`() = runTest { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + + // RTO4a - If the HAS_OBJECTS flag is 1, the server will shortly perform an OBJECT_SYNC sequence + defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) + + assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } + + // It is expected that the client will start a new sync sequence + verify(exactly = 1) { + defaultRealtimeObjects.ObjectsManager.startNewSync(null) + } + verify(exactly = 0) { + defaultRealtimeObjects.ObjectsManager.endSync(any()) + } + } + + @Test + fun `(RTO4, RTO4b) When channel ATTACHED with HAS_OBJECTS flag false should complete sync immediately`() = runTest { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + + // Set up some objects in objectPool that should be cleared + val rootObject = defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap + rootObject.data["key1"] = LiveMapEntry(data = ObjectData("testValue1")) + defaultRealtimeObjects.objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects)) + assertEquals(2, defaultRealtimeObjects.objectsPool.size(), "RTO4b - Should have 2 objects before state change") + + // RTO4b - If the HAS_OBJECTS flag is 0, the sync sequence must be considered complete immediately + defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) + + // Verify expected outcomes + assertWaiter { defaultRealtimeObjects.state == ObjectsState.Synced } // RTO4b4 + + verify(exactly = 1) { + defaultRealtimeObjects.objectsPool.resetToInitialPool(true) + } + verify(exactly = 1) { + defaultRealtimeObjects.ObjectsManager.endSync(any()) + } + + assertEquals(0, defaultRealtimeObjects.ObjectsManager.SyncObjectsDataPool.size) // RTO4b3 + assertEquals(0, defaultRealtimeObjects.ObjectsManager.BufferedObjectOperations.size) // RTO4b5 + assertEquals(1, defaultRealtimeObjects.objectsPool.size()) // RTO4b1 - Only root remains + assertEquals(rootObject, defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID)) // points to previously created root object + assertEquals(0, rootObject.data.size) // RTO4b2 - root object must be empty + } + + @Test + fun `(RTO4) When channel ATTACHED from INITIALIZED state should always start sync`() = runTest { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + + // Ensure we're in INITIALIZED state + defaultRealtimeObjects.state = ObjectsState.Initialized + + // RTO4a - Should start sync even with HAS_OBJECTS flag false when in INITIALIZED state + defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) + + verify(exactly = 1) { + defaultRealtimeObjects.ObjectsManager.startNewSync(null) + } + verify(exactly = 1) { + defaultRealtimeObjects.ObjectsManager.endSync(true) // deferStateEvent = true + } + } + + @Test + fun `(RTO5, RTO7) Should delegate OBJECT and OBJECT_SYNC protocolMessage to ObjectManager`() = runTest { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps(relaxed = true) + + // Create test ObjectMessage for OBJECT action + val objectMessage = ObjectMessage( + id = "testId", + timestamp = 1234567890L, + connectionId = "testConnectionId", + operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "counter:testObject@1", + counterOp = ObjectsCounterOp(amount = 5.0) + ), + serial = "serial1", + siteCode = "site1" + ) + // Create ProtocolMessage with OBJECT action + val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { + id = "protocolId1" + channel = "testChannel" + channelSerial = "channelSerial1" + timestamp = 1234567890L + state = arrayOf(objectMessage) + } + // Test OBJECT action delegation + defaultRealtimeObjects.handle(objectProtocolMessage) + + // Verify that handleObjectMessages was called with the correct parameters + verify(exactly = 1) { + defaultRealtimeObjects.ObjectsManager.handleObjectMessages(listOf(objectMessage)) + } + + // Create test ObjectMessage for OBJECT_SYNC action + val objectSyncMessage = ObjectMessage( + id = "testSyncId", + timestamp = 1234567890L, + connectionId = "testSyncConnectionId", + objectState = ObjectState( + objectId = "map:testObject@1", + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + ), + serial = "syncSerial1", + siteCode = "site1" + ) + // Create ProtocolMessage with OBJECT_SYNC action + val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { + id = "protocolId2" + channel = "testChannel" + channelSerial = "syncChannelSerial1" + timestamp = 1234567890L + state = arrayOf(objectSyncMessage) + } + // Test OBJECT_SYNC action delegation + defaultRealtimeObjects.handle(objectSyncProtocolMessage) + // Verify that handleObjectSyncMessages was called with the correct parameters + verify(exactly = 1) { + defaultRealtimeObjects.ObjectsManager.handleObjectSyncMessages(listOf(objectSyncMessage), "syncChannelSerial1") + } + } + + @Test + fun `(OM2) Populate objectMessage missing id, timestamp and connectionId from protocolMessage`() = runTest { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + + // Capture the ObjectMessages that are passed to ObjectsManager methods + var capturedObjectMessages: List? = null + var capturedObjectSyncMessages: List? = null + + // Mock the ObjectsManager to capture the messages + defaultRealtimeObjects.ObjectsManager.apply { + every { handleObjectMessages(any>()) } answers { + capturedObjectMessages = firstArg() + } + every { handleObjectSyncMessages(any(), any()) } answers { + capturedObjectSyncMessages = firstArg() + } + } + + // Create ObjectMessage with missing fields (id, timestamp, connectionId) + val objectMessageWithMissingFields = ObjectMessage( + id = null, // OM2a - missing id + timestamp = null, // OM2e - missing timestamp + connectionId = null, // OM2c - missing connectionId + ) + + // Create ProtocolMessage with OBJECT action and populated fields + val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { + id = "protocolId1" + channel = "testChannel" + channelSerial = "channelSerial1" + connectionId = "protocolConnectionId" + timestamp = 1234567890L + state = arrayOf(objectMessageWithMissingFields) + } + + // Test OBJECT action - should populate missing fields + defaultRealtimeObjects.handle(objectProtocolMessage) + + // Verify that the captured ObjectMessage has populated fields + assertWaiter { capturedObjectMessages != null } + assertEquals(1, capturedObjectMessages!!.size) + + val populatedObjectMessage = capturedObjectMessages!![0] + assertEquals("protocolId1:0", populatedObjectMessage.id) // OM2a - id should be protocolId:index + assertEquals(1234567890L, populatedObjectMessage.timestamp) // OM2e - timestamp from protocol message + assertEquals("protocolConnectionId", populatedObjectMessage.connectionId) // OM2c - connectionId from protocol message + + + // Create ObjectMessage with missing fields for OBJECT_SYNC + val objectSyncMessageWithMissingFields = ObjectMessage( + id = null, // OM2a - missing id + timestamp = null, // OM2e - missing timestamp + connectionId = null, // OM2c - missing connectionId + ) + + // Create ProtocolMessage with OBJECT_SYNC action and populated fields + val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { + id = "protocolId2" + channel = "testChannel" + channelSerial = "syncChannelSerial1" + connectionId = "protocolConnectionId" + timestamp = 9876543210L + state = arrayOf(objectSyncMessageWithMissingFields) + } + + // Test OBJECT_SYNC action - should populate missing fields + defaultRealtimeObjects.handle(objectSyncProtocolMessage) + + // Verify that the captured ObjectMessage has populated fields + assertWaiter { capturedObjectSyncMessages != null } + assertEquals(1, capturedObjectSyncMessages!!.size) + + val populatedObjectSyncMessage = capturedObjectSyncMessages!![0] + assertEquals("protocolId2:0", populatedObjectSyncMessage.id) // OM2a - id should be protocolId:index + assertEquals(9876543210L, populatedObjectSyncMessage.timestamp) // OM2e - timestamp from protocol message + assertEquals("protocolConnectionId", populatedObjectSyncMessage.connectionId) // OM2c - connectionId from protocol message + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt new file mode 100644 index 000000000..3e04d9e06 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt @@ -0,0 +1,232 @@ +package io.ably.lib.objects.unit.objects + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectsState +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.unit.* +import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps +import io.mockk.* +import org.junit.Test +import kotlin.test.* + +class ObjectsManagerTest { + + @Test + fun `(RTO5) ObjectsManager should handle object sync messages`() { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") + + val objectsManager = defaultRealtimeObjects.ObjectsManager + + mockZeroValuedObjects() + + // Populate objectsPool with existing objects + val objectsPool = defaultRealtimeObjects.ObjectsPool + objectsPool.set("map:testObject@1", mockk(relaxed = true)) + objectsPool.set("counter:testObject@4", mockk(relaxed = true)) + + // Incoming object messages + val objectMessage1 = ObjectMessage( + id = "testId1", + objectState = ObjectState( + objectId = "map:testObject@1", // already exists in pool + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + map = ObjectsMap(), + ) + ) + val objectMessage2 = ObjectMessage( + id = "testId2", + objectState = ObjectState( + objectId = "counter:testObject@2", // Does not exist in pool + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + counter = ObjectsCounter(count = 20.0) + ) + ) + val objectMessage3 = ObjectMessage( + id = "testId3", + objectState = ObjectState( + objectId = "map:testObject@3", // Does not exist in pool + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + map = ObjectsMap(), + ) + ) + // Should start and end sync, apply object states, and create new objects for missing ones + objectsManager.handleObjectSyncMessages(listOf(objectMessage1, objectMessage2, objectMessage3), "sync-123:") + + verify(exactly = 1) { + objectsManager.startNewSync("sync-123") + } + verify(exactly = 1) { + objectsManager.endSync(true) // deferStateEvent = true since new sync was started + } + val newlyCreatedObjects = mutableListOf() + verify(exactly = 2) { + objectsManager["createObjectFromState"](capture(newlyCreatedObjects)) + } + assertEquals("counter:testObject@2", newlyCreatedObjects[0].objectId) + assertEquals("map:testObject@3", newlyCreatedObjects[1].objectId) + + assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state, "State should be SYNCED after sync sequence") + // After sync `counter:testObject@4` will be removed from pool + assertNull(objectsPool.get("counter:testObject@4")) + assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects after sync including root") + assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") + val testObject1 = objectsPool.get("map:testObject@1") + assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") + verify(exactly = 1) { + testObject1.applyObjectSync(any()) + } + val testObject2 = objectsPool.get("counter:testObject@2") + assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") + verify(exactly = 1) { + testObject2.applyObjectSync(any()) + } + val testObject3 = objectsPool.get("map:testObject@3") + assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") + verify(exactly = 1) { + testObject3.applyObjectSync(any()) + } + } + + @Test + fun `(RTO8) ObjectsManager should apply object operation when state is synced`() { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + defaultRealtimeObjects.state = ObjectsState.Synced // Ensure we're in SYNCED state + + val objectsManager = defaultRealtimeObjects.ObjectsManager + + mockZeroValuedObjects() + + // Populate objectsPool with existing objects + val objectsPool = defaultRealtimeObjects.ObjectsPool + objectsPool.set("map:testObject@1", mockk(relaxed = true)) + + // Incoming object messages with operation field instead of objectState + val objectMessage1 = ObjectMessage( + id = "testId1", + operation = ObjectOperation( + action = ObjectOperationAction.MapSet, // Assuming this is the right action for maps + objectId = "map:testObject@1", // already exists in pool + ), + serial = "serial1", + siteCode = "site1" + ) + + val objectMessage2 = ObjectMessage( + id = "testId2", + operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, // Set the counter value + objectId = "counter:testObject@2", // Does not exist in pool + ), + serial = "serial2", + siteCode = "site1" + ) + + val objectMessage3 = ObjectMessage( + id = "testId3", + operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testObject@3", // Does not exist in pool + ), + serial = "serial3", + siteCode = "site1" + ) + + // RTO8b - Apply messages immediately if synced + objectsManager.handleObjectMessages(listOf(objectMessage1, objectMessage2, objectMessage3)) + assertEquals(0, objectsManager.BufferedObjectOperations.size, "No buffer needed in SYNCED state") + + assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects including root") + assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") + + val testObject1 = objectsPool.get("map:testObject@1") + assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") + verify(exactly = 1) { + testObject1.applyObject(objectMessage1) + } + val testObject2 = objectsPool.get("counter:testObject@2") + assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") + verify(exactly = 1) { + testObject2.applyObject(objectMessage2) + } + val testObject3 = objectsPool.get("map:testObject@3") + assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") + verify(exactly = 1) { + testObject3.applyObject(objectMessage3) + } + } + + @Test + fun `(RTO7) ObjectsManager should buffer operations when not in sync, apply them after synced`() { + val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") + + val objectsManager = defaultRealtimeObjects.ObjectsManager + assertEquals(0, objectsManager.BufferedObjectOperations.size, "RTO7a1 - Initial buffer should be empty") + + val objectsPool = defaultRealtimeObjects.ObjectsPool + assertEquals(1, objectsPool.size(), "RTO7a2 - Initial pool should contain only root object") + + mockZeroValuedObjects() + + // Set state to SYNCING + defaultRealtimeObjects.state = ObjectsState.Syncing + + val objectMessage = ObjectMessage( + id = "testId", + operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testObject@1", + counterOp = ObjectsCounterOp(amount = 5.0) + ), + serial = "serial1", + siteCode = "site1" + ) + + // RTO7a - Buffer operations during sync + objectsManager.handleObjectMessages(listOf(objectMessage)) + + verify(exactly = 0) { + objectsManager["applyObjectMessages"](any>()) + } + assertEquals(1, objectsManager.BufferedObjectOperations.size) + assertEquals(objectMessage, objectsManager.BufferedObjectOperations[0]) + assertEquals(1, objectsPool.size(), "Pool should still contain only root object during sync") + + // RTO7 - Apply buffered operations after sync + objectsManager.endSync(false) // End sync without new sync + verify(exactly = 1) { + objectsManager["applyObjectMessages"](any>()) + } + assertEquals(0, objectsManager.BufferedObjectOperations.size) + assertEquals(2, objectsPool.size(), "Pool should contain 2 objects after applying buffered operations") + assertNotNull(objectsPool.get("counter:testObject@1"), "Counter object should be created after sync") + assertTrue(objectsPool.get("counter:testObject@1") is DefaultLiveCounter, "Should create a DefaultLiveCounter object") + } + + private fun mockZeroValuedObjects() { + mockkObject(DefaultLiveMap.Companion) + every { + DefaultLiveMap.zeroValue(any(), any()) + } answers { + mockk(relaxed = true) + } + mockkObject(DefaultLiveCounter.Companion) + every { + DefaultLiveCounter.zeroValue(any(), any()) + } answers { + mockk(relaxed = true) + } + } + + @AfterTest + fun tearDown() { + unmockkAll() // Clean up all mockk objects after each test + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt new file mode 100644 index 000000000..656b1e7c1 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt @@ -0,0 +1,132 @@ +package io.ably.lib.objects.unit.objects + +import io.ably.lib.objects.DefaultRealtimeObjects +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ROOT_OBJECT_ID +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livemap.LiveMapEntry +import io.ably.lib.objects.unit.* +import io.mockk.mockk +import io.mockk.spyk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ObjectsPoolTest { + + @Test + fun `(RTO3, RTO3a, RTO3b) An internal ObjectsPool should be used to maintain the list of objects present on a channel`() { + val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = defaultRealtimeObjects.objectsPool + assertNotNull(objectsPool) + + // RTO3b - It must always contain a LiveMap object with id root + val rootLiveMap = objectsPool.get(ROOT_OBJECT_ID) + assertNotNull(rootLiveMap) + assertTrue(rootLiveMap is DefaultLiveMap) + assertTrue(rootLiveMap.data.isEmpty()) + assertEquals(ROOT_OBJECT_ID, rootLiveMap.objectId) + assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") + + // RTO3a - ObjectsPool is a Dict, a map of RealtimeObjects keyed by objectId string + val testLiveMap = DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true)) + objectsPool.set("map:testObject@1", testLiveMap) + val testLiveCounter = DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true)) + objectsPool.set("counter:testObject@1", testLiveCounter) + // Assert that the objects are stored in the pool + assertEquals(testLiveMap, objectsPool.get("map:testObject@1")) + assertEquals(testLiveCounter, objectsPool.get("counter:testObject@1")) + assertEquals(3, objectsPool.size(), "RTO3 - Should have 3 objects in pool (root + testLiveMap + testLiveCounter)") + } + + @Test + fun `(RTO6) ObjectsPool should create zero-value objects if not exists`() { + val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = spyk(defaultRealtimeObjects.objectsPool) + assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") + + // Test creating zero-value map + // RTO6b1, RTO6b2 - Type is parsed from the objectId format (map:hash@timestamp) + val mapId = "map:xyz789@67890" + val map = objectsPool.createZeroValueObjectIfNotExists(mapId) + assertNotNull(map, "Should create a map object") + assertTrue(map is DefaultLiveMap, "RTO6b2 - Should create a LiveMap for map type") + assertEquals(mapId, map.objectId) + assertTrue(map.data.isEmpty(), "RTO6b2 - Should create an empty map") + assertEquals(2, objectsPool.size(), "RTO6 - root + map should be in pool after creation") + + // Test creating zero-value counter + // RTO6b1, RTO6b3 - Type is parsed from the objectId format (counter:hash@timestamp) + val counterId = "counter:abc123@12345" + val counter = objectsPool.createZeroValueObjectIfNotExists(counterId) + assertNotNull(counter, "Should create a counter object") + assertTrue(counter is DefaultLiveCounter, "RTO6b3 - Should create a LiveCounter for counter type") + assertEquals(counterId, counter.objectId) + assertEquals(0.0, counter.data.get(), "RTO6b3 - Should create a zero-value counter") + assertEquals(3, objectsPool.size(), "RTO6 - root + map + counter should be in pool after creation") + + // RTO6a - If object exists in pool, do not create a new one + val existingMap = objectsPool.createZeroValueObjectIfNotExists(mapId) + assertEquals(map, existingMap, "RTO6a - Should return existing object, not create a new one") + val existingCounter = objectsPool.createZeroValueObjectIfNotExists(counterId) + assertEquals(counter, existingCounter, "RTO6a - Should return existing object, not create a new one") + assertEquals(3, objectsPool.size(), "RTO6 - Should still have 3 objects in pool after re-creation attempt") + } + + @Test + fun `(RTO4b1, RTO4b2) ObjectsPool should reset to initial pool retaining original root map`() { + val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = defaultRealtimeObjects.objectsPool + assertEquals(1, objectsPool.size()) + val rootMap = objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap + // add some data to the root map + rootMap.data["initialKey1"] = LiveMapEntry(data = ObjectData("testValue1")) + rootMap.data["initialKey2"] = LiveMapEntry(data = ObjectData("testValue2")) + assertEquals(2, rootMap.data.size, "RTO3 - Root map should have initial data") + + // Add some objects + objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) + assertEquals(2, objectsPool.size()) // root + testObject + objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) + assertEquals(3, objectsPool.size()) // root + testObject + anotherObject + objectsPool.set("map:testObject@1", DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true))) + assertEquals(4, objectsPool.size()) // root + testObject + anotherObject + testMap + + // Reset to initial pool + objectsPool.resetToInitialPool(true) + + // RTO4b1 - Should only contain root object + assertEquals(1, objectsPool.size()) + assertEquals(rootMap, objectsPool.get(ROOT_OBJECT_ID)) + // RTO4b2 - RootMap should be empty after reset + assertTrue(rootMap.data.isEmpty(), "RTO3 - Root map should be empty after reset") + } + + @Test + fun `(RTO5c2, RTO5c2a) ObjectsPool should delete extra object IDs`() { + val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = defaultRealtimeObjects.objectsPool + + // Add some objects + objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) + objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) + objectsPool.set("counter:testObject@3", DefaultLiveCounter.zeroValue("counter:testObject@3", mockk(relaxed = true))) + assertEquals(4, objectsPool.size()) // root + 3 objects + + // Delete extra object IDs (keep only object1 and object2) + val receivedObjectIds = mutableSetOf("counter:testObject@1", "counter:testObject@2") + objectsPool.deleteExtraObjectIds(receivedObjectIds) + + // Should only contain root, object1, and object2 + assertEquals(3, objectsPool.size()) + // RTO5c2a - Should keep the root object + assertNotNull(objectsPool.get(ROOT_OBJECT_ID)) + // RTO5c2 - Should delete object3 and keep object1 and object2 + assertNotNull(objectsPool.get("counter:testObject@1")) + assertNotNull(objectsPool.get("counter:testObject@2")) + assertNull(objectsPool.get("counter:testObject@3")) // Should be deleted + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt new file mode 100644 index 000000000..9868bf680 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt @@ -0,0 +1,172 @@ +package io.ably.lib.objects.unit.type + +import io.ably.lib.objects.* +import io.ably.lib.objects.type.BaseRealtimeObject +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith + +class BaseRealtimeObjectTest { + + private val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() + + @Test + fun `(RTLO1, RTLO2) BaseRealtimeObject should be abstract base class for LiveMap and LiveCounter`() { + // RTLO2 - Check that BaseRealtimeObject is abstract + val isAbstract = java.lang.reflect.Modifier.isAbstract(BaseRealtimeObject::class.java.modifiers) + assertTrue(isAbstract, "BaseRealtimeObject should be an abstract class") + + // RTLO1 - Check that BaseRealtimeObject is the parent class of DefaultLiveMap and DefaultLiveCounter + assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveMap::class.java), + "DefaultLiveMap should extend BaseRealtimeObject") + assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveCounter::class.java), + "DefaultLiveCounter should extend BaseRealtimeObject") + } + + @Test + fun `(RTLO3) BaseRealtimeObject should have required properties`() { + val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) + val liveCounter: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) + // RTLO3a - check that objectId is set correctly + assertEquals("map:testObject@1", liveMap.objectId) + assertEquals("counter:testObject@1", liveCounter.objectId) + + // RTLO3b, RTLO3b1 - check that siteTimeserials is initialized as an empty map + assertEquals(emptyMap(), liveMap.siteTimeserials) + assertEquals(emptyMap(), liveCounter.siteTimeserials) + + // RTLO3c - Create operation merged flag + assertFalse(liveMap.createOperationIsMerged, "Create operation should not be merged by default") + assertFalse(liveCounter.createOperationIsMerged, "Create operation should not be merged by default") + } + + @Test + fun `(RTLO4a1, RTLO4a2) canApplyOperation should accept ObjectMessage params and return boolean`() { + // RTLO4a1a - Assert parameter types and return type based on method signature using reflection + val method = BaseRealtimeObject::class.java.findMethod("canApplyOperation") + + // RTLO4a1a - Verify parameter types + val parameters = method.parameters + assertEquals(2, parameters.size, "canApplyOperation should have exactly 2 parameters") + + // First parameter should be String? (siteCode) + assertEquals(String::class.java, parameters[0].type, "First parameter should be of type String?") + assertTrue(parameters[0].isVarArgs.not(), "First parameter should not be varargs") + + // Second parameter should be String? (timeSerial) + assertEquals(String::class.java, parameters[1].type, "Second parameter should be of type String?") + assertTrue(parameters[1].isVarArgs.not(), "Second parameter should not be varargs") + + // RTLO4a2 - Verify return type + assertEquals(Boolean::class.java, method.returnType, "canApplyOperation should return Boolean") + } + + @Test + fun `(RTLO4a3) canApplyOperation should throw error for null or empty incoming siteSerial`() { + val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) + + // Test null serial + assertFailsWith("Should throw error for null serial") { + liveMap.canApplyOperation("site1", null) + } + + // Test empty serial + assertFailsWith("Should throw error for empty serial") { + liveMap.canApplyOperation("site1", "") + } + + // Test null siteCode + assertFailsWith("Should throw error for null site code") { + liveMap.canApplyOperation(null, "serial1") + } + + // Test empty siteCode + assertFailsWith("Should throw error for empty site code") { + liveMap.canApplyOperation("", "serial1") + } + } + + @Test + fun `(RTLO4a4, RTLO4a5) canApplyOperation should return true when existing siteSerial is null or empty`() { + val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) + assertTrue(liveMap.siteTimeserials.isEmpty(), "Initial siteTimeserials should be empty") + + // RTLO4a4 - Get siteSerial from siteTimeserials map + // RTLO4a5 - Return true when siteSerial is null (no entry in map) + assertTrue(liveMap.canApplyOperation("site1", "serial1"), + "Should return true when no siteSerial exists for the site") + + // RTLO4a5 - Return true when siteSerial is empty string + liveMap.siteTimeserials["site1"] = "" + assertTrue(liveMap.canApplyOperation("site1", "serial1"), + "Should return true when siteSerial is empty string") + } + + @Test + fun `(RTLO4a6) canApplyOperation should return true when message siteSerial is greater than existing siteSerial`() { + val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) + + // Set existing siteSerial + liveMap.siteTimeserials["site1"] = "serial1" + + // RTLO4a6 - Return true when message serial is greater (lexicographically) + assertTrue(liveMap.canApplyOperation("site1", "serial2"), + "Should return true when message serial 'serial2' > siteSerial 'serial1'") + + assertTrue(liveMap.canApplyOperation("site1", "serial10"), + "Should return true when message serial 'serial10' > siteSerial 'serial1'") + + assertTrue(liveMap.canApplyOperation("site1", "serialA"), + "Should return true when message serial 'serialA' > siteSerial 'serial1'") + } + + @Test + fun `(RTLO4a6) canApplyOperation should return false when message siteSerial is less than or equal to siteSerial`() { + val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) + + // Set existing siteSerial + liveMap.siteTimeserials["site1"] = "serial2" + + // RTLO4a6 - Return false when message serial is less than siteSerial + assertFalse(liveMap.canApplyOperation("site1", "serial1"), + "Should return false when message serial 'serial1' < siteSerial 'serial2'") + + // RTLO4a6 - Return false when message serial equals siteSerial + assertFalse(liveMap.canApplyOperation("site1", "serial2"), + "Should return false when message serial equals siteSerial") + + // RTLO4a6 - Return false when message serial is less (lexicographically) + assertTrue(liveMap.canApplyOperation("site1", "serialA"), + "Should return true when message serial 'serialA' > siteSerial 'serial2'") + } + + @Test + fun `(RTLO4a) canApplyOperation should work with different site codes`() { + val liveMap: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) + + // Set serials for different sites + liveMap.siteTimeserials["site1"] = "serial1" + liveMap.siteTimeserials["site2"] = "serial5" + + // Test site1 + assertTrue(liveMap.canApplyOperation("site1", "serial2"), + "Should return true for site1 when serial2 > serial1") + assertFalse(liveMap.canApplyOperation("site1", "serial1"), + "Should return false for site1 when serial1 = serial1") + + // Test site2 + assertTrue(liveMap.canApplyOperation("site2", "serial6"), + "Should return true for site2 when serial6 > serial5") + assertFalse(liveMap.canApplyOperation("site2", "serial4"), + "Should return false for site2 when serial4 < serial5") + + // Test new site (should return true) + assertTrue(liveMap.canApplyOperation("site3", "serial1"), + "Should return true for new site with any serial") + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt new file mode 100644 index 000000000..77576a907 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt @@ -0,0 +1,123 @@ +package io.ably.lib.objects.unit.type.livecounter + +import io.ably.lib.objects.ObjectsCounter +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps +import io.ably.lib.types.AblyException +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class DefaultLiveCounterTest { + @Test + fun `(RTLC6, RTLC6a) DefaultLiveCounter should override serials with state serials from sync`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + // Set initial data + liveCounter.siteTimeserials["site1"] = "serial1" + liveCounter.siteTimeserials["site2"] = "serial2" + + val objectState = ObjectState( + objectId = "counter:testCounter@1", + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + ) + + val objectMessage = ObjectMessage( + id = "testId", + objectState = objectState, + serial = "serial1", + siteCode = "site1" + ) + + liveCounter.applyObjectSync(objectMessage) + assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveCounter.siteTimeserials) // RTLC6a + } + + @Test + fun `(RTLC7, RTLC7a) DefaultLiveCounter should check objectId before applying operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testCounter@2", // Different objectId + counter = ObjectsCounter(count = 20.0) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", + siteCode = "site1" + ) + + // RTLC7a - Should throw error when objectId doesn't match + val exception = assertFailsWith { + liveCounter.applyObject(message) + } + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + + // Assert on error codes + assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code + assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLC7, RTLC7b) DefaultLiveCounter should validate site serial before applying operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + // Set existing site serial that is newer than the incoming message + liveCounter.siteTimeserials["site1"] = "serial2" // Newer than "serial1" + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testCounter@1", // Matching objectId + counter = ObjectsCounter(count = 20.0) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", // Older serial + siteCode = "site1" + ) + + // RTLC7b - Should skip operation when serial is not newer + liveCounter.applyObject(message) + + // Verify that the site serial was not updated (operation was skipped) + assertEquals("serial2", liveCounter.siteTimeserials["site1"]) + } + + @Test + fun `(RTLC7, RTLC7c) DefaultLiveCounter should update site serial if valid`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + // Set existing site serial that is older than the incoming message + liveCounter.siteTimeserials["site1"] = "serial1" // Older than "serial2" + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testCounter@1", // Matching objectId + counter = ObjectsCounter(count = 20.0) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial2", // Newer serial + siteCode = "site1" + ) + + // RTLC7c - Should update site serial when operation is valid + liveCounter.applyObject(message) + + // Verify that the site serial was updated + assertEquals("serial2", liveCounter.siteTimeserials["site1"]) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt new file mode 100644 index 000000000..6c1e49748 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt @@ -0,0 +1,312 @@ +package io.ably.lib.objects.unit.type.livecounter + +import io.ably.lib.objects.* +import io.ably.lib.objects.unit.LiveCounterManager +import io.ably.lib.objects.unit.TombstonedAt +import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps +import io.ably.lib.types.AblyException +import org.junit.Test +import kotlin.test.* + +class DefaultLiveCounterManagerTest { + + @Test + fun `(RTLC6, RTLC6b, RTLC6c) DefaultLiveCounter should override counter data with state from sync`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val objectState = ObjectState( + objectId = "testCounterId", + counter = ObjectsCounter(count = 25.0), + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + ) + + val update = liveCounterManager.applyState(objectState, null) + + assertFalse(liveCounter.createOperationIsMerged) // RTLC6b + assertEquals(25.0, liveCounter.data.get()) // RTLC6c + assertEquals(15.0, update.update.amount) // Difference between old and new data + } + + + @Test + fun `(RTLC6, RTLC6d) DefaultLiveCounter should merge create operation in state from sync`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(5.0) + + val createOp = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectsCounter(count = 10.0) + ) + + val objectState = ObjectState( + objectId = "testCounterId", + counter = ObjectsCounter(count = 15.0), + createOp = createOp, + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + // RTLC6d - Merge initial data from create operation + val update = liveCounterManager.applyState(objectState, null) + + assertEquals(25.0, liveCounter.data.get()) // 15 from state + 10 from create op + assertEquals(20.0, update.update.amount) // Total change + } + + + @Test + fun `(RTLC7, RTLC7d3) LiveCounterManager should throw error for unsupported action`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, // Unsupported action for counter + objectId = "testCounterId", + map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) + ) + + // RTLC7d3 - Should throw error for unsupported action + val exception = assertFailsWith { + liveCounterManager.applyOperation(operation, null) + } + + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + assertEquals(92000, errorInfo.code) // InvalidObject error code + assertEquals(500, errorInfo.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLC7, RTLC7d1, RTLC8) LiveCounterManager should apply counter create operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectsCounter(count = 20.0) + ) + + // RTLC7d1 - Apply counter create operation + liveCounterManager.applyOperation(operation, null) + + assertEquals(20.0, liveCounter.data.get()) // Should be set to counter count + assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged + } + + @Test + fun `(RTLC8, RTLC8b) LiveCounterManager should skip counter create operation if already merged`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + liveCounter.data.set(4.0) // Start with 4 + + // Set create operation as already merged + liveCounter.createOperationIsMerged = true + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectsCounter(count = 20.0) + ) + + // RTLC8b - Should skip if already merged + liveCounterManager.applyOperation(operation, null) + + assertEquals(4.0, liveCounter.data.get()) // Should not change (still 0) + assertTrue(liveCounter.createOperationIsMerged) // Should remain merged + } + + @Test + fun `(RTLC8, RTLC8c) LiveCounterManager should apply counter create operation if not merged`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + // Set initial data + liveCounter.data.set(10.0) // Start with 10 + + // Set create operation as not merged + liveCounter.createOperationIsMerged = false + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectsCounter(count = 20.0) + ) + + // RTLC8c - Should apply if not merged + liveCounterManager.applyOperation(operation, null) + assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged + + assertEquals(30.0, liveCounter.data.get()) // Should be set to counter count + assertTrue(liveCounter.createOperationIsMerged) // RTLC10b - Should be marked as merged + } + + @Test + fun `(RTLC8, RTLC10, RTLC10a) LiveCounterManager should handle null count in create operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = null // No count specified + ) + + // RTLC10a - Should default to 0 + // RTLC10b - Mark as merged + liveCounterManager.applyOperation(operation, null) + + assertEquals(10.0, liveCounter.data.get()) // No change (null defaults to 0) + assertTrue(liveCounter.createOperationIsMerged) // RTLC10b + } + + @Test + fun `(RTLC7, RTLC7d2, RTLC9) LiveCounterManager should apply counter increment operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = ObjectsCounterOp(amount = 5.0) + ) + + // RTLC7d2 - Apply counter increment operation + liveCounterManager.applyOperation(operation, null) + + assertEquals(15.0, liveCounter.data.get()) // RTLC9b - 10 + 5 + } + + @Test + fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error for missing payload for counter increment operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = null // Missing payload + ) + + // RTLC7d2 - Should throw error for missing payload + val exception = assertFailsWith { + liveCounterManager.applyOperation(operation, null) + } + + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + assertEquals(92000, errorInfo.code) // InvalidObject error code + assertEquals(500, errorInfo.statusCode) // InternalServerError status code + } + + + @Test + fun `(RTLC9, RTLC9b) LiveCounterManager should apply counter increment operation correctly`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val counterOp = ObjectsCounterOp(amount = 7.0) + + // RTLC9b - Apply counter increment + liveCounterManager.applyOperation(ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = counterOp + ), null) + + assertEquals(17.0, liveCounter.data.get()) // 10 + 7 + } + + @Test + fun `(RTLC9, RTLC9b) LiveCounterManager should handle null amount in counter increment`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val counterOp = ObjectsCounterOp(amount = null) // Null amount + + // RTLC9b - Apply counter increment with null amount + liveCounterManager.applyOperation(ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = counterOp + ), null) + + assertEquals(10.0, liveCounter.data.get()) // Should not change (null defaults to 0) + } + + @Test + fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone with serialTimestamp in state`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val expectedTimestamp = 1234567890L + val objectState = ObjectState( + objectId = "testCounterId", + counter = null, // Null counter for tombstone + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = true, // Object is tombstoned + ) + + val update = liveCounterManager.applyState(objectState, expectedTimestamp) + + assertTrue(liveCounter.isTombstoned) // Should be tombstoned + assertEquals(expectedTimestamp, liveCounter.TombstonedAt) // Should use provided timestamp + assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone + + // Assert on update field - should show the change + assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 + } + + @Test + fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone without serialTimestamp in state`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val objectState = ObjectState( + objectId = "testCounterId", + counter = null, // Null counter for tombstone + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = true, // Object is tombstoned + ) + + val beforeOperation = System.currentTimeMillis() + val update = liveCounterManager.applyState(objectState, null) + val afterOperation = System.currentTimeMillis() + + assertTrue(liveCounter.isTombstoned) // Should be tombstoned + assertNotNull(liveCounter.TombstonedAt) // Should have timestamp + assertTrue(liveCounter.TombstonedAt!! >= beforeOperation) // Should be after operation start + assertTrue(liveCounter.TombstonedAt!! <= afterOperation) // Should be before operation end + assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone + + // Assert on update field - should show the change + assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt new file mode 100644 index 000000000..783cfe928 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt @@ -0,0 +1,136 @@ +package io.ably.lib.objects.unit.type.livemap + +import io.ably.lib.objects.ObjectsMapSemantics +import io.ably.lib.objects.ObjectsMap +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.unit.* +import io.ably.lib.types.AblyException +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class DefaultLiveMapTest { + @Test + fun `(RTLM6, RTLM6a) DefaultLiveMap should override serials with state serials from sync`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + // Set initial data + liveMap.siteTimeserials["site1"] = "serial1" + liveMap.siteTimeserials["site2"] = "serial2" + + val objectState = ObjectState( + objectId = "map:testMap@1", + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + ) + ) + + val objectMessage = ObjectMessage( + id = "testId", + objectState = objectState, + serial = "serial1", + siteCode = "site1" + ) + + liveMap.applyObjectSync(objectMessage) + assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveMap.siteTimeserials) // RTLM6a + } + + @Test + fun `(RTLM15, RTLM15a) DefaultLiveMap should check objectId before applying operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@2", // Different objectId + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = emptyMap() + ) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", + siteCode = "site1" + ) + + // RTLM15a - Should throw error when objectId doesn't match + val exception = assertFailsWith { + liveMap.applyObject(message) + } + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + + // Assert on error codes + assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code + assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLM15, RTLM15b) DefaultLiveMap should validate site serial before applying operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + // Set existing site serial that is newer than the incoming message + liveMap.siteTimeserials["site1"] = "serial2" // Newer than "serial1" + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", // Matching objectId + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = emptyMap() + ) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", // Older serial + siteCode = "site1" + ) + + // RTLM15b - Should skip operation when serial is not newer + liveMap.applyObject(message) + + // Verify that the site serial was not updated (operation was skipped) + assertEquals("serial2", liveMap.siteTimeserials["site1"]) + } + + @Test + fun `(RTLM15, RTLM15c) DefaultLiveMap should update site serial if valid`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + // Set existing site serial that is older than the incoming message + liveMap.siteTimeserials["site1"] = "serial1" // Older than "serial2" + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", // Matching objectId + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = emptyMap() + ) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial2", // Newer serial + siteCode = "site1" + ) + + // RTLM15c - Should update site serial when operation is valid + liveMap.applyObject(message) + + // Verify that the site serial was updated + assertEquals("serial2", liveMap.siteTimeserials["site1"]) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt new file mode 100644 index 000000000..8f5e37bbd --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt @@ -0,0 +1,1071 @@ +package io.ably.lib.objects.unit.type.livemap + +import io.ably.lib.objects.* +import io.ably.lib.objects.type.livemap.LiveMapEntry +import io.ably.lib.objects.type.livemap.LiveMapManager +import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.unit.LiveMapManager +import io.ably.lib.objects.unit.TombstonedAt +import io.ably.lib.objects.unit.getDefaultLiveMapWithMockedDeps +import io.ably.lib.types.AblyException +import io.mockk.mockk +import org.junit.Test +import org.junit.Assert.* +import kotlin.test.* + +class LiveMapManagerTest { + + private val livemapManager = LiveMapManager(mockk(relaxed = true)) + + @Test + fun `(RTLM6, RTLM6b, RTLM6c) DefaultLiveMap should override map data with state from sync`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("newValue1")), + timeserial = "serial1" + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("value2")), + timeserial = "serial2" + ) + ) + ), + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState, null) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(2, liveMap.data.size) // RTLM6c + assertEquals("newValue1", liveMap.data["key1"]?.data?.value?.value) // RTLM6c + assertEquals("value2", liveMap.data["key2"]?.data?.value?.value) // RTLM6c + + // Assert on update field - should show changes from old to new state + val expectedUpdate = mapOf( + "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "oldValue" to "newValue1" + "key2" to LiveMapUpdate.Change.UPDATED // key2 was added + ) + assertEquals(expectedUpdate, update.update) + } + + @Test + fun `(RTLM6, RTLM6c) DefaultLiveMap should handle empty map entries in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = emptyMap() // Empty map entries + ), + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState, null) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map + + // Assert on update field - should show that key1 was removed + val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) + assertEquals(expectedUpdate, update.update) + } + + @Test + fun `(RTLM6, RTLM6c) DefaultLiveMap should handle null map in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = null, // Null map + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState, null) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map when map is null + + // Assert on update field - should show that key1 was removed + val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) + assertEquals(expectedUpdate, update.update) + } + + @Test + fun `(RTLM6, RTLM6d) DefaultLiveMap should merge initial data from create operation from state in sync`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val createOp = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("createValue")), + timeserial = "serial1" + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial2" + ) + ) + ) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("stateValue")), + timeserial = "serial3" + ) + ) + ), + createOp = createOp, + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + // RTLM6d - Merge initial data from create operation + val update = liveMapManager.applyState(objectState, null) + + assertEquals(2, liveMap.data.size) // Should have both state and create op entries + assertEquals("stateValue", liveMap.data["key1"]?.data?.value?.value) // State value takes precedence + assertEquals("newValue", liveMap.data["key2"]?.data?.value?.value) // Create op value + + // Assert on update field - should show changes from create operation + val expectedUpdate = mapOf( + "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "existingValue" to "stateValue" + "key2" to LiveMapUpdate.Change.UPDATED // key2 was added from create operation + ) + assertEquals(expectedUpdate, update.update) + } + + @Test + fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries with serialTimestamp in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val expectedTimestamp = 1234567890L + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial1", + tombstone = true, + serialTimestamp = expectedTimestamp + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("value2")), + timeserial = "serial2" + ) + ) + ), + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState, null) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(2, liveMap.data.size) // RTLM6c + assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned + assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp + assertEquals("value2", liveMap.data["key2"]?.data?.value?.value) // RTLM6c + + // Assert on update field - should show that key1 was removed (tombstoned) + val expectedUpdate = mapOf( + "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned + "key2" to LiveMapUpdate.Change.UPDATED // key2 was added + ) + assertEquals(expectedUpdate, update.update) + } + + @Test + fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries without serialTimestamp in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial1", + tombstone = true, + serialTimestamp = null // No timestamp provided + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("value2")), + timeserial = "serial2" + ) + ) + ), + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + val beforeOperation = System.currentTimeMillis() + val update = liveMapManager.applyState(objectState, null) + val afterOperation = System.currentTimeMillis() + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(2, liveMap.data.size) // RTLM6c + assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned + assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp + assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start + assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end + assertEquals("value2", liveMap.data["key2"]?.data?.value?.value) // RTLM6c + + // Assert on update field - should show that key1 was removed (tombstoned) + val expectedUpdate = mapOf( + "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned + "key2" to LiveMapUpdate.Change.UPDATED // key2 was added + ) + assertEquals(expectedUpdate, update.update) + } + + + @Test + fun `(RTLM15, RTLM15d1, RTLM16) LiveMapManager should apply map create operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("value1")), + timeserial = "serial1" + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("value2")), + timeserial = "serial2" + ) + ) + ) + ) + + // RTLM15d1 - Apply map create operation + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals(2, liveMap.data.size) // Should have both entries + assertEquals("value1", liveMap.data["key1"]?.data?.value?.value) // Should have value1 + assertEquals("value2", liveMap.data["key2"]?.data?.value?.value) // Should have value2 + assertTrue(liveMap.createOperationIsMerged) // Should be marked as merged + } + + @Test + fun `(RTLM16, RTLM16d, RTLM17, OME2d) LiveMapManager should merge initial data from create operation with tombstoned entries`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val expectedTimestamp = 1234567890L + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("createValue")), + timeserial = "serial2", + tombstone = true, + serialTimestamp = expectedTimestamp + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial3" + ), + "key3" to ObjectsMapEntry( + data = null, + timeserial = "serial4", + tombstone = true + ) + ) + ) + ) + + // RTLM16d - Merge initial data from create operation + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals(3, liveMap.data.size) // Should have all entries + assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM17a2 - Should be tombstoned + assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp + assertEquals("newValue", liveMap.data["key2"]?.data?.value?.value) // RTLM17a1 - Should be added + assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM17a2 - Should be tombstoned + assertTrue(liveMap.createOperationIsMerged) // RTLM17b - Should be marked as merged + } + + @Test + fun `(RTLM15, RTLM15d2, RTLM7) LiveMapManager should apply map set operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM15d2 - Apply map set operation + liveMapManager.applyOperation(operation, "serial2", null) + + assertEquals("newValue", liveMap.data["key1"]?.data?.value?.value) // RTLM7a2a + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM7a2b + assertFalse(liveMap.data["key1"]?.isTombstoned == true) // RTLM7a2c + } + + @Test + fun `(RTLM15, RTLM15d3, RTLM8) LiveMapManager should apply map remove operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp(key = "key1") + ) + + val expectedTimestamp = 1234567890L + // RTLM15d3 - Apply map remove operation with provided timestamp + liveMapManager.applyOperation(operation, "serial2", expectedTimestamp) + + assertNull(liveMap.data["key1"]?.data) // RTLM8a2a + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b + assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c + assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // RTLM8c3 - Should use provided timestamp + } + + @Test + fun `(RTLM8, RTLM8c3, OME2d) LiveMapManager should use current time when no timestamp provided for map remove operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp(key = "key1") + ) + + val beforeOperation = System.currentTimeMillis() + // RTLM8c3 - Apply map remove operation without timestamp (should use current time) + liveMapManager.applyOperation(operation, "serial2", null) + val afterOperation = System.currentTimeMillis() + + assertNull(liveMap.data["key1"]?.data) // RTLM8a2a + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b + assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c + assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp + assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start + assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end + } + + @Test + fun `(RTLM15, RTLM15d4) LiveMapManager should throw error for unsupported action`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, // Unsupported action for map + objectId = "map:testMap@1", + counter = ObjectsCounter(count = 20.0) + ) + + // RTLM15d4 - Should throw error for unsupported action + val exception = assertFailsWith { + liveMapManager.applyOperation(operation, "serial1", null) + } + + val errorInfo = exception.errorInfo + assertNotNull(errorInfo, "Error info should not be null") + assertEquals(92000, errorInfo?.code) // InvalidObject error code + assertEquals(500, errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLM16, RTLM16b) LiveMapManager should skip map create operation if already merged`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set create operation as already merged + liveMap.createOperationIsMerged = true + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("value1")), + timeserial = "serial1" + ) + ) + ) + ) + + // RTLM16b - Should skip if already merged + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals(0, liveMap.data.size) // Should not change (still empty) + assertTrue(liveMap.createOperationIsMerged) // Should remain merged + } + + + + @Test + fun `(RTLM16, RTLM16d, RTLM17) LiveMapManager should merge initial data from create operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.LWW, + entries = mapOf( + "key1" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("createValue")), + timeserial = "serial2" + ), + "key2" to ObjectsMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial3" + ), + "key3" to ObjectsMapEntry( + data = null, + timeserial = "serial4", + tombstone = true + ) + ) + ) + ) + + // RTLM16d - Merge initial data from create operation + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals(3, liveMap.data.size) // Should have all entries + assertEquals("createValue", liveMap.data["key1"]?.data?.value?.value) // RTLM17a1 - Should be updated + assertEquals("newValue", liveMap.data["key2"]?.data?.value?.value) // RTLM17a1 - Should be added + assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM17a2 - Should be tombstoned + assertTrue(liveMap.createOperationIsMerged) // RTLM17b - Should be marked as merged + } + + @Test + fun `(RTLM7, RTLM7b) LiveMapManager should create new entry for map set operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "newKey", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM7b - Create new entry + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals(1, liveMap.data.size) // Should have one entry + assertEquals("newValue", liveMap.data["newKey"]?.data?.value?.value) // RTLM7b1 + assertEquals("serial1", liveMap.data["newKey"]?.timeserial) // Should have serial + assertFalse(liveMap.data["newKey"]?.isTombstoned == true) // RTLM7b2 + } + + @Test + fun `(RTLM7, RTLM7a) LiveMapManager should skip map set operation with lower serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with higher serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial2", // Higher than "serial1" + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM7a - Should skip operation with lower serial + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial + } + + @Test + fun `(RTLM8, RTLM8b) LiveMapManager should create tombstoned entry for map remove operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp(key = "nonExistingKey") + ) + + // RTLM8b - Create tombstoned entry for non-existing key + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals(1, liveMap.data.size) // Should have one entry + assertNull(liveMap.data["nonExistingKey"]?.data) // RTLM8b1 + assertEquals("serial1", liveMap.data["nonExistingKey"]?.timeserial) // Should have serial + assertTrue(liveMap.data["nonExistingKey"]?.isTombstoned == true) // RTLM8b2 + } + + @Test + fun `(RTLM8, RTLM8a) LiveMapManager should skip map remove operation with lower serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with higher serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial2", // Higher than "serial1" + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp(key = "key1") + ) + + // RTLM8a - Should skip operation with lower serial + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial + assertFalse(liveMap.data["key1"]?.isTombstoned == true) // Should not be tombstoned + } + + @Test + fun `(RTLM9, RTLM9b) LiveMapManager should handle null serials correctly`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with null serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = null, + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9b - Both null serials should be treated as equal + liveMapManager.applyOperation(operation, null, null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + } + + @Test + fun `(RTLM9, RTLM9d) LiveMapManager should apply operation with serial when entry has null serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with null serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = null, + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9d - Operation serial is greater than missing entry serial + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals("newValue", liveMap.data["key1"]?.data?.value?.value) // Should be updated + assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should have new serial + } + + @Test + fun `(RTLM9, RTLM9c) LiveMapManager should skip operation with null serial when entry has serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9c - Missing operation serial is lower than existing entry serial + liveMapManager.applyOperation(operation, null, null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should keep original serial + } + + @Test + fun `(RTLM9, RTLM9e) LiveMapManager should apply operation with higher serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with lower serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9e - Higher serial should be applied + liveMapManager.applyOperation(operation, "serial2", null) + + assertEquals("newValue", liveMap.data["key1"]?.data?.value?.value) // Should be updated + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should have new serial + } + + @Test + fun `(RTLM9, RTLM9e) LiveMapManager should skip operation with lower serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with higher serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial2", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectsMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9e - Lower serial should be skipped + liveMapManager.applyOperation(operation, "serial1", null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial + } + + @Test + fun `(RTLM16, RTLM16c) DefaultLiveMap should throw error for mismatched semantics`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectsMap( + semantics = ObjectsMapSemantics.Unknown, // This should match, but we'll test error case + entries = emptyMap() + ) + ) + + val exception = assertFailsWith { + liveMapManager.applyOperation(operation, "serial1", null) + } + + val errorInfo = exception.errorInfo + kotlin.test.assertNotNull(errorInfo, "Error info should not be null") // RTLM16c + + // Assert on error codes + kotlin.test.assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code + kotlin.test.assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun shouldCalculateMapDifferenceCorrectly() { + // Test case 1: No changes + val prevData1 = mapOf() + val newData1 = mapOf() + val result1 = livemapManager.calculateUpdateFromDataDiff(prevData1, newData1) + assertEquals(emptyMap(), result1.update, "Should return empty map for no changes") + + // Test case 2: Entry added + val prevData2 = mapOf() + val newData2 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result2 = livemapManager.calculateUpdateFromDataDiff(prevData2, newData2) + assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result2.update, "Should detect added entry") + + // Test case 3: Entry removed + val prevData3 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData3 = mapOf() + val result3 = livemapManager.calculateUpdateFromDataDiff(prevData3, newData3) + assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result3.update, "Should detect removed entry") + + // Test case 4: Entry updated + val prevData4 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData4 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value2")) + ) + ) + val result4 = livemapManager.calculateUpdateFromDataDiff(prevData4, newData4) + assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result4.update, "Should detect updated entry") + + // Test case 5: Entry tombstoned + val prevData5 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData5 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "2", + data = null + ) + ) + val result5 = livemapManager.calculateUpdateFromDataDiff(prevData5, newData5) + assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result5.update, "Should detect tombstoned entry") + + // Test case 6: Entry untombstoned + val prevData6 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "1", + data = null + ) + ) + val newData6 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result6 = livemapManager.calculateUpdateFromDataDiff(prevData6, newData6) + assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result6.update, "Should detect untombstoned entry") + + // Test case 7: Both entries tombstoned (noop) + val prevData7 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "1", + data = null + ) + ) + val newData7 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result7 = livemapManager.calculateUpdateFromDataDiff(prevData7, newData7) + assertEquals(emptyMap(), result7.update, "Should not detect change for both tombstoned entries") + + // Test case 8: New tombstoned entry (noop) + val prevData8 = mapOf() + val newData8 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "1", + data = null + ) + ) + val result8 = livemapManager.calculateUpdateFromDataDiff(prevData8, newData8) + assertEquals(emptyMap(), result8.update, "Should not detect change for new tombstoned entry") + + // Test case 9: Multiple changes + val prevData9 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ), + "key2" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value2")) + ) + ) + val newData9 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1_updated")) + ), + "key3" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value3")) + ) + ) + val result9 = livemapManager.calculateUpdateFromDataDiff(prevData9, newData9) + val expected9 = mapOf( + "key1" to LiveMapUpdate.Change.UPDATED, + "key2" to LiveMapUpdate.Change.REMOVED, + "key3" to LiveMapUpdate.Change.UPDATED + ) + assertEquals(expected9, result9.update, "Should detect multiple changes correctly") + + // Test case 10: ObjectId references + val prevData10 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(objectId = "obj1") + ) + ) + val newData10 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(objectId = "obj2") + ) + ) + val result10 = livemapManager.calculateUpdateFromDataDiff(prevData10, newData10) + assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result10.update, "Should detect objectId change") + + // Test case 11: Same data, no change + val prevData11 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData11 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result11 = livemapManager.calculateUpdateFromDataDiff(prevData11, newData11) + assertEquals(emptyMap(), result11.update, "Should not detect change for same data") + } + + @Test + fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone with serialTimestamp in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val expectedTimestamp = 1234567890L + val objectState = ObjectState( + objectId = "map:testMap@1", + map = null, // Null map for tombstone + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = true, // Object is tombstoned + ) + + val update = liveMapManager.applyState(objectState, expectedTimestamp) + + assertTrue(liveMap.isTombstoned) // Should be tombstoned + assertEquals(expectedTimestamp, liveMap.TombstonedAt) // Should use provided timestamp + assertEquals(0, liveMap.data.size) // Should be empty after tombstone + + // Assert on update field - should show that key1 was removed + val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) + assertEquals(expectedUpdate, update.update) + } + + @Test + fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone without serialTimestamp in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = null, // Null map for tombstone + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = true, // Object is tombstoned + ) + + val beforeOperation = System.currentTimeMillis() + val update = liveMapManager.applyState(objectState, null) + val afterOperation = System.currentTimeMillis() + + assertTrue(liveMap.isTombstoned) // Should be tombstoned + assertNotNull(liveMap.TombstonedAt) // Should have timestamp + assertTrue(liveMap.TombstonedAt!! >= beforeOperation) // Should be after operation start + assertTrue(liveMap.TombstonedAt!! <= afterOperation) // Should be before operation end + assertEquals(0, liveMap.data.size) // Should be empty after tombstone + + // Assert on update field - should show that key1 was removed + val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) + assertEquals(expectedUpdate, update.update) + } +} diff --git a/network-client-core/build.gradle.kts b/network-client-core/build.gradle.kts new file mode 100644 index 000000000..f7bb62dd6 --- /dev/null +++ b/network-client-core/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + `java-library` + alias(libs.plugins.lombok) + alias(libs.plugins.maven.publish) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} diff --git a/network-client-core/gradle.properties b/network-client-core/gradle.properties new file mode 100644 index 000000000..f37ee24fe --- /dev/null +++ b/network-client-core/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=network-client-core +POM_NAME=Core HTTP client abstraction +POM_DESCRIPTION=Core HTTP client abstraction +POM_PACKAGING=jar diff --git a/network-client-core/src/main/java/io/ably/lib/network/EngineType.java b/network-client-core/src/main/java/io/ably/lib/network/EngineType.java new file mode 100644 index 000000000..d3984de23 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/EngineType.java @@ -0,0 +1,6 @@ +package io.ably.lib.network; + +public enum EngineType { + DEFAULT, + OKHTTP +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/FailedConnectionException.java b/network-client-core/src/main/java/io/ably/lib/network/FailedConnectionException.java new file mode 100644 index 000000000..cc226716a --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/FailedConnectionException.java @@ -0,0 +1,7 @@ +package io.ably.lib.network; + +public class FailedConnectionException extends RuntimeException { + public FailedConnectionException(Throwable cause) { + super(cause); + } +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpBody.java b/network-client-core/src/main/java/io/ably/lib/network/HttpBody.java new file mode 100644 index 000000000..00102dbc5 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpBody.java @@ -0,0 +1,14 @@ +package io.ably.lib.network; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Setter; + +@Data +@Setter(AccessLevel.NONE) +@AllArgsConstructor +public class HttpBody { + private final String contentType; + private final byte[] content; +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpCall.java b/network-client-core/src/main/java/io/ably/lib/network/HttpCall.java new file mode 100644 index 000000000..87e77aa40 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpCall.java @@ -0,0 +1,18 @@ +package io.ably.lib.network; + +/** + * Cancelable Http request call + *

+ * Implementation should be thread-safe + */ +public interface HttpCall { + /** + * Synchronously execute Http request and return response from te server + */ + HttpResponse execute(); + + /** + * Cancel pending Http request + */ + void cancel(); +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpEngine.java b/network-client-core/src/main/java/io/ably/lib/network/HttpEngine.java new file mode 100644 index 000000000..eae17fd4a --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpEngine.java @@ -0,0 +1,18 @@ +package io.ably.lib.network; + +/** + * An HTTP engine instance that can make cancelable HTTP requests. + * It contains some engine-wide configurations, such as proxy settings, + * if it operates under a corporate proxy. + */ +public interface HttpEngine { + /** + * @return cancelable Http request call + */ + HttpCall call(HttpRequest request); + + /** + * @return true if it uses proxy, false otherwise + */ + boolean isUsingProxy(); +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpEngineConfig.java b/network-client-core/src/main/java/io/ably/lib/network/HttpEngineConfig.java new file mode 100644 index 000000000..e19c63029 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpEngineConfig.java @@ -0,0 +1,15 @@ +package io.ably.lib.network; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Setter; + +@Data +@Setter(AccessLevel.NONE) +@Builder +@AllArgsConstructor +public class HttpEngineConfig { + private final ProxyConfig proxy; +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpEngineFactory.java b/network-client-core/src/main/java/io/ably/lib/network/HttpEngineFactory.java new file mode 100644 index 000000000..e388064a0 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpEngineFactory.java @@ -0,0 +1,44 @@ +package io.ably.lib.network; + +import java.lang.reflect.InvocationTargetException; + +/** + * The HttpEngineFactory is a utility class that produces a common HTTP Engine API + * for different implementations. Currently, it supports: + * - HttpURLConnection ({@link EngineType#DEFAULT}) + * - OkHttp ({@link EngineType#OKHTTP}) + */ +public interface HttpEngineFactory { + + static HttpEngineFactory getFirstAvailable() { + HttpEngineFactory okHttpFactory = tryGetOkHttpFactory(); + if (okHttpFactory != null) return okHttpFactory; + HttpEngineFactory defaultFactory = tryGetDefaultFactory(); + if (defaultFactory != null) return defaultFactory; + throw new IllegalStateException("No engines are available"); + } + + static HttpEngineFactory tryGetOkHttpFactory() { + try { + Class okHttpFactoryClass = Class.forName("io.ably.lib.network.OkHttpEngineFactory"); + return (HttpEngineFactory) okHttpFactoryClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + return null; + } + } + + static HttpEngineFactory tryGetDefaultFactory() { + try { + Class defaultFactoryClass = Class.forName("io.ably.lib.network.DefaultHttpEngineFactory"); + return (HttpEngineFactory) defaultFactoryClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + return null; + } + } + + HttpEngine create(HttpEngineConfig config); + + EngineType getEngineType(); +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpRequest.java b/network-client-core/src/main/java/io/ably/lib/network/HttpRequest.java new file mode 100644 index 000000000..361506ccb --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpRequest.java @@ -0,0 +1,100 @@ +package io.ably.lib.network; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +@Setter(AccessLevel.NONE) +@AllArgsConstructor +public class HttpRequest { + + public static final String CONTENT_LENGTH = "Content-Length"; + public static final String CONTENT_TYPE = "Content-Type"; + + private final URL url; + private final String method; + private final int httpOpenTimeout; + private final int httpReadTimeout; + private final HttpBody body; + @Getter(AccessLevel.NONE) + private final Map> headers; + + public Map> getHeaders() { + Map> headersCopy = new HashMap<>(headers); + if (body != null) { + int length = body.getContent() == null ? 0 : body.getContent().length; + headersCopy.put(CONTENT_TYPE, Collections.singletonList(body.getContentType())); + headersCopy.put(CONTENT_LENGTH, Collections.singletonList(Integer.toString(length))); + } + return headersCopy; + } + + public static HttpRequestBuilder builder() { + return new HttpRequestBuilder(); + } + + public static class HttpRequestBuilder { + private URL url; + private String method; + private int httpOpenTimeout; + private int httpReadTimeout; + private HttpBody body; + private Map> headers; + + HttpRequestBuilder() { + } + + public HttpRequestBuilder url(URL url) { + this.url = url; + return this; + } + + public HttpRequestBuilder method(String method) { + this.method = method; + return this; + } + + public HttpRequestBuilder httpOpenTimeout(int httpOpenTimeout) { + this.httpOpenTimeout = httpOpenTimeout; + return this; + } + + public HttpRequestBuilder httpReadTimeout(int httpReadTimeout) { + this.httpReadTimeout = httpReadTimeout; + return this; + } + + public HttpRequestBuilder body(HttpBody body) { + this.body = body; + return this; + } + + public HttpRequestBuilder headers(Map headers) { + Map> result = new HashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + result.put(key, Collections.singletonList(value)); + } + this.headers = Collections.unmodifiableMap(result); + return this; + } + + public HttpRequest build() { + return new HttpRequest(this.url, this.method, this.httpOpenTimeout, this.httpReadTimeout, this.body, this.headers); + } + + public String toString() { + return "HttpRequest.HttpRequestBuilder(url=" + this.url + ", method=" + this.method + ", httpOpenTimeout=" + this.httpOpenTimeout + ", httpReadTimeout=" + this.httpReadTimeout + ", body=" + this.body + ", headers=" + this.headers + ")"; + } + } +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/HttpResponse.java b/network-client-core/src/main/java/io/ably/lib/network/HttpResponse.java new file mode 100644 index 000000000..e2cf4103c --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpResponse.java @@ -0,0 +1,21 @@ +package io.ably.lib.network; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +@Data +@Setter(AccessLevel.NONE) +@Builder +@AllArgsConstructor +public class HttpResponse { + private final int code; + private final String message; + private final HttpBody body; + private final Map> headers; +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/NotConnectedException.java b/network-client-core/src/main/java/io/ably/lib/network/NotConnectedException.java new file mode 100644 index 000000000..166549e81 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/NotConnectedException.java @@ -0,0 +1,7 @@ +package io.ably.lib.network; + +public class NotConnectedException extends RuntimeException { + public NotConnectedException(Throwable cause) { + super(cause); + } +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/ProxyAuthType.java b/network-client-core/src/main/java/io/ably/lib/network/ProxyAuthType.java new file mode 100644 index 000000000..ca4cb57a5 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/ProxyAuthType.java @@ -0,0 +1,6 @@ +package io.ably.lib.network; + +public enum ProxyAuthType { + BASIC, + DIGEST +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/ProxyConfig.java b/network-client-core/src/main/java/io/ably/lib/network/ProxyConfig.java new file mode 100644 index 000000000..8b87a6846 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/ProxyConfig.java @@ -0,0 +1,22 @@ +package io.ably.lib.network; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Setter; + +import java.util.List; + +@Data +@Setter(AccessLevel.NONE) +@Builder +@AllArgsConstructor +public class ProxyConfig { + private String host; + private int port; + private String username; + private String password; + private List nonProxyHosts; + private ProxyAuthType authType; +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketClient.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketClient.java new file mode 100644 index 000000000..9452fc132 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketClient.java @@ -0,0 +1,50 @@ +package io.ably.lib.network; + +/** + * WebSocketClient instance bind to the specified URI. + * The connection will be established once you call connect. + */ +public interface WebSocketClient { + + /** + * Establish connection to the Websocket server + */ + void connect(); + + /** + * Sends the closing handshake. May be sent in response to any other handshake. + */ + void close(); + + /** + * Sends the closing handshake. May be sent in response to any other handshake. + * + * @param code the closing code + * @param reason the closing message + */ + void close(int code, String reason); + + /** + * This will close the connection immediately without a proper close handshake. The code and the + * message therefore won't be transferred over the wire also they will be forwarded to `onClose`. + * + * @param code the closing code + * @param reason the closing message + **/ + void cancel(int code, String reason); + + /** + * Sends binary message to the connected webSocket server. + * + * @param message The byte-Array of data to send to the WebSocket server. + */ + void send(byte[] message); + + /** + * Sends message to the connected websocket server. + * + * @param message The string which will be transmitted. + */ + void send(String message); + +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java new file mode 100644 index 000000000..af29dce14 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java @@ -0,0 +1,9 @@ +package io.ably.lib.network; + +/** + * Create WebSocket client bind to the specific URL + */ +public interface WebSocketEngine { + WebSocketClient create(String url, WebSocketListener listener); + boolean isPingListenerSupported(); +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineConfig.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineConfig.java new file mode 100644 index 000000000..b294c58c0 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineConfig.java @@ -0,0 +1,20 @@ +package io.ably.lib.network; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Setter; + +import javax.net.ssl.SSLSocketFactory; + +@Data +@Setter(AccessLevel.NONE) +@Builder +@AllArgsConstructor +public class WebSocketEngineConfig { + private final ProxyConfig proxy; + private final boolean tls; + private final String host; + private final SSLSocketFactory sslSocketFactory; +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java new file mode 100644 index 000000000..c5693d655 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java @@ -0,0 +1,43 @@ +package io.ably.lib.network; + +import java.lang.reflect.InvocationTargetException; + +/** + * The WebSocketEngineFactory is a utility class that produces a common WebSocket Engine API + * for different implementations. Currently, it supports: + * - TooTallNate/Java-WebSocket ({@link EngineType#DEFAULT}) + * - OkHttp ({@link EngineType#OKHTTP}) + */ +public interface WebSocketEngineFactory { + static WebSocketEngineFactory getFirstAvailable() { + WebSocketEngineFactory okWebSocketFactory = tryGetOkWebSocketFactory(); + if (okWebSocketFactory != null) return okWebSocketFactory; + WebSocketEngineFactory defaultFactory = tryGetDefaultFactory(); + if (defaultFactory != null) return defaultFactory; + throw new IllegalStateException("No engines are available"); + } + + static WebSocketEngineFactory tryGetOkWebSocketFactory() { + try { + Class okWebSocketFactoryClass = Class.forName("io.ably.lib.network.OkHttpWebSocketEngineFactory"); + return (WebSocketEngineFactory) okWebSocketFactoryClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + return null; + } + } + + static WebSocketEngineFactory tryGetDefaultFactory() { + try { + Class defaultFactoryClass = Class.forName("io.ably.lib.network.DefaultWebSocketEngineFactory"); + return (WebSocketEngineFactory) defaultFactoryClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + return null; + } + } + + WebSocketEngine create(WebSocketEngineConfig config); + + EngineType getEngineType(); +} diff --git a/network-client-core/src/main/java/io/ably/lib/network/WebSocketListener.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketListener.java new file mode 100644 index 000000000..003d2a7bf --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketListener.java @@ -0,0 +1,57 @@ +package io.ably.lib.network; + +import java.nio.ByteBuffer; + +/** + * WebSocket Listener + */ +public interface WebSocketListener { + /** + * Called after an opening handshake has been performed and the given websocket is ready to be + * written on. + */ + void onOpen(); + + /** + * Callback for binary messages received from the remote host + * + * @param blob The binary message that was received. + * @see #onMessage(String) + **/ + void onMessage(ByteBuffer blob); + + /** + * Callback for string messages received from the remote host + * + * @param string The UTF-8 decoded message that was received. + * @see #onMessage(ByteBuffer) + **/ + void onMessage(String string); + + /** + * Callback for receiving ping frame if it supported by websocket engine + */ + void onWebsocketPing(); + + /** + * Called after the websocket connection has been closed. + * + * @param reason Additional information string + **/ + void onClose(int code, String reason); + + /** + * Called when errors occurs. If an error causes the websocket connection to fail {@link + * WebSocketListener#onClose(int, String)} will be called additionally.
This method will be called + * primarily because of IO or protocol errors.
If the given exception is an RuntimeException + * that probably means that you encountered a bug.
+ * + * @param throwable The exception causing this error + **/ + void onError(Throwable throwable); + + /** + * We invoke this callback when runtime is not able to use secure https algorithms (TLS 1.2 +) + */ + void onOldJavaVersionDetected(Throwable throwable); +} diff --git a/network-client-default/build.gradle.kts b/network-client-default/build.gradle.kts new file mode 100644 index 000000000..9b19b174f --- /dev/null +++ b/network-client-default/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + `java-library` + alias(libs.plugins.lombok) + alias(libs.plugins.maven.publish) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation(project(":network-client-core")) + implementation(libs.java.websocket) +} diff --git a/network-client-default/gradle.properties b/network-client-default/gradle.properties new file mode 100644 index 000000000..a56c963cb --- /dev/null +++ b/network-client-default/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=network-client-default +POM_NAME=Default HTTP client +POM_DESCRIPTION=Default implementation for HTTP client +POM_PACKAGING=jar diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpCall.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpCall.java new file mode 100644 index 000000000..bfc3a78d0 --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpCall.java @@ -0,0 +1,165 @@ +package io.ably.lib.network; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.NoRouteToHostException; +import java.net.Proxy; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +class DefaultHttpCall implements HttpCall { + private final Proxy proxy; + private final HttpRequest request; + private HttpURLConnection connection; + + DefaultHttpCall(HttpRequest request, Proxy proxy) { + this.request = request; + this.proxy = proxy; + } + + @Override + public HttpResponse execute() { + URL url = request.getUrl(); + try { + connection = (HttpURLConnection) url.openConnection(proxy); + /* prepare connection */ + connection.setRequestMethod(request.getMethod()); + connection.setConnectTimeout(request.getHttpOpenTimeout()); + connection.setReadTimeout(request.getHttpReadTimeout()); + connection.setDoInput(true); + + for (Map.Entry> entry : request.getHeaders().entrySet()) { + String headerName = entry.getKey(); + List values = entry.getValue(); + for (String headerValue : values) { + connection.setRequestProperty(headerName, headerValue); + } + } + + /* prepare request body */ + if (request.getBody() != null) { + byte[] body = prepareRequestBody(request.getBody()); + writeRequestBody(body); + } + + return readResponse(); + } catch (ConnectException | SocketTimeoutException | UnknownHostException | NoRouteToHostException fce) { + throw new FailedConnectionException(fce); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } finally { + cancel(); + } + } + + @Override + public void cancel() { + if (connection != null) { + connection.disconnect(); + } + } + + /** + * Emit the request body for an HTTP request + */ + private byte[] prepareRequestBody(HttpBody requestBody) throws IOException { + connection.setDoOutput(true); + byte[] body = requestBody.getContent(); + int length = body.length; + connection.setFixedLengthStreamingMode(length); + return body; + } + + + private void writeRequestBody(byte[] body) throws IOException { + OutputStream os = connection.getOutputStream(); + os.write(body); + } + + private HttpResponse readResponse() throws IOException { + HttpResponse.HttpResponseBuilder builder = HttpResponse.builder(); + int statusCode = connection.getResponseCode(); + + builder + .code(statusCode) + .message(connection.getResponseMessage()); + + /* Store all header field names in lower-case to eliminate case insensitivity */ + Map> caseSensitiveHeaders = connection.getHeaderFields(); + Map> headers = new HashMap<>(caseSensitiveHeaders.size(), 1f); + + for (Map.Entry> entry : caseSensitiveHeaders.entrySet()) { + if (entry.getKey() != null) { + headers.put(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue()); + } + } + + builder.headers(headers); + + if (statusCode == HttpURLConnection.HTTP_NO_CONTENT) { + return builder.build(); + } + + String contentType = connection.getContentType(); + int contentLength = connection.getContentLength(); + + InputStream is = null; + try { + is = connection.getInputStream(); + } catch (Throwable ignored) {} + + if (is == null) is = connection.getErrorStream(); + + try { + byte[] body = readInputStream(is, contentLength); + builder.body(new HttpBody(contentType, body)); + } catch (NullPointerException e) { + /* nothing to read */ + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + + return builder.build(); + } + + private byte[] readInputStream(InputStream inputStream, int bytes) throws IOException { + /* If there is nothing to read */ + if (inputStream == null) { + throw new NullPointerException("inputStream == null"); + } + + int bytesRead = 0; + + if (bytes == -1) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4 * 1024]; + while ((bytesRead = inputStream.read(buffer)) > -1) { + outputStream.write(buffer, 0, bytesRead); + } + + return outputStream.toByteArray(); + } else { + int idx = 0; + byte[] output = new byte[bytes]; + while ((bytesRead = inputStream.read(output, idx, bytes - idx)) > -1) { + idx += bytesRead; + } + + return output; + } + } +} diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngine.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngine.java new file mode 100644 index 000000000..e61b58d95 --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngine.java @@ -0,0 +1,26 @@ +package io.ably.lib.network; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +public class DefaultHttpEngine implements HttpEngine { + + private final HttpEngineConfig config; + + public DefaultHttpEngine(HttpEngineConfig config) { + this.config = config; + } + + @Override + public HttpCall call(HttpRequest request) { + Proxy proxy = isUsingProxy() + ? new Proxy(Proxy.Type.HTTP, new InetSocketAddress(config.getProxy().getHost(), config.getProxy().getPort())) + : Proxy.NO_PROXY; + return new DefaultHttpCall(request, proxy); + } + + @Override + public boolean isUsingProxy() { + return config.getProxy() != null; + } +} diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngineFactory.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngineFactory.java new file mode 100644 index 000000000..533f06d91 --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngineFactory.java @@ -0,0 +1,14 @@ +package io.ably.lib.network; + +public class DefaultHttpEngineFactory implements HttpEngineFactory { + + @Override + public HttpEngine create(HttpEngineConfig config) { + return new DefaultHttpEngine(config); + } + + @Override + public EngineType getEngineType() { + return EngineType.DEFAULT; + } +} diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketClient.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketClient.java new file mode 100644 index 000000000..3cd4b068e --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketClient.java @@ -0,0 +1,106 @@ +package io.ably.lib.network; + +import org.java_websocket.WebSocket; +import org.java_websocket.exceptions.WebsocketNotConnectedException; +import org.java_websocket.framing.Framedata; +import org.java_websocket.handshake.ServerHandshake; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import java.net.URI; +import java.nio.ByteBuffer; + +public class DefaultWebSocketClient extends org.java_websocket.client.WebSocketClient implements WebSocketClient { + + private final WebSocketListener listener; + private final WebSocketEngineConfig config; + + private boolean shouldExplicitlyVerifyHostname = true; + + public DefaultWebSocketClient(URI serverUri, WebSocketListener listener, WebSocketEngineConfig config) { + super(serverUri); + this.listener = listener; + this.config = config; + } + + @Override + public void onOpen(ServerHandshake serverHandshake) { + if (config.isTls() && shouldExplicitlyVerifyHostname && !isHostnameVerified(config.getHost())) { + close(); + } else { + listener.onOpen(); + } + } + + @Override + public void onMessage(String s) { + listener.onMessage(s); + } + + @Override + public void onMessage(ByteBuffer blob) { + listener.onMessage(blob); + } + + /* This allows us to detect a websocket ping, so we don't need Ably pings. */ + @Override + public void onWebsocketPing(WebSocket conn, Framedata f) { + /* Call superclass to ensure the pong is sent. */ + super.onWebsocketPing(conn, f); + listener.onWebsocketPing(); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + listener.onClose(code, reason); + } + + @Override + public void onError(Exception e) { + listener.onError(e); + } + + @Override + public void cancel(int code, String reason) { + closeConnection(code, reason); + } + + @Override + protected void onSetSSLParameters(SSLParameters sslParameters) { + try { + super.onSetSSLParameters(sslParameters); + shouldExplicitlyVerifyHostname = false; + } catch (NoSuchMethodError exception) { + // This error will be thrown on Android below level 24. + // When the minSdkVersion will be updated to 24 we should remove this overridden method. + // https://github.com/TooTallNate/Java-WebSocket/wiki/No-such-method-error-setEndpointIdentificationAlgorithm#workaround + shouldExplicitlyVerifyHostname = true; + listener.onOldJavaVersionDetected(exception); + } + } + + @Override + public void send(String text) { + try { + super.send(text); + } catch (WebsocketNotConnectedException e) { + throw new NotConnectedException(e); + } + } + + /** + * Added because we had to override the onSetSSLParameters() that usually performs this verification. + * When the minSdkVersion will be updated to 24 we should remove this method and its usages. + * https://github.com/TooTallNate/Java-WebSocket/wiki/No-such-method-error-setEndpointIdentificationAlgorithm#workaround + */ + private boolean isHostnameVerified(String hostname) { + final SSLSession session = getSSLSession(); + if (HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session)) { + return true; + } else { + listener.onError(new IllegalArgumentException("Hostname verification failed, expected " + hostname + ", found " + session.getPeerHost())); + return false; + } + } +} diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java new file mode 100644 index 000000000..a73f9f580 --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java @@ -0,0 +1,25 @@ +package io.ably.lib.network; + +import java.net.URI; + +public class DefaultWebSocketEngine implements WebSocketEngine { + private final WebSocketEngineConfig config; + + public DefaultWebSocketEngine(WebSocketEngineConfig config) { + this.config = config; + } + + @Override + public WebSocketClient create(String url, WebSocketListener listener) { + DefaultWebSocketClient client = new DefaultWebSocketClient(URI.create(url), listener, config); + if (config.isTls()) { + client.setSocketFactory(config.getSslSocketFactory()); + } + return client; + } + + @Override + public boolean isPingListenerSupported() { + return true; + } +} diff --git a/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngineFactory.java b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngineFactory.java new file mode 100644 index 000000000..48b564e2c --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngineFactory.java @@ -0,0 +1,14 @@ +package io.ably.lib.network; + +public class DefaultWebSocketEngineFactory implements WebSocketEngineFactory { + + @Override + public WebSocketEngine create(WebSocketEngineConfig config) { + return new DefaultWebSocketEngine(config); + } + + @Override + public EngineType getEngineType() { + return EngineType.DEFAULT; + } +} diff --git a/network-client-okhttp/build.gradle.kts b/network-client-okhttp/build.gradle.kts new file mode 100644 index 000000000..7e3118764 --- /dev/null +++ b/network-client-okhttp/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + `java-library` + alias(libs.plugins.lombok) + alias(libs.plugins.maven.publish) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation(project(":network-client-core")) + implementation(libs.okhttp) +} diff --git a/network-client-okhttp/gradle.properties b/network-client-okhttp/gradle.properties new file mode 100644 index 000000000..4b648381c --- /dev/null +++ b/network-client-okhttp/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=network-client-okhttp +POM_NAME=Default HTTP client +POM_DESCRIPTION=Default implementation for HTTP client +POM_PACKAGING=jar diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpCall.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpCall.java new file mode 100644 index 000000000..643697391 --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpCall.java @@ -0,0 +1,45 @@ +package io.ably.lib.network; + +import okhttp3.Call; +import okhttp3.Response; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; + +public class OkHttpCall implements HttpCall { + private final Call call; + + public OkHttpCall(Call call) { + this.call = call; + } + + @Override + public HttpResponse execute() { + try (Response response = call.execute()) { + return HttpResponse.builder() + .headers(response.headers().toMultimap()) + .code(response.code()) + .message(response.message()) + .body( + response.body() != null && response.body().contentType() != null + ? new HttpBody(response.body().contentType().toString(), response.body().bytes()) + : null + ) + .build(); + + } catch (ConnectException | SocketTimeoutException | UnknownHostException | NoRouteToHostException fce) { + throw new FailedConnectionException(fce); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + + } + + @Override + public void cancel() { + call.cancel(); + } +} diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngine.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngine.java new file mode 100644 index 000000000..50faa3610 --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngine.java @@ -0,0 +1,32 @@ +package io.ably.lib.network; + +import okhttp3.Call; +import okhttp3.OkHttpClient; + +import java.util.concurrent.TimeUnit; + +public class OkHttpEngine implements HttpEngine { + + private final OkHttpClient client; + private final HttpEngineConfig config; + + public OkHttpEngine(OkHttpClient client, HttpEngineConfig config) { + this.client = client; + this.config = config; + } + + @Override + public HttpCall call(HttpRequest request) { + Call call = client.newBuilder() + .connectTimeout(request.getHttpOpenTimeout(), TimeUnit.MILLISECONDS) + .readTimeout(request.getHttpReadTimeout(), TimeUnit.MILLISECONDS) + .build() + .newCall(OkHttpUtils.toOkhttpRequest(request)); + return new OkHttpCall(call); + } + + @Override + public boolean isUsingProxy() { + return config.getProxy() != null; + } +} diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngineFactory.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngineFactory.java new file mode 100644 index 000000000..2cf65a9a8 --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpEngineFactory.java @@ -0,0 +1,17 @@ +package io.ably.lib.network; + +import okhttp3.OkHttpClient; + +public class OkHttpEngineFactory implements HttpEngineFactory { + @Override + public HttpEngine create(HttpEngineConfig config) { + OkHttpClient.Builder connectionBuilder = new OkHttpClient.Builder(); + OkHttpUtils.injectProxySetting(config.getProxy(), connectionBuilder); + return new OkHttpEngine(connectionBuilder.build(), config); + } + + @Override + public EngineType getEngineType() { + return EngineType.OKHTTP; + } +} diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpUtils.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpUtils.java new file mode 100644 index 000000000..2bd566153 --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpUtils.java @@ -0,0 +1,51 @@ +package io.ably.lib.network; + +import okhttp3.Credentials; +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.List; +import java.util.Map; + +public class OkHttpUtils { + public static void injectProxySetting(ProxyConfig proxyConfig, OkHttpClient.Builder connectionBuilder) { + if (proxyConfig == null) return; + connectionBuilder.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyConfig.getHost(), proxyConfig.getPort()))); + if (proxyConfig.getUsername() == null || proxyConfig.getAuthType() != ProxyAuthType.BASIC) return; + String username = proxyConfig.getUsername(); + String password = proxyConfig.getPassword(); + connectionBuilder.proxyAuthenticator((route, response) -> { + String credential = Credentials.basic(username, password); + return response.request().newBuilder() + .header("Proxy-Authorization", credential) + .build(); + }); + } + + public static Request toOkhttpRequest(HttpRequest request) { + Request.Builder builder = new Request.Builder() + .url(request.getUrl()); + + RequestBody body = null; + + if (request.getBody() != null) { + body = RequestBody.create(request.getBody().getContent(), MediaType.parse(request.getBody().getContentType())); + } + + builder.method(request.getMethod(), body); + for (Map.Entry> entry : request.getHeaders().entrySet()) { + String headerName = entry.getKey(); + List values = entry.getValue(); + for (String headerValue : values) { + builder.addHeader(headerName, headerValue); + } + } + + return builder.build(); + } +} diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketClient.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketClient.java new file mode 100644 index 000000000..7341eb71a --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketClient.java @@ -0,0 +1,87 @@ +package io.ably.lib.network; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okio.ByteString; + +import java.nio.ByteBuffer; + +public class OkHttpWebSocketClient implements WebSocketClient { + private final OkHttpClient connection; + private final Request request; + private final WebSocketListener listener; + private WebSocket webSocket; + + public OkHttpWebSocketClient(OkHttpClient connection, Request request, WebSocketListener listener) { + this.connection = connection; + this.request = request; + this.listener = listener; + } + + @Override + public void connect() { + webSocket = connection.newWebSocket(request, new WebSocketHandler(listener)); + } + + @Override + public void close() { + webSocket.close(1000, "Close"); + } + + @Override + public void close(int code, String reason) { + webSocket.close(code, reason); + } + + @Override + public void cancel(int code, String reason) { + webSocket.cancel(); + listener.onClose(code, reason); + } + + @Override + public void send(byte[] bytes) { + webSocket.send(ByteString.of(bytes)); + } + + @Override + public void send(String message) { + webSocket.send(message); + } + + private static class WebSocketHandler extends okhttp3.WebSocketListener { + private final WebSocketListener listener; + + private WebSocketHandler(WebSocketListener listener) { + super(); + this.listener = listener; + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + listener.onClose(code, reason); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + listener.onError(t); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + listener.onMessage(text); + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + listener.onMessage(ByteBuffer.wrap(bytes.toByteArray())); + } + + @Override + public void onOpen(WebSocket webSocket, Response response) { + listener.onOpen(); + } + } +} diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngine.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngine.java new file mode 100644 index 000000000..7715501ab --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngine.java @@ -0,0 +1,32 @@ +package io.ably.lib.network; + +import okhttp3.OkHttpClient; +import okhttp3.Request; + +public class OkHttpWebSocketEngine implements WebSocketEngine { + private final WebSocketEngineConfig config; + + public OkHttpWebSocketEngine(WebSocketEngineConfig config) { + this.config = config; + } + + @Override + public WebSocketClient create(String url, WebSocketListener listener) { + OkHttpClient.Builder connectionBuilder = new OkHttpClient.Builder(); + + Request.Builder requestBuilder = new Request.Builder().url(url); + + OkHttpUtils.injectProxySetting(config.getProxy(), connectionBuilder); + + if (config.getSslSocketFactory() != null) { + connectionBuilder.sslSocketFactory(config.getSslSocketFactory()); + } + + return new OkHttpWebSocketClient(connectionBuilder.build(), requestBuilder.build(), listener); + } + + @Override + public boolean isPingListenerSupported() { + return false; + } +} diff --git a/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngineFactory.java b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngineFactory.java new file mode 100644 index 000000000..24b7dcf20 --- /dev/null +++ b/network-client-okhttp/src/main/java/io/ably/lib/network/OkHttpWebSocketEngineFactory.java @@ -0,0 +1,13 @@ +package io.ably.lib.network; + +public class OkHttpWebSocketEngineFactory implements WebSocketEngineFactory { + @Override + public WebSocketEngine create(WebSocketEngineConfig config) { + return new OkHttpWebSocketEngine(config); + } + + @Override + public EngineType getEngineType() { + return EngineType.OKHTTP; + } +} diff --git a/overview.html b/overview.html new file mode 100644 index 000000000..36d103543 --- /dev/null +++ b/overview.html @@ -0,0 +1,8 @@ + + +

Ably Java Client Library SDK API Reference

+

The Java Client Library SDK supports a realtime and a REST interface. The Java API references are generated from the Ably Java Client Library SDK source code using JavaDoc and structured by classes.

+

The realtime interface enables a client to maintain a persistent connection to Ably and publish, subscribe and be present on channels. The REST interface is stateless and typically implemented server-side. It is used to make requests such as retrieving statistics, token authentication and publishing to a channel.

+

View the Ably docs for conceptual information on using Ably, and for API references featuring all languages. The combined API references are organized by features and split between the realtime and REST interfaces.

+ + diff --git a/pubsub-adapter/build.gradle.kts b/pubsub-adapter/build.gradle.kts new file mode 100644 index 000000000..66ab1f09f --- /dev/null +++ b/pubsub-adapter/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + `java-library` + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.maven.publish) +} + +kotlin { + explicitApi() +} + +dependencies { + compileOnly(project(":java")) + testImplementation(kotlin("test")) + testImplementation(project(":java")) + testImplementation(libs.nanohttpd) + testImplementation(libs.coroutine.core) + testImplementation(libs.coroutine.test) + testImplementation(libs.turbine) +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.register("runUnitTests") { + beforeTest(closureOf { logger.lifecycle("-> $this") }) +} diff --git a/pubsub-adapter/gradle.properties b/pubsub-adapter/gradle.properties new file mode 100644 index 000000000..48d9d1d46 --- /dev/null +++ b/pubsub-adapter/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=pubsub-adapter +POM_NAME=Internal Ably PubSub adapter +POM_DESCRIPTION=Internal adapter for using Ably PubSub in Kotlin +POM_PACKAGING=jar diff --git a/pubsub-adapter/src/main/kotlin/com/ably/Subscription.kt b/pubsub-adapter/src/main/kotlin/com/ably/Subscription.kt new file mode 100644 index 000000000..2aecb2ca5 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/Subscription.kt @@ -0,0 +1,12 @@ +package com.ably + +/** + * An unsubscription handle, returned by various functions (mostly subscriptions) + * where unsubscription is required. + */ +public fun interface Subscription { + /** + * Handle unsubscription (unsubscribe listeners, clean up) + */ + public fun unsubscribe() +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/annotations/Annotations.kt b/pubsub-adapter/src/main/kotlin/com/ably/annotations/Annotations.kt new file mode 100644 index 000000000..5532de62a --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/annotations/Annotations.kt @@ -0,0 +1,21 @@ +package com.ably.annotations + +/** + * API marked with this annotation is internal, and it is not intended to be used outside Ably. + * It could be modified or removed without any notice. Using it outside Ably could cause undefined behaviour and/or + * any unexpected effects. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This API is internal in Ably and should not be used. It could be removed or changed without notice." +) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.TYPEALIAS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.PROPERTY_SETTER, +) +public annotation class InternalAPI diff --git a/pubsub-adapter/src/main/kotlin/com/ably/http/HttpMethod.kt b/pubsub-adapter/src/main/kotlin/com/ably/http/HttpMethod.kt new file mode 100644 index 000000000..2e9008581 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/http/HttpMethod.kt @@ -0,0 +1,12 @@ +package com.ably.http + +public enum class HttpMethod(private val method: String) { + Get("GET"), + Post("POST"), + Put("PUT"), + Delete("DELETE"), + Patch("PATCH"), + ; + + override fun toString(): String = method +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Channel.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Channel.kt new file mode 100644 index 000000000..4588e610b --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Channel.kt @@ -0,0 +1,87 @@ +package com.ably.pubsub + +import com.ably.query.OrderBy +import io.ably.lib.types.* + +/** + * An interface representing a Channel in the Ably API. This serves as the base interface + * for both [RealtimeChannel] and [RestChannel], providing common channel functionality + * such as history retrieval and presence management. + * + * A channel is the medium through which messages are distributed. Channels can represent + * different topics, rooms, or contexts in your application. + * + * @see Ably Channels Documentation + */ +public interface Channel { + + /** + * The channel name. + * + * Channel names: + * - Can contain any Unicode characters except colon (':') + * - Are limited to 250 characters + * - Are case-sensitive + * + * @see Channel Naming Rules + */ + public val name: String + + /** + * A [Presence] object. + * + * The Presence object enables clients to be notified when other clients enter or leave + * the channel (presence events) and get the set of current members on the channel + * (presence state). + * + * Common use cases include: + * - Online status indicators + * - Typing indicators + * - User activity tracking + * + * Spec: RTL9 + */ + public val presence: Presence + + /** + * Obtain recent history for this channel using the REST API. + * The history provided relates to all clients of this application, + * not just this instance. + * + * @param start The start of the query interval as a time in milliseconds since the epoch. + * A message qualifies as a member of the result set if it was received at or after this time. (default: beginning of time) + * @param end The end of the query interval as a time in milliseconds since the epoch. + * A message qualifies as a member of the result set if it was received at or before this time. (default: now) + * @param limit The maximum number of records to return. A limit greater than 1,000 is invalid. + * @param orderBy The direction of this query. + * + * @return Paginated result of Messages for this Channel. + */ + public fun history( + start: Long? = null, + end: Long? = null, + limit: Int = 100, + orderBy: OrderBy = OrderBy.NewestFirst, + ): PaginatedResult + + /** + * Asynchronously obtain recent history for this channel using the REST API. + * + * @param start The start of the query interval as a time in milliseconds since the epoch. + * A message qualifies as a member of the result set if it was received at or after this time. (default: beginning of time) + * @param end The end of the query interval as a time in milliseconds since the epoch. + * A message qualifies as a member of the result set if it was received at or before this time. (default: now) + * @param limit The maximum number of records to return. A limit greater than 1,000 is invalid. + * @param orderBy The direction of this query. + * @param callback A Callback returning [AsyncPaginatedResult] object containing an array of [Message] objects. + * Note: This callback is invoked on a background thread. + */ + public fun historyAsync( + callback: Callback>, + start: Long? = null, + end: Long? = null, + limit: Int = 100, + orderBy: OrderBy = OrderBy.NewestFirst, + ) + +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Channels.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Channels.kt new file mode 100644 index 000000000..cdc989924 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Channels.kt @@ -0,0 +1,48 @@ +package com.ably.pubsub + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ChannelOptions + +/** + * Represents collection of managed Channel instances + */ +public interface Channels : Iterable { + + /** + * Checks if channel with specified name exists + *

+ * Spec: RSN2, RTS2 + * @param name The channel name. + * @return `true` if it contains the specified [name]. + */ + public fun contains(name: String): Boolean + + /** + * Creates a new [Channel] object, or returns the existing channel object. + *

+ * Spec: RSN3a, RTS3a + * @param name The channel name. + * @return A [Channel] object. + */ + public fun get(name: String): ChannelType + + /** + * Creates a new [Channel] object, with the specified [ChannelOptions], or returns the existing channel object. + *

+ * Spec: RSN3c, RTS3c + * @param name The channel name. + * @param options A [ChannelOptions] object. + * @return A [Channel] object. + */ + public fun get(name: String, options: ChannelOptions): ChannelType + + /** + * Releases a [Channel] object, deleting it, and enabling it to be garbage collected. + * It also removes any listeners associated with the channel. + * To release a channel, the [ChannelState] must be `INITIALIZED`, `DETACHED`, or `FAILED`. + *

+ * Spec: RSN4, RTS4 + * @param name The channel name. + */ + public fun release(name: String) +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Client.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Client.kt new file mode 100644 index 000000000..4ff48ed22 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Client.kt @@ -0,0 +1,174 @@ +package com.ably.pubsub + +import com.ably.query.OrderBy +import com.ably.query.TimeUnit +import com.ably.http.HttpMethod +import io.ably.lib.http.HttpCore +import io.ably.lib.push.Push +import io.ably.lib.rest.Auth +import io.ably.lib.types.* + +/** + * A client that offers a base interface to interact with Ably's API. + * + * This class implements {@link AutoCloseable} so you can use it in + * try-with-resources constructs and have the JDK close it for you. + */ +public interface Client : AutoCloseable { + + /** + * An [Auth] object. + * + * Spec: RSC5 + */ + public val auth: Auth + + /** + * A [Channels] object. + * + * Spec: RTC3, RTS1 + */ + public val channels: Channels + + /** + * Client options + */ + public val options: ClientOptions + + /** + * An [Push] object. + * + * Spec: RSH7 + */ + public val push: Push + + /** + * Retrieves the time from the Ably service as milliseconds + * since the Unix epoch. Clients that do not have access + * to a sufficiently well maintained time source and wish + * to issue Ably [Auth.TokenRequest] with + * a more accurate timestamp should use the + * [ClientOptions.queryTime] property instead of this method. + *

+ * Spec: RSC16 + * @return The time as milliseconds since the Unix epoch. + */ + public fun time(): Long + + /** + * Asynchronously retrieves the time from the Ably service as milliseconds + * since the Unix epoch. Clients that do not have access + * to a sufficiently well maintained time source and wish + * to issue Ably [Auth.TokenRequest] with + * a more accurate timestamp should use the + * [ClientOptions.queryTime] property instead of this method. + * + * Spec: RSC16 + * + * @param callback Listener with the time as milliseconds since the Unix epoch. + * This callback is invoked on a background thread + */ + public fun timeAsync(callback: Callback) + + /** + * Queries the REST /stats API and retrieves your application's usage statistics. + * @param start (RSC6b1) - The time from which stats are retrieved, specified as milliseconds since the Unix epoch. + * @param end (RSC6b1) - The time until stats are retrieved, specified as milliseconds since the Unix epoch. + * @param orderBy (RSC6b2) - The order for which stats are returned in. + * @param limit (RSC6b3) - An upper limit on the number of stats returned. The default is 100, and the maximum is 1000. + * @param unit (RSC6b4) - minute, hour, day or month. Based on the unit selected, the given start or end times are rounded down to the start of the relevant interval depending on the unit granularity of the query. + * + * Spec: RSC6a + * + * @return A [PaginatedResult] object containing an array of [Stats] objects. + * @throws AblyException + */ + public fun stats( + start: Long? = null, + end: Long? = null, + limit: Int = 100, + orderBy: OrderBy = OrderBy.NewestFirst, + unit: TimeUnit = TimeUnit.Minute, + ): PaginatedResult + + /** + * Asynchronously queries the REST /stats API and retrieves your application's usage statistics. + * + * @param start (RSC6b1) - The time from which stats are retrieved, specified as milliseconds since the Unix epoch. + * @param end (RSC6b1) - The time until stats are retrieved, specified as milliseconds since the Unix epoch. + * @param orderBy (RSC6b2) - The order for which stats are returned in. + * @param limit (RSC6b3) - An upper limit on the number of stats returned. The default is 100, and the maximum is 1000. + * @param unit (RSC6b4) - minute, hour, day or month. Based on the unit selected, the given start or end times are rounded down to the start of the relevant interval depending on the unit granularity of the query. + * + * Spec: RSC6a + * + * @param callback Listener which returns a [AsyncPaginatedResult] object containing an array of [Stats] objects. + * This callback is invoked on a background thread + */ + public fun statsAsync( + callback: Callback>, + start: Long? = null, + end: Long? = null, + limit: Int = 100, + orderBy: OrderBy = OrderBy.NewestFirst, + unit: TimeUnit = TimeUnit.Minute, + ) + + /** + * Makes a REST request to a provided path. This is provided as a convenience + * for developers who wish to use REST API functionality that is either not + * documented or is not yet included in the public API, without having to + * directly handle features such as authentication, paging, fallback hosts, + * MsgPack and JSON support. + * + * Spec: RSC19 + * + * @param method The request method to use, such as GET, POST. + * @param path The request path. + * @param params The parameters to include in the URL query of the request. + * The parameters depend on the endpoint being queried. + * See the [REST API reference](https://ably.com/docs/api/rest-api) + * for the available parameters of each endpoint. + * @param body The RequestBody of the request. + * @param headers Additional HTTP headers to include in the request. + * @return An [HttpPaginatedResponse] object returned by the HTTP request, containing an empty or JSON-encodable object. + */ + public fun request( + path: String, + method: HttpMethod = HttpMethod.Get, + params: List = emptyList(), + body: HttpCore.RequestBody? = null, + headers: List = emptyList(), + ): HttpPaginatedResponse + + /** + * Makes a async REST request to a provided path. This is provided as a convenience + * for developers who wish to use REST API functionality that is either not + * documented or is not yet included in the public API, without having to + * directly handle features such as authentication, paging, fallback hosts, + * MsgPack and JSON support. + * + * Spec: RSC19 + * + * @param method The request method to use, such as GET, POST. + * @param path The request path. + * @param params The parameters to include in the URL query of the request. + * The parameters depend on the endpoint being queried. + * See the [REST API reference](https://ably.com/docs/api/rest-api) + * for the available parameters of each endpoint. + * @param body The RequestBody of the request. + * @param headers Additional HTTP headers to include in the request. + * @param callback called with the asynchronous result, + * returns an [AsyncHttpPaginatedResponse] object returned by the HTTP request, + * containing an empty or JSON-encodable object. + * This callback is invoked on a background thread + */ + public fun requestAsync( + path: String, + callback: AsyncHttpPaginatedResponse.Callback, + method: HttpMethod = HttpMethod.Get, + params: List = emptyList(), + body: HttpCore.RequestBody? = null, + headers: List = emptyList(), + ) +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Presence.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Presence.kt new file mode 100644 index 000000000..268ffe78d --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/Presence.kt @@ -0,0 +1,55 @@ +package com.ably.pubsub + +import com.ably.query.OrderBy +import io.ably.lib.types.* + +/** + * Enables get historic presence set for a channel. + */ +public interface Presence { + + /** + * Retrieves a [PaginatedResult] object, containing an array of historical [PresenceMessage] objects for the channel. + * If the channel is configured to persist messages, + * then presence messages can be retrieved from history for up to 72 hours in the past. + * If not, presence messages can only be retrieved from history for up to two minutes in the past. + * + * Spec: RSP4a + * + * @param start (RSP4b1) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + * @param end (RSP4b1) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + * @param orderBy (RSP4b2) - The order for which messages are returned in. + * @param limit (RSP4b3) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * + * @return A [PaginatedResult] object containing an array of [PresenceMessage] objects. + */ + public fun history( + start: Long? = null, + end: Long? = null, + limit: Int = 100, + orderBy: OrderBy = OrderBy.NewestFirst, + ): PaginatedResult + + /** + * Asynchronously retrieves a [PaginatedResult] object, containing an array of historical [PresenceMessage] objects for the channel. + * If the channel is configured to persist messages, + * then presence messages can be retrieved from history for up to 72 hours in the past. + * If not, presence messages can only be retrieved from history for up to two minutes in the past. + * + * Spec: RSP4a + * + * @param start (RSP4b1) - The time from which messages are retrieved, specified as milliseconds since the Unix epoch. + * @param end (RSP4b1) - The time until messages are retrieved, specified as milliseconds since the Unix epoch. + * @param orderBy (RSP4b2) - The order for which messages are returned in. + * @param limit (RSP4b3) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @param callback A Callback returning [AsyncPaginatedResult] object containing an array of [PresenceMessage] objects. + * Note: This callback is invoked on a background thread. + */ + public fun historyAsync( + callback: Callback>, + start: Long? = null, + end: Long? = null, + limit: Int = 100, + orderBy: OrderBy = OrderBy.NewestFirst, + ) +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimeChannel.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimeChannel.kt new file mode 100644 index 000000000..691544ef3 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimeChannel.kt @@ -0,0 +1,154 @@ +package com.ably.pubsub + +import com.ably.Subscription +import com.ably.annotations.InternalAPI +import io.ably.lib.realtime.ChannelBase.MessageListener +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ChannelProperties +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Message + + +/** + * An interface representing a Realtime Channel. + */ +public interface RealtimeChannel : Channel { + /** + * Presence set for a channel. + */ + override val presence: RealtimePresence + + /** + * The current [ChannelState] of the channel. + * + * Spec: RTL2b + */ + public val state: ChannelState + + /** + * An [ErrorInfo] object describing the last error which occurred on the channel, if any. + * + * Spec: RTL4e + */ + public val reason: ErrorInfo? + + /** + * A [ChannelProperties] object. + * + * Spec: CP1, RTL15 + */ + public val properties: ChannelProperties + + /** + * Attach to this channel ensuring the channel is created in the Ably system and all messages published + * on the channel are received by any channel listeners registered using [subscribe]. + * Any resulting channel state change will be emitted to any listeners registered using the + * [io.ably.lib.util.EventEmitter.on] or [io.ably.lib.util.EventEmitter.once] methods. + * As a convenience, `attach()` is called implicitly if [subscribe] for the channel is called, + * or [RealtimePresence.enter] or [RealtimePresence.subscribe] are called on the [RealtimePresence] object for this channel. + * + * Spec: RTL4d + */ + public fun attach(listener: CompletionListener? = null) + + /** + * Detach from this channel. + * Any resulting channel state change is emitted to any listeners registered using the + * [io.ably.lib.util.EventEmitter.on] or [io.ably.lib.util.EventEmitter.once] methods. + * Once all clients globally have detached from the channel, the channel will be released in the Ably service within two minutes. + * + * Spec: RTL5e + */ + public fun detach(listener: CompletionListener? = null) + + /** + * Registers a listener for messages on this channel. + * The caller supplies a listener function, which is called each time one or more messages arrives on the channel. + * + * Spec: RTL7a + * + * @param listener A listener may optionally be passed in to this call to be notified of success or failure + * of the channel [RealtimeChannel.attach] operation. This listener is invoked on a background thread. + */ + public fun subscribe(listener: MessageListener): Subscription + + /** + * Registers a listener for messages with a given event name on this channel. + * The caller supplies a listener function, which is called each time one or more matching messages arrives at the channel. + * + * Spec: RTL7b + * + * @param eventName The event name. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure + * of the channel [RealtimeChannel.attach] operation. This listener is invoked on a background thread. + */ + public fun subscribe(eventName: String, listener: MessageListener): Subscription + + /** + * Registers a listener for messages on this channel for multiple event name values. + * The caller supplies a listener function, which is called each time one or more matching messages arrives on the channel. + * + * Spec: RTL7a + * + * @param eventNames A list of event names. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure + * of the channel [RealtimeChannel.attach] operation. This listener is invoked on a background thread. + */ + public fun subscribe(eventNames: List, listener: MessageListener): Subscription + + /** + * Publishes a single message to the channel with the given event name and payload. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel, + * so long as [transient publishing](https://ably.com/docs/realtime/channels#transient-publish) is available in the library. + * Otherwise, the client will implicitly attach. + * + * Spec: RTL6i + * + * @param name the event name + * @param data the message payload + * @param listener A listener may optionally be passed in to this call to be notified of success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun publish(name: String? = null, data: Any? = null, listener: CompletionListener? = null) + + /** + * Publishes a message to the channel. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel. + * + * Spec: RTL6i + * + * @param message A [Message] object. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun publish(message: Message, listener: CompletionListener? = null) + + /** + * Publishes an array of messages to the channel. + * When publish is called with this client library, it won't attempt to implicitly attach to the channel. + * + * Spec: RTL6i + * + * @param messages A list of [Message] objects. + * @param listener A listener may optionally be passed in to this call to be notified of success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun publish(messages: List, listener: CompletionListener? = null) + + /** + * Sets the [ChannelOptions] for the channel. + * + * Spec: RTL16 + * + * @param options A {@link ChannelOptions} object. + */ + public fun setOptions(options: ChannelOptions) + + /** + * This property will be removed once public API for new version of ably-java is stable + */ + @InternalAPI + public val javaChannel: io.ably.lib.realtime.Channel +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimeClient.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimeClient.kt new file mode 100644 index 000000000..2dfd2ee7c --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimeClient.kt @@ -0,0 +1,32 @@ +package com.ably.pubsub + +import com.ably.annotations.InternalAPI +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Connection + +/** + * A client that extends the functionality of the {@link Client} and provides additional realtime-specific features. + * + * This class implements {@link AutoCloseable} so you can use it in + * try-with-resources constructs and have the JDK close it for you. + */ +public interface RealtimeClient : Client { + + /** + * The {@link Connection} object for this instance. + *

+ * Spec: RTC2 + */ + public val connection: Connection + + /** + * Collection of [RealtimeChannel] instances currently managed by Realtime client + */ + override val channels: Channels + + /** + * This property will be removed once public API for new version of ably-java is stable + */ + @InternalAPI + public val javaClient: AblyRealtime +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt new file mode 100644 index 000000000..cbd00805e --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt @@ -0,0 +1,153 @@ +package com.ably.pubsub + +import com.ably.Subscription +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.realtime.Presence.PresenceListener +import io.ably.lib.types.PresenceMessage +import java.util.* + + +/** + * Presence for a Realtime channel + */ +public interface RealtimePresence : Presence { + + /** + * Retrieves the current members present on the channel and the metadata for each member, + * such as their [io.ably.lib.types.PresenceMessage.Action] and ID. + * Returns an array of [PresenceMessage] objects. + * + * Spec: RTP11 + * + * @param waitForSync (RTP11c1) - Sets whether to wait for a full presence set synchronization between Ably and the clients on + * the channel to complete before returning the results. + * Synchronization begins as soon as the channel is [ChannelState.attached]. + * When set to true the results will be returned as soon as the sync is complete. + * When set to false the current list of members will be returned without the sync completing. + * The default is true. + * @param clientId (RTP11c2) - Filters the array of returned presence members by a specific client using its ID. + * @param connectionId (RTP11c3) - Filters the array of returned presence members by a specific connection using its ID. + * @return A list of [PresenceMessage] objects. + */ + public fun get(clientId: String? = null, connectionId: String? = null, waitForSync: Boolean = true): List + + /** + * Registers a listener that is called each time a [PresenceMessage] matching a given [PresenceMessage.Action], + * or an action within an array of [PresenceMessage.Action], is received on the channel, + * such as a new member entering the presence set. + * + * Spec: RTP6a + * + * @param listener An event listener function. + * The listener is invoked on a background thread. + */ + public fun subscribe(listener: PresenceListener): Subscription + + /** + * Registers a listener that is called each time a [PresenceMessage] matching a given [PresenceMessage.Action], + * or an action within an array of [PresenceMessage.Action], is received on the channel, + * such as a new member entering the presence set. + * + * Spec: RTP6b + * + * @param action A [PresenceMessage.Action] to register the listener for. + * @param listener An event listener function. + * The listener is invoked on a background thread. + */ + public fun subscribe(action: PresenceMessage.Action, listener: PresenceListener): Subscription + + /** + * Registers a listener that is called each time a [PresenceMessage] matching a given [PresenceMessage.Action], + * or an action within an array of [PresenceMessage.Action], is received on the channel, + * such as a new member entering the presence set. + * + * Spec: RTP6b + * + * @param actions An array of [PresenceMessage.Action] to register the listener for. + * @param listener An event listener function. + * The listener is invoked on a background thread. + */ + public fun subscribe(actions: EnumSet, listener: PresenceListener): Subscription + + /** + * Enters the presence set for the channel, optionally passing a data payload. + * A clientId is required to be present on a channel. + * An optional callback may be provided to notify of the success or failure of the operation. + * + * Spec: RTP8 + * + * @param data The payload associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun enter(data: Any? = null, listener: CompletionListener? = null) + + /** + * Updates the data payload for a presence member. + * If called before entering the presence set, this is treated as an [PresenceMessage.Action.enter] event. + * An optional callback may be provided to notify of the success or failure of the operation. + * + * Spec: RTP9 + * + * @param data The payload associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun update(data: Any? = null, listener: CompletionListener? = null) + + /** + * Leaves the presence set for the channel. + * A client must have previously entered the presence set before they can leave it. + * + * Spec: RTP10 + * + * @param data The payload associated with the presence member. + * @param listener a listener to notify of the success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun leave(data: Any? = null, listener: CompletionListener? = null) + + /** + * Enters the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + * Spec: RTP4, RTP14, RTP15 + * + * @param clientId The ID of the client to enter into the presence set. + * @param data The payload associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun enterClient(clientId: String, data: Any? = null, listener: CompletionListener? = null) + + /** + * Updates the data payload for a presence member using a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * An optional callback may be provided to notify of the success or failure of the operation. + * + * Spec: RTP15 + * + * @param clientId The ID of the client to update in the presence set. + * @param data The payload to update for the presence member. + * @param listener A callback to notify of the success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun updateClient(clientId: String, data: Any? = null, listener: CompletionListener? = null) + + /** + * Leaves the presence set of the channel for a given clientId. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + * Spec: RTP15 + * + * @param clientId The ID of the client to leave the presence set for. + * @param data The payload associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + * This listener is invoked on a background thread. + */ + public fun leaveClient(clientId: String?, data: Any? = null, listener: CompletionListener? = null) +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestChannel.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestChannel.kt new file mode 100644 index 000000000..12e05e8b1 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestChannel.kt @@ -0,0 +1,44 @@ +package com.ably.pubsub + +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.types.Message + +public interface RestChannel : Channel { + + /** + * Presence set for a channel. + */ + override val presence: RestPresence + + /** + * Publish a message on this channel + * + * @param name the event name + * @param data the message payload; see [io.ably.types.Data] for details of supported data types. + */ + public fun publish(name: String? = null, data: Any? = null) + + /** + * Publish list of messages on this channel. When there are + * multiple messages to be sent, it is more efficient to use this + * method to publish them in a single request, as compared with + * publishing via multiple independent requests. + * + * @param messages list of messages to publish. + */ + public fun publish(messages: List) + + /** + * Publish a message on this channel asynchronously + * + * @see [publish] + */ + public fun publishAsync(name: String? = null, data: Any? = null, listener: CompletionListener) + + /** + * Publish list of messages on this channel asynchronously + * + * @see [publish] + */ + public fun publishAsync(messages: List, listener: CompletionListener) +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestClient.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestClient.kt new file mode 100644 index 000000000..2e0451db9 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestClient.kt @@ -0,0 +1,9 @@ +package com.ably.pubsub + +public interface RestClient : Client { + + /** + * Collection of [RestChannel] instances currently managed by the client + */ + override val channels: Channels +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestPresence.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestPresence.kt new file mode 100644 index 000000000..40d0f4d73 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RestPresence.kt @@ -0,0 +1,36 @@ +package com.ably.pubsub + +import io.ably.lib.types.* + +public interface RestPresence : Presence { + + /** + * Retrieves the current members present on the channel and the metadata for each member, + * such as their [io.ably.lib.types.PresenceMessage.Action] and ID. Returns a [PaginatedResult] object, + * containing an array of [PresenceMessage] objects. + * + * Spec: RSPa + * + * @param limit (RSP3a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @param clientId (RSP3a2) - Filters the list of returned presence members by a specific client using its ID. + * @param connectionId (RSP3a3) - Filters the list of returned presence members by a specific connection using its ID. + * @return A [PaginatedResult] object containing an array of [PresenceMessage] objects. + */ + public fun get(limit: Int = 100, clientId: String? = null, connectionId: String? = null): PaginatedResult + + /** + * Asynchronously retrieves the current members present on the channel and the metadata for each member, + * such as their [io.ably.lib.types.PresenceMessage.Action] and ID. Returns a [PaginatedResult] object, + * containing an array of [PresenceMessage] objects. + * + * Spec: RSPa + * + * @param limit (RSP3a) - An upper limit on the number of messages returned. The default is 100, and the maximum is 1000. + * @param clientId (RSP3a2) - Filters the list of returned presence members by a specific client using its ID. + * @param connectionId (RSP3a3) - Filters the list of returned presence members by a specific connection using its ID. + * @param callback A Callback returning [AsyncPaginatedResult] object containing an array of [PresenceMessage] objects. + * This callback is invoked on a background thread. + */ + public fun getAsync(callback: Callback>, limit: Int = 100, clientId: String? = null, connectionId: String? = null) + +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/WrapperSdkProxy.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/WrapperSdkProxy.kt new file mode 100644 index 000000000..175b89d18 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/WrapperSdkProxy.kt @@ -0,0 +1,20 @@ +package com.ably.pubsub + +public data class WrapperSdkProxyOptions(val agents: Map) + +public interface SdkWrapperCompatible { + + /** + * Creates a proxy client to be used to supply analytics information for Ably-authored SDKs. + * The proxy client shares the state of the `RealtimeClient` or `RestClient` instance on which this method is called. + * This method should only be called by Ably-authored SDKs. + */ + public fun createWrapperSdkProxy(options: WrapperSdkProxyOptions): T +} + +public fun RealtimeClient.createWrapperSdkProxy(options: WrapperSdkProxyOptions): RealtimeClient = + (this as SdkWrapperCompatible<*>).createWrapperSdkProxy(options) as RealtimeClient + +public fun RestClient.createWrapperSdkProxy(options: WrapperSdkProxyOptions): RestClient = + (this as SdkWrapperCompatible<*>).createWrapperSdkProxy(options) as RestClient + diff --git a/pubsub-adapter/src/main/kotlin/com/ably/query/OrderBy.kt b/pubsub-adapter/src/main/kotlin/com/ably/query/OrderBy.kt new file mode 100644 index 000000000..96ce45c10 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/query/OrderBy.kt @@ -0,0 +1,18 @@ +package com.ably.query + +/** + * Represents direction to query messages in. + */ +public enum class OrderBy(public val direction: String) { + + /** + * The response will include messages from the end of the time window to the start. + */ + NewestFirst("backwards"), + + /** + * The response will include messages from the start of the time window to the end. + */ + OldestFirst("forwards"), + ; +} diff --git a/pubsub-adapter/src/main/kotlin/com/ably/query/TimeUnit.kt b/pubsub-adapter/src/main/kotlin/com/ably/query/TimeUnit.kt new file mode 100644 index 000000000..5fd286d1f --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/com/ably/query/TimeUnit.kt @@ -0,0 +1,16 @@ +package com.ably.query + +/** + * The period for which the stats query will be aggregated by, + * values supported are minute, hour, day or month; if omitted the unit defaults + * to the REST API default (minute) + */ +public enum class TimeUnit(private val unit: String) { + Minute("minute"), + Hour("hour"), + Day("day"), + Month("month"), + ; + + override fun toString(): String = unit +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/Utils.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/Utils.kt new file mode 100644 index 000000000..a3371dbf1 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/Utils.kt @@ -0,0 +1,38 @@ +package io.ably.lib + +import com.ably.query.OrderBy +import com.ably.query.TimeUnit +import io.ably.lib.types.Param + +internal fun buildStatsParams( + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit, +) = buildList { + addAll(buildHistoryParams(start, end, limit, orderBy)) + add(Param("unit", unit.toString())) +} + +internal fun buildHistoryParams( + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, +) = buildList { + start?.let { add(Param("start", it)) } + end?.let { add(Param("end", it)) } + add(Param("limit", limit)) + add(Param("direction", orderBy.direction)) +} + +internal fun buildRestPresenceParams( + limit: Int, + clientId: String?, + connectionId: String?, +) = buildList { + add(Param("limit", limit)) + clientId?.let { add(Param("clientId", it)) } + connectionId?.let { add(Param("connectionId", it)) } +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeChannelAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeChannelAdapter.kt new file mode 100644 index 000000000..8a5e32abd --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeChannelAdapter.kt @@ -0,0 +1,71 @@ +package io.ably.lib.realtime + +import com.ably.Subscription +import com.ably.annotations.InternalAPI +import com.ably.pubsub.RealtimeChannel +import com.ably.pubsub.RealtimePresence +import com.ably.query.OrderBy +import io.ably.lib.buildHistoryParams +import io.ably.lib.types.* + + +@OptIn(InternalAPI::class) +internal class RealtimeChannelAdapter(override val javaChannel: Channel) : RealtimeChannel { + override val name: String + get() = javaChannel.name + override val presence: RealtimePresence + get() = RealtimePresenceAdapter(javaChannel.presence) + override val state: ChannelState + get() = javaChannel.state + override val reason: ErrorInfo + get() = javaChannel.reason + override val properties: ChannelProperties + get() = javaChannel.properties + + override fun attach(listener: CompletionListener?) = javaChannel.attach(listener) + + override fun detach(listener: CompletionListener?) = javaChannel.detach(listener) + + override fun subscribe(listener: ChannelBase.MessageListener): Subscription { + javaChannel.subscribe(listener) + return Subscription { + javaChannel.unsubscribe(listener) + } + } + + override fun subscribe(eventName: String, listener: ChannelBase.MessageListener): Subscription { + javaChannel.subscribe(eventName, listener) + return Subscription { + javaChannel.unsubscribe(eventName, listener) + } + } + + override fun subscribe(eventNames: List, listener: ChannelBase.MessageListener): Subscription { + javaChannel.subscribe(eventNames.toTypedArray(), listener) + return Subscription { + javaChannel.unsubscribe(eventNames.toTypedArray(), listener) + } + } + + override fun publish(name: String?, data: Any?, listener: CompletionListener?) = + javaChannel.publish(name, data, listener) + + override fun publish(message: Message, listener: CompletionListener?) = javaChannel.publish(message, listener) + + override fun publish(messages: List, listener: CompletionListener?) = + javaChannel.publish(messages.toTypedArray(), listener) + + override fun setOptions(options: ChannelOptions) = javaChannel.setOptions(options) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaChannel.history(buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaChannel.historyAsync(buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeChannelsAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeChannelsAdapter.kt new file mode 100644 index 000000000..0fe955b30 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeChannelsAdapter.kt @@ -0,0 +1,20 @@ +package io.ably.lib.realtime + +import com.ably.pubsub.Channels +import com.ably.pubsub.RealtimeChannel +import io.ably.lib.types.ChannelOptions + +internal class RealtimeChannelsAdapter(private val javaChannels: AblyRealtime.Channels) : Channels { + override fun contains(name: String): Boolean = javaChannels.containsKey(name) + + override fun get(name: String): RealtimeChannel = RealtimeChannelAdapter(javaChannels.get(name)) + + override fun get(name: String, options: ChannelOptions): RealtimeChannel = + RealtimeChannelAdapter(javaChannels.get(name, options)) + + override fun release(name: String) = javaChannels.release(name) + + override fun iterator(): Iterator = iterator { + javaChannels.entrySet().forEach { yield(RealtimeChannelAdapter(it.value)) } + } +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeClientAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeClientAdapter.kt new file mode 100644 index 000000000..c95d8365a --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimeClientAdapter.kt @@ -0,0 +1,77 @@ +package io.ably.lib.realtime + +import com.ably.annotations.InternalAPI +import com.ably.http.HttpMethod +import com.ably.pubsub.* +import com.ably.query.OrderBy +import com.ably.query.TimeUnit +import io.ably.lib.buildStatsParams +import io.ably.lib.http.HttpCore +import io.ably.lib.push.Push +import io.ably.lib.rest.Auth +import io.ably.lib.types.* + +/** + * Wrapper for Realtime client + */ +public fun RealtimeClient(javaClient: AblyRealtime): RealtimeClient = RealtimeClientAdapter(javaClient) + +@OptIn(InternalAPI::class) +internal class RealtimeClientAdapter(override val javaClient: AblyRealtime) : RealtimeClient, SdkWrapperCompatible { + override val channels: Channels + get() = RealtimeChannelsAdapter(javaClient.channels) + override val connection: Connection + get() = javaClient.connection + override val auth: Auth + get() = javaClient.auth + override val options: ClientOptions + get() = javaClient.options + override val push: Push + get() = javaClient.push + + override fun time(): Long = javaClient.time() + + override fun timeAsync(callback: Callback) = javaClient.timeAsync(callback) + + override fun stats( + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ): PaginatedResult = javaClient.stats(buildStatsParams(start, end, limit, orderBy, unit).toTypedArray()) + + override fun statsAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ) = javaClient.statsAsync(buildStatsParams(start, end, limit, orderBy, unit).toTypedArray(), callback) + + override fun request( + path: String, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.request(method.toString(), path, params.toTypedArray(), body, headers.toTypedArray())!! + + override fun requestAsync( + path: String, + callback: AsyncHttpPaginatedResponse.Callback, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.requestAsync(method.toString(), path, params.toTypedArray(), body, headers.toTypedArray(), callback) + + override fun close() = javaClient.close() + + override fun createWrapperSdkProxy(options: WrapperSdkProxyOptions): RealtimeClient { + val httpCoreWithAgents = javaClient.httpCore.injectDynamicAgents(options.agents) + val httpModule = javaClient.http.exchangeHttpCore(httpCoreWithAgents) + return WrapperRealtimeClient(javaClient, this, httpModule, options.agents) + } +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt new file mode 100644 index 000000000..441c3dace --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt @@ -0,0 +1,73 @@ +package io.ably.lib.realtime + +import com.ably.Subscription +import com.ably.pubsub.RealtimePresence +import com.ably.query.OrderBy +import io.ably.lib.buildHistoryParams +import io.ably.lib.types.* +import java.util.* + +internal class RealtimePresenceAdapter(private val javaPresence: Presence) : RealtimePresence { + override fun get(clientId: String?, connectionId: String?, waitForSync: Boolean): List { + val params = buildList { + clientId?.let { add(Param(Presence.GET_CLIENTID, it)) } + connectionId?.let { add(Param(Presence.GET_CONNECTIONID, it)) } + add(Param(Presence.GET_WAITFORSYNC, waitForSync)) + } + return javaPresence.get(*params.toTypedArray()).toList() + } + + override fun subscribe(listener: Presence.PresenceListener): Subscription { + javaPresence.subscribe(listener) + return Subscription { + javaPresence.unsubscribe(listener) + } + } + + override fun subscribe( + action: PresenceMessage.Action, + listener: Presence.PresenceListener, + ): Subscription { + javaPresence.subscribe(action, listener) + return Subscription { + javaPresence.unsubscribe(action, listener) + } + } + + override fun subscribe( + actions: EnumSet, + listener: Presence.PresenceListener, + ): Subscription { + javaPresence.subscribe(actions, listener) + return Subscription { + javaPresence.unsubscribe(actions, listener) + } + } + + override fun enter(data: Any?, listener: CompletionListener?) = javaPresence.enter(data, listener) + + override fun update(data: Any?, listener: CompletionListener?) = javaPresence.update(data, listener) + + override fun leave(data: Any?, listener: CompletionListener?) = javaPresence.leave(data, listener) + + override fun enterClient(clientId: String, data: Any?, listener: CompletionListener?) = + javaPresence.enterClient(clientId, data, listener) + + override fun updateClient(clientId: String, data: Any?, listener: CompletionListener?) = + javaPresence.updateClient(clientId, data, listener) + + override fun leaveClient(clientId: String?, data: Any?, listener: CompletionListener?) = + javaPresence.leaveClient(clientId, data, listener) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaPresence.history(buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaPresence.historyAsync(buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/WrapperRealtimeClient.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/WrapperRealtimeClient.kt new file mode 100644 index 000000000..923204cb7 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/WrapperRealtimeClient.kt @@ -0,0 +1,144 @@ +package io.ably.lib.realtime + +import com.ably.annotations.InternalAPI +import com.ably.http.HttpMethod +import com.ably.pubsub.* +import com.ably.query.OrderBy +import com.ably.query.TimeUnit +import io.ably.lib.buildHistoryParams +import io.ably.lib.buildStatsParams +import io.ably.lib.http.Http +import io.ably.lib.http.HttpCore +import io.ably.lib.rest.* +import io.ably.lib.types.* + +@OptIn(InternalAPI::class) +internal class WrapperRealtimeClient( + override val javaClient: AblyRealtime, + private val adapter: RealtimeClientAdapter, + private val httpModule: Http, + private val agents: Map, +) : SdkWrapperCompatible, RealtimeClient by adapter { + + override val channels: Channels + get() = WrapperRealtimeChannels(javaClient.channels, httpModule, agents) + + override fun createWrapperSdkProxy(options: WrapperSdkProxyOptions): RealtimeClient = + adapter.createWrapperSdkProxy(options.copy(agents = options.agents + agents)) + + override fun time(): Long = javaClient.time(httpModule) + + override fun timeAsync(callback: Callback) = javaClient.timeAsync(httpModule, callback) + + override fun stats( + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ): PaginatedResult = + javaClient.stats(httpModule, buildStatsParams(start, end, limit, orderBy, unit).toTypedArray()) + + override fun statsAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ) = javaClient.statsAsync(httpModule, buildStatsParams(start, end, limit, orderBy, unit).toTypedArray(), callback) + + override fun request( + path: String, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.request(httpModule, method.toString(), path, params.toTypedArray(), body, headers.toTypedArray())!! + + override fun requestAsync( + path: String, + callback: AsyncHttpPaginatedResponse.Callback, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.requestAsync( + httpModule, + method.toString(), + path, + params.toTypedArray(), + body, + headers.toTypedArray(), + callback + ) +} + +internal class WrapperRealtimeChannels( + private val javaChannels: AblyRealtime.Channels, + private val httpModule: Http, + private val agents: Map, +) : + Channels by RealtimeChannelsAdapter(javaChannels) { + + override fun get(name: String): RealtimeChannel { + if (javaChannels.containsKey(name)) return WrapperRealtimeChannel(javaChannels.get(name), httpModule) + return try { + WrapperRealtimeChannel(javaChannels.get(name, ChannelOptions().injectAgents(agents)), httpModule) + } catch (e: AblyException) { + WrapperRealtimeChannel(javaChannels.get(name), httpModule) + } + + } + + override fun get(name: String, options: ChannelOptions): RealtimeChannel = + WrapperRealtimeChannel(javaChannels.get(name, options.injectAgents(agents)), httpModule) +} + +@OptIn(InternalAPI::class) +internal class WrapperRealtimeChannel(override val javaChannel: Channel, private val httpModule: Http) : + RealtimeChannel by RealtimeChannelAdapter(javaChannel) { + + override val presence: RealtimePresence + get() = WrapperRealtimePresence(javaChannel.presence, httpModule) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaChannel.history(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaChannel.historyAsync(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} + +internal class WrapperRealtimePresence(private val javaPresence: Presence, private val httpModule: Http) : + RealtimePresence by RealtimePresenceAdapter(javaPresence) { + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaPresence.history(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaPresence.historyAsync(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} + +private fun ChannelOptions.injectAgents(agents: Map): ChannelOptions { + val options = ChannelOptions() + options.params = (this.params ?: mapOf()) + mapOf( + "agent" to agents.map { "${it.key}/${it.value}" }.joinToString(" "), + ) + options.modes = modes + options.cipherParams = cipherParams + options.attachOnSubscribe = attachOnSubscribe + options.encrypted = encrypted + return options +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestChannelAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestChannelAdapter.kt new file mode 100644 index 000000000..29a00d1c9 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestChannelAdapter.kt @@ -0,0 +1,38 @@ +package io.ably.lib.rest + +import com.ably.pubsub.RestChannel +import com.ably.pubsub.RestPresence +import com.ably.query.OrderBy +import io.ably.lib.buildHistoryParams +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.types.* + +internal class RestChannelAdapter(private val javaChannel: Channel) : RestChannel { + override val name: String + get() = javaChannel.name + + override val presence: RestPresence + get() = RestPresenceAdapter(javaChannel.presence) + + override fun publish(name: String?, data: Any?) = javaChannel.publish(name, data) + + override fun publish(messages: List) = javaChannel.publish(messages.toTypedArray()) + + override fun publishAsync(name: String?, data: Any?, listener: CompletionListener) = + javaChannel.publishAsync(name, data, listener) + + override fun publishAsync(messages: List, listener: CompletionListener) = + javaChannel.publishAsync(messages.toTypedArray(), listener) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaChannel.history(buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaChannel.historyAsync(buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestChannelsAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestChannelsAdapter.kt new file mode 100644 index 000000000..083a2ce2a --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestChannelsAdapter.kt @@ -0,0 +1,20 @@ +package io.ably.lib.rest + +import com.ably.pubsub.Channels +import com.ably.pubsub.RestChannel +import io.ably.lib.types.ChannelOptions + +internal class RestChannelsAdapter(private val javaChannels: AblyBase.Channels) : Channels { + override fun contains(name: String): Boolean = javaChannels.containsKey(name) + + override fun get(name: String): RestChannel = RestChannelAdapter(javaChannels.get(name)) + + override fun get(name: String, options: ChannelOptions): RestChannel = + RestChannelAdapter(javaChannels.get(name, options)) + + override fun release(name: String) = javaChannels.release(name) + + override fun iterator(): Iterator = iterator { + javaChannels.entrySet().forEach { yield(RestChannelAdapter(it.value)) } + } +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestClientAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestClientAdapter.kt new file mode 100644 index 000000000..c9b6c47f2 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestClientAdapter.kt @@ -0,0 +1,72 @@ +package io.ably.lib.rest + +import com.ably.http.HttpMethod +import com.ably.pubsub.* +import com.ably.query.OrderBy +import com.ably.query.TimeUnit +import io.ably.lib.buildStatsParams +import io.ably.lib.http.HttpCore +import io.ably.lib.push.Push +import io.ably.lib.types.* + +/** + * Wrapper for Rest client + */ +public fun RestClient(javaClient: AblyRest): RestClient = RestClientAdapter(javaClient) + +internal class RestClientAdapter(private val javaClient: AblyRest) : RestClient, SdkWrapperCompatible { + override val channels: Channels + get() = RestChannelsAdapter(javaClient.channels) + override val auth: Auth + get() = javaClient.auth + override val options: ClientOptions + get() = javaClient.options + override val push: Push + get() = javaClient.push + + override fun time(): Long = javaClient.time() + + override fun timeAsync(callback: Callback) = javaClient.timeAsync(callback) + + override fun stats( + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ): PaginatedResult = javaClient.stats(buildStatsParams(start, end, limit, orderBy, unit).toTypedArray()) + + override fun statsAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ) = javaClient.statsAsync(buildStatsParams(start, end, limit, orderBy, unit).toTypedArray(), callback) + + override fun request( + path: String, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.request(method.toString(), path, params.toTypedArray(), body, headers.toTypedArray())!! + + override fun requestAsync( + path: String, + callback: AsyncHttpPaginatedResponse.Callback, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.requestAsync(method.toString(), path, params.toTypedArray(), body, headers.toTypedArray(), callback) + + override fun close() = javaClient.close() + + override fun createWrapperSdkProxy(options: WrapperSdkProxyOptions): RestClient { + val httpCoreWithAgents = javaClient.httpCore.injectDynamicAgents(options.agents) + val httpModule = javaClient.http.exchangeHttpCore(httpCoreWithAgents) + return WrapperRestClient(javaClient, this, httpModule, options.agents) + } +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestClientUtils.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestClientUtils.kt new file mode 100644 index 000000000..d3b57bc42 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestClientUtils.kt @@ -0,0 +1,46 @@ +package io.ably.lib.rest + +import com.ably.annotations.InternalAPI +import io.ably.lib.http.Http +import io.ably.lib.http.HttpCore +import io.ably.lib.types.* + +@InternalAPI +@Suppress("EXTENSION_SHADOWED_BY_MEMBER") +public fun AblyBase.time(http: Http): Long = time(http) + +@InternalAPI +@Suppress("EXTENSION_SHADOWED_BY_MEMBER") +public fun AblyBase.timeAsync(http: Http, callback: Callback): Unit = timeAsync(http, callback) + +@InternalAPI +@Suppress("EXTENSION_SHADOWED_BY_MEMBER") +public fun AblyBase.stats(http: Http, params: Array): PaginatedResult = stats(http, params) + +@InternalAPI +@Suppress("EXTENSION_SHADOWED_BY_MEMBER") +public fun AblyBase.statsAsync(http: Http, params: Array, callback: Callback>): Unit = + this.statsAsync(http, params, callback) + +@InternalAPI +@Suppress("EXTENSION_SHADOWED_BY_MEMBER") +public fun AblyBase.request( + http: Http, + method: String, + path: String, + params: Array?, + body: HttpCore.RequestBody?, + headers: Array? +): HttpPaginatedResponse = this.request(http, method, path, params, body, headers) + +@InternalAPI +@Suppress("EXTENSION_SHADOWED_BY_MEMBER") +public fun AblyBase.requestAsync( + http: Http, + method: String?, + path: String?, + params: Array?, + body: HttpCore.RequestBody?, + headers: Array?, + callback: AsyncHttpPaginatedResponse.Callback? +): Unit = this.requestAsync(http, method, path, params, body, headers, callback) diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestPresenceAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestPresenceAdapter.kt new file mode 100644 index 000000000..1b4267b09 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/RestPresenceAdapter.kt @@ -0,0 +1,39 @@ +package io.ably.lib.rest + +import com.ably.pubsub.RestPresence +import com.ably.query.OrderBy +import io.ably.lib.buildHistoryParams +import io.ably.lib.buildRestPresenceParams +import io.ably.lib.rest.ChannelBase.Presence +import io.ably.lib.types.AsyncPaginatedResult +import io.ably.lib.types.Callback +import io.ably.lib.types.PaginatedResult +import io.ably.lib.types.PresenceMessage + +internal class RestPresenceAdapter(private val javaPresence: Presence) : RestPresence { + override fun get( + limit: Int, + clientId: String?, + connectionId: String?, + ): PaginatedResult = + javaPresence.get(buildRestPresenceParams(limit, clientId, connectionId).toTypedArray()) + + override fun getAsync( + callback: Callback>, limit: Int, + clientId: String?, + connectionId: String?, + ) = + javaPresence.getAsync(buildRestPresenceParams(limit, clientId, connectionId).toTypedArray(), callback) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaPresence.history(buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaPresence.historyAsync(buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/WrapperRestClient.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/WrapperRestClient.kt new file mode 100644 index 000000000..6cf3c2dc0 --- /dev/null +++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/rest/WrapperRestClient.kt @@ -0,0 +1,138 @@ +package io.ably.lib.rest + +import com.ably.http.HttpMethod +import com.ably.pubsub.* +import com.ably.query.OrderBy +import com.ably.query.TimeUnit +import io.ably.lib.buildHistoryParams +import io.ably.lib.buildRestPresenceParams +import io.ably.lib.buildStatsParams +import io.ably.lib.http.Http +import io.ably.lib.http.HttpCore +import io.ably.lib.realtime.CompletionListener +import io.ably.lib.rest.ChannelBase.Presence +import io.ably.lib.types.* + +internal class WrapperRestClient( + private val javaClient: AblyRest, + private val adapter: RestClientAdapter, + private val httpModule: Http, + private val agents: Map, +) : SdkWrapperCompatible, RestClient by adapter { + override val channels: Channels + get() = WrapperRestChannels(javaClient.channels, httpModule) + + override fun createWrapperSdkProxy(options: WrapperSdkProxyOptions): RestClient = + adapter.createWrapperSdkProxy(options.copy(agents = options.agents + agents)) + + override fun time(): Long = javaClient.time(httpModule) + + override fun timeAsync(callback: Callback) = javaClient.timeAsync(httpModule, callback) + + override fun stats( + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ): PaginatedResult = + javaClient.stats(httpModule, buildStatsParams(start, end, limit, orderBy, unit).toTypedArray()) + + override fun statsAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + unit: TimeUnit + ) = javaClient.statsAsync(httpModule, buildStatsParams(start, end, limit, orderBy, unit).toTypedArray(), callback) + + override fun request( + path: String, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.request(httpModule, method.toString(), path, params.toTypedArray(), body, headers.toTypedArray())!! + + override fun requestAsync( + path: String, + callback: AsyncHttpPaginatedResponse.Callback, + method: HttpMethod, + params: List, + body: HttpCore.RequestBody?, + headers: List, + ) = javaClient.requestAsync( + httpModule, + method.toString(), + path, + params.toTypedArray(), + body, + headers.toTypedArray(), + callback + ) + +} + +internal class WrapperRestChannels(private val javaChannels: AblyBase.Channels, private val httpModule: Http) : + Channels by RestChannelsAdapter(javaChannels) { + override fun get(name: String): RestChannel = WrapperRestChannel(javaChannels.get(name), httpModule) + + override fun get(name: String, options: ChannelOptions): RestChannel = + WrapperRestChannel(javaChannels.get(name, options), httpModule) +} + +internal class WrapperRestChannel(private val javaChannel: Channel, private val httpModule: Http) : + RestChannel by RestChannelAdapter(javaChannel) { + + override val presence: RestPresence + get() = WrapperRestPresence(javaChannel.presence, httpModule) + + override fun publish(name: String?, data: Any?) = javaChannel.publish(httpModule, name, data) + + override fun publish(messages: List) = javaChannel.publish(httpModule, messages.toTypedArray()) + + override fun publishAsync(name: String?, data: Any?, listener: CompletionListener) = + javaChannel.publishAsync(httpModule, name, data, listener) + + override fun publishAsync(messages: List, listener: CompletionListener) = + javaChannel.publishAsync(httpModule, messages.toTypedArray(), listener) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaChannel.history(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaChannel.historyAsync(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} + +internal class WrapperRestPresence(private val javaPresence: Presence, private val httpModule: Http) : + RestPresence by RestPresenceAdapter(javaPresence) { + override fun get(limit: Int, clientId: String?, connectionId: String?): PaginatedResult = + javaPresence.get(buildRestPresenceParams(limit, clientId, connectionId).toTypedArray()) + + override fun getAsync( + callback: Callback>, + limit: Int, + clientId: String?, + connectionId: String? + ) = + javaPresence.getAsync(buildRestPresenceParams(limit, clientId, connectionId).toTypedArray(), callback) + + override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult = + javaPresence.history(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray()) + + override fun historyAsync( + callback: Callback>, + start: Long?, + end: Long?, + limit: Int, + orderBy: OrderBy, + ) = + javaPresence.historyAsync(httpModule, buildHistoryParams(start, end, limit, orderBy).toTypedArray(), callback) +} diff --git a/pubsub-adapter/src/test/kotlin/com/ably/EmbeddedServer.kt b/pubsub-adapter/src/test/kotlin/com/ably/EmbeddedServer.kt new file mode 100644 index 000000000..61d31decd --- /dev/null +++ b/pubsub-adapter/src/test/kotlin/com/ably/EmbeddedServer.kt @@ -0,0 +1,62 @@ +package com.ably + +import fi.iki.elonen.NanoHTTPD +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.io.ByteArrayInputStream + +data class Request( + val path: String, + val params: Map = emptyMap(), + val headers: Map = emptyMap(), +) + +data class Response( + val mimeType: String, + val data: ByteArray, + val headers: Map = emptyMap(), +) + +fun json(json: String, headers: Map = emptyMap()): Response = Response( + mimeType = "application/json", + data = json.toByteArray(), + headers = headers, +) + +fun interface RequestHandler { + fun handle(request: Request): Response +} + +class EmbeddedServer(port: Int, private val requestHandler: RequestHandler? = null) : NanoHTTPD(port) { + private val _servedRequests = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val servedRequests: Flow = _servedRequests + + override fun serve(session: IHTTPSession): Response { + val request = Request( + path = session.uri, + params = session.parms, + headers = session.headers, + ) + _servedRequests.tryEmit(request) + val response = requestHandler?.handle(request) + return response?.toNanoHttp() ?: newFixedLengthResponse("404") + } + + override fun start() { + start(SOCKET_READ_TIMEOUT, true) + } +} + +private fun Response.toNanoHttp(): NanoHTTPD.Response = NanoHTTPD.newFixedLengthResponse( + NanoHTTPD.Response.Status.OK, + mimeType, + ByteArrayInputStream(data), + data.size.toLong(), +).apply { + headers.forEach { (key, value) -> addHeader(key, value) } +} diff --git a/pubsub-adapter/src/test/kotlin/com/ably/Utils.kt b/pubsub-adapter/src/test/kotlin/com/ably/Utils.kt new file mode 100644 index 000000000..e21f27910 --- /dev/null +++ b/pubsub-adapter/src/test/kotlin/com/ably/Utils.kt @@ -0,0 +1,46 @@ +package com.ably + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.rest.AblyRest +import io.ably.lib.types.ClientOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +suspend fun waitFor(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) { + withContext(Dispatchers.Default) { + withTimeout(timeoutInMs) { + do { + val success = block() + delay(100) + } while (!success) + } + } +} + +fun createAblyRealtime(port: Int): AblyRealtime { + val options = ClientOptions("xxxxx:yyyyyyy").apply { + this.port = port + useBinaryProtocol = false + realtimeHost = "localhost" + restHost = "localhost" + tls = false + autoConnect = false + } + + return AblyRealtime(options) +} + +fun createAblyRest(port: Int): AblyRest { + val options = ClientOptions("xxxxx:yyyyyyy").apply { + this.port = port + useBinaryProtocol = false + realtimeHost = "localhost" + restHost = "localhost" + tls = false + autoConnect = false + } + + return AblyRest(options) +} diff --git a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentChannelParamTest.kt b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentChannelParamTest.kt new file mode 100644 index 000000000..dbf4ca9c6 --- /dev/null +++ b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentChannelParamTest.kt @@ -0,0 +1,89 @@ +package com.ably.pubsub + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.RealtimeClient +import io.ably.lib.realtime.RealtimeClientAdapter +import io.ably.lib.realtime.channelOptions +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ClientOptions +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SdkWrapperAgentChannelParamTest { + + @Test + fun `should add agent information to Realtime channels params`() = runTest { + val javaRealtimeClient = createAblyRealtime() + val realtimeClient = RealtimeClientAdapter(javaRealtimeClient) + val wrapperSdkClient = + realtimeClient.createWrapperSdkProxy(WrapperSdkProxyOptions(agents = mapOf("chat-android" to "0.1.0"))) + + // create channel from sdk proxy wrapper + wrapperSdkClient.channels.get("chat-channel") + + // create channel without sdk proxy wrapper + realtimeClient.channels.get("regular-channel") + + assertEquals( + "chat-android/0.1.0", + javaRealtimeClient.channels.get("chat-channel").channelOptions?.params?.get("agent") + ) + + assertNull( + javaRealtimeClient.channels.get("regular-channel").channelOptions?.params?.get("agent") + ) + } + + @Test + fun `should add agent information to Realtime channels params when channel created with custom options`() = runTest { + val javaRealtimeClient = createAblyRealtime() + val realtimeClient = RealtimeClient(javaRealtimeClient) + val wrapperSdkClient = + realtimeClient.createWrapperSdkProxy(WrapperSdkProxyOptions(agents = mapOf("chat-android" to "0.1.0"))) + + // create channel from sdk proxy wrapper + wrapperSdkClient.channels.get("chat-channel", ChannelOptions().apply { + params = mapOf("foo" to "bar") + modes = arrayOf(ChannelMode.presence) + }) + + // create channel without sdk proxy wrapper + realtimeClient.channels.get("regular-channel", ChannelOptions().apply { + encrypted = true + }) + + assertEquals( + "chat-android/0.1.0", + javaRealtimeClient.channels.get("chat-channel").channelOptions?.params?.get("agent") + ) + + assertEquals( + "bar", + javaRealtimeClient.channels.get("chat-channel").channelOptions?.params?.get("foo") + ) + + assertEquals( + ChannelMode.presence, + javaRealtimeClient.channels.get("chat-channel").channelOptions?.modes?.get(0) + ) + + assertNull( + javaRealtimeClient.channels.get("regular-channel").channelOptions?.params?.get("agent") + ) + + assertTrue( + javaRealtimeClient.channels.get("regular-channel").channelOptions?.encrypted ?: false + ) + } +} + +private fun createAblyRealtime(): AblyRealtime { + val options = ClientOptions("xxxxx:yyyyyyy").apply { + autoConnect = false + } + return AblyRealtime(options) +} diff --git a/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt new file mode 100644 index 000000000..d91359b8c --- /dev/null +++ b/pubsub-adapter/src/test/kotlin/com/ably/pubsub/SdkWrapperAgentHeaderTest.kt @@ -0,0 +1,176 @@ +package com.ably.pubsub + +import app.cash.turbine.test +import com.ably.EmbeddedServer +import com.ably.createAblyRest +import com.ably.createAblyRealtime +import com.ably.json +import com.ably.waitFor +import io.ably.lib.BuildConfig +import io.ably.lib.realtime.RealtimeClient +import io.ably.lib.rest.RestClient +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import kotlin.test.Test +import kotlin.test.assertEquals + +class SdkWrapperAgentHeaderTest { + + @Test + fun `should use additional agents in Realtime wrapper SDK client calls`() = runTest { + val realtimeClient = createRealtimeClient() + + val wrapperSdkClient = + realtimeClient.createWrapperSdkProxy(WrapperSdkProxyOptions(agents = mapOf("chat-android" to "0.1.0"))) + + server.servedRequests.test { + wrapperSdkClient.time() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + realtimeClient.time() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + wrapperSdkClient.request("/time") + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + } + + @Test + fun `should use additional agents in Rest wrapper SDK client calls`() = runTest { + val restClient = createRealtimeClient() + + val wrapperSdkClient = + restClient.createWrapperSdkProxy(WrapperSdkProxyOptions(agents = mapOf("chat-android" to "0.1.0"))) + + server.servedRequests.test { + wrapperSdkClient.time() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + restClient.time() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + wrapperSdkClient.request("/time") + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + } + + @Test + fun `should use additional agents in Rest wrapper SDK channel calls`() = runTest { + val restClient = createRestClient() + + val wrapperSdkClient = + restClient.createWrapperSdkProxy(WrapperSdkProxyOptions(agents = mapOf("chat-android" to "0.1.0"))) + + server.servedRequests.test { + wrapperSdkClient.channels.get("test").history() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + restClient.channels.get("test").history() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + wrapperSdkClient.channels.get("test").presence.history() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + } + + @Test + fun `should use additional agents in Realtime wrapper SDK channel calls`() = runTest { + val realtimeClient = createRealtimeClient() + + val wrapperSdkClient = + realtimeClient.createWrapperSdkProxy(WrapperSdkProxyOptions(agents = mapOf("chat-android" to "0.1.0"))) + + server.servedRequests.test { + wrapperSdkClient.channels.get("test").history() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + realtimeClient.channels.get("test").history() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + + server.servedRequests.test { + wrapperSdkClient.channels.get("test").presence.history() + assertEquals( + setOf("ably-java/${BuildConfig.VERSION}", "jre/${System.getProperty("java.version")}", "chat-android/0.1.0"), + awaitItem().headers["ably-agent"]?.split(" ")?.toSet(), + ) + } + } + + companion object { + + private const val PORT = 27332 + private lateinit var server: EmbeddedServer + + @JvmStatic + @BeforeAll + fun setUp() = runTest { + server = EmbeddedServer(PORT) { + when (it.path) { + "/time" -> json("[1739551931167]") + else -> json("[]") + } + } + server.start() + waitFor { server.wasStarted() } + } + + @JvmStatic + @AfterAll + fun tearDown() { + server.stop() + } + + private fun createRealtimeClient(): RealtimeClient = RealtimeClient(createAblyRealtime(PORT)) + + private fun createRestClient(): RestClient = RestClient(createAblyRest(PORT)) + } +} diff --git a/pubsub-adapter/src/test/kotlin/io/ably/lib/RequestsTest.kt b/pubsub-adapter/src/test/kotlin/io/ably/lib/RequestsTest.kt new file mode 100644 index 000000000..5ecbebd96 --- /dev/null +++ b/pubsub-adapter/src/test/kotlin/io/ably/lib/RequestsTest.kt @@ -0,0 +1,92 @@ +package io.ably.lib + +import app.cash.turbine.test +import com.ably.* +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.types.AsyncHttpPaginatedResponse +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Param +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.test.Test +import kotlin.test.assertEquals + +class RequestsTest { + + @Test + fun `should encode params on pagination requests`() = runTest { + val client = createAblyRealtime() + server.servedRequests.test { + val paginatedResult = client.request("GET", "/page", arrayOf(Param("foo", "b a r")), null, null) + assertEquals(mapOf("foo" to "b a r"), awaitItem().params) + paginatedResult.next() + assertEquals(mapOf("param" to "1@1 2"), awaitItem().params) + } + } + + @Test + fun `should encode params on async pagination requests`() = runTest { + val client = createAblyRealtime() + server.servedRequests.test { + val paginatedResult = suspendCancellableCoroutine { continuation -> + client.requestAsync("GET", "/page", arrayOf(Param("foo", "b a r")), null, null, object : AsyncHttpPaginatedResponse.Callback { + override fun onResponse(response: AsyncHttpPaginatedResponse?) { + continuation.resume(response!!) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(IllegalArgumentException(reason.toString())) + } + + }) + } + assertEquals(mapOf("foo" to "b a r"), awaitItem().params) + suspendCancellableCoroutine { continuation -> + paginatedResult.next(object : AsyncHttpPaginatedResponse.Callback { + override fun onResponse(response: AsyncHttpPaginatedResponse?) { + continuation.resume(response!!) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(IllegalArgumentException(reason.toString())) + } + }) + } + assertEquals(mapOf("param" to "1@1 2"), awaitItem().params) + } + } + + companion object { + + private const val PORT = 27332 + private lateinit var server: EmbeddedServer + + @JvmStatic + @BeforeAll + fun setUp() = runTest { + server = EmbeddedServer(PORT) { + when (it.path) { + "/page" -> json("[]", buildMap { + put("Link", "<./page?param=1%401%202>; rel=\"next\"") + }) + + else -> error("Unhandled ${it.path}") + } + } + server.start() + waitFor { server.wasStarted() } + } + + @JvmStatic + @AfterAll + fun tearDown() { + server.stop() + } + + private fun createAblyRealtime(): AblyRealtime = createAblyRealtime(PORT) + } +} diff --git a/pubsub-adapter/src/test/kotlin/io/ably/lib/realtime/ChannelUtils.kt b/pubsub-adapter/src/test/kotlin/io/ably/lib/realtime/ChannelUtils.kt new file mode 100644 index 000000000..a99e5a67f --- /dev/null +++ b/pubsub-adapter/src/test/kotlin/io/ably/lib/realtime/ChannelUtils.kt @@ -0,0 +1,6 @@ +package io.ably.lib.realtime + +import io.ably.lib.types.ChannelOptions + +val ChannelBase.channelOptions: ChannelOptions? + get() = options diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index c0b5664d4..000000000 --- a/settings.gradle +++ /dev/null @@ -1,8 +0,0 @@ -rootProject.name = 'ably-java' -include 'java' - -if (System.getenv('ANDROID_HOME')) { - include 'android' -} - -include 'gradle-lint' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..6d7d6ba8c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "ably-java" + +include("java") +include("android") +include("gradle-lint") +include("network-client-core") +include("network-client-default") +include("network-client-okhttp") +include("pubsub-adapter") +include("live-objects")