From 7d70cd93d2206b838486c2f941ad6b8b9309f702 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 24 Sep 2024 15:10:03 +0100 Subject: [PATCH 1/3] chore: update gradle wrapper --- .editorconfig | 2 +- .github/workflows/check.yml | 7 +- .github/workflows/emulate.yml | 6 + .github/workflows/integration-test.yml | 12 ++ .github/workflows/javadoc.yml | 4 +- CONTRIBUTING.md | 13 +- android/build.gradle | 105 ---------------- android/build.gradle.kts | 66 ++++++++++ android/gradle.properties | 4 + android/maven.gradle | 149 ----------------------- android/src/main/AndroidManifest.xml | 5 +- build.gradle | 14 --- build.gradle.kts | 35 ++++++ common.gradle | 12 -- dependencies.gradle | 16 --- gradle.properties | 18 +++ gradle/libs.versions.toml | 49 ++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 55190 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 53 +++++--- gradlew.bat | 43 ++++--- java/build.gradle | 113 ----------------- java/build.gradle.kts | 89 ++++++++++++++ java/gradle.properties | 4 + java/maven.gradle | 123 ------------------- settings.gradle | 4 - settings.gradle.kts | 13 ++ 27 files changed, 370 insertions(+), 592 deletions(-) delete mode 100644 android/build.gradle create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties delete mode 100644 android/maven.gradle delete mode 100644 build.gradle create mode 100644 build.gradle.kts delete mode 100644 common.gradle delete mode 100644 dependencies.gradle create mode 100644 gradle/libs.versions.toml delete mode 100644 java/build.gradle create mode 100644 java/build.gradle.kts create mode 100644 java/gradle.properties delete mode 100644 java/maven.gradle delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts 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 index 4f4e9376d..98508cf54 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,4 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - run: ./gradlew checkstyleMain checkstyleTest checkWithCodenarc runUnitTests + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests diff --git a/.github/workflows/emulate.yml b/.github/workflows/emulate.yml index 64eda9500..10a3ad2d5 100644 --- a/.github/workflows/emulate.yml +++ b/.github/workflows/emulate.yml @@ -18,6 +18,12 @@ jobs: - name: checkout uses: actions/checkout@v4 + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 08a3b459f..64db71862 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -15,6 +15,12 @@ jobs: with: submodules: 'recursive' + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - run: ./gradlew :java:testRestSuite - uses: actions/upload-artifact@v3 @@ -30,6 +36,12 @@ jobs: with: submodules: 'recursive' + - name: Set up the JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - run: ./gradlew :java:testRealtimeSuite - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/javadoc.yml b/.github/workflows/javadoc.yml index fff44a37b..6c5ccdcbc 100644 --- a/.github/workflows/javadoc.yml +++ b/.github/workflows/javadoc.yml @@ -25,8 +25,8 @@ jobs: - name: Set up the JDK uses: actions/setup-java@v3 with: - java-version: '11' - distribution: 'adopt' + java-version: '17' + distribution: 'temurin' - name: Build docs run: ./gradlew javadoc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b98399897..7bf2ca18c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -201,8 +201,7 @@ implementation files('libs/ably-android-1.2.42.aar') 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 [common.gradle](./common.gradle)) and commit the changes - a. Increment the `versionCode` in the Android project's `build.gradle` by 1 +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. @@ -212,12 +211,10 @@ This library uses [semantic versioning](http://semver.org/). For each release, t 5. Make a PR against `main` 6. Once the PR is approved, merge it into `main` 7. From the updated `main` branch on your local workstation, assemble and upload: - 1. Run `./gradlew java:assembleRelease -PpublishTarget=MavenCentral` to build and upload `ably-java` to Nexus staging repository - 2. Run `./gradlew android:assembleRelease -PpublishTarget=MavenCentral` build and upload `ably-android` to Nexus staging repository - 3. Find the new staging repository using the [Nexus Repository Manager](https://oss.sonatype.org/#stagingRepositories) - 4. Check that it contains `ably-android` and `ably-java` releases - 5. "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" - 6. 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) + 1. Run `./gradlew publishToMavenCentral` to build and upload `ably-java` and `ably-android` to Nexus staging repository + 2. Find the new staging repository using the [Nexus Repository Manager](https://oss.sonatype.org/#stagingRepositories) + 3. Check that it contains `ably-android` and `ably-java` releases + 4. "Release" it - this will take a few minutes during which time it will say (after a refresh of your browser) that "Activity: Operation in Progress". 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) 7. 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` 8. Add a tag and push to origin - e.g.: `git tag v1.2.4 && git push origin v1.2.4` 9. Create the release on Github including populating the release notes diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index c422c3373..000000000 --- a/android/build.gradle +++ /dev/null @@ -1,105 +0,0 @@ -buildscript { - repositories { - mavenCentral() - mavenLocal() - google() - } - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - } -} - -apply plugin: 'com.android.library' -apply from: '../common.gradle' - -ext { - artifactId = 'ably-android' -} - -allprojects { - repositories { - google() - } -} - -android { - compileSdkVersion 30 - - defaultConfig { - buildConfigField 'String', 'LIBRARY_NAME', '"android"' - buildConfigField 'String', 'VERSION', "\"$version\"" - minSdkVersion 19 - targetSdkVersion 30 - // This MUST be incremented by 1 on each ably-java release - versionCode 17 - 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' - } - - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } - - 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' -dependencies { - implementation 'com.google.firebase:firebase-messaging:22.0.0' - 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' - androidTestImplementation 'net.sourceforge.streamsupport:android-retrostreams:1.7.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..c66a5ee66 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.maven.publish) +} + +android { + namespace = "io.ably.lib" + defaultConfig { + minSdk = 19 + compileSdk = 30 + 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 = 30 + + 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) + testImplementation(libs.bundles.tests) + 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/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 88e1ec7ae..000000000 --- a/android/maven.gradle +++ /dev/null @@ -1,149 +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 = findProperty('ossrhUsername') -final String MAVEN_PASSWORD = findProperty('ossrhPassword') - -final boolean IS_PUBLISHING_TO_MAVEN_CENTRAL = findProperty('publishTarget') == 'MavenCentral' -if (IS_PUBLISHING_TO_MAVEN_CENTRAL && (MAVEN_USER == null || MAVEN_PASSWORD == null)) { - throw new GradleException('Either ossrhUsername or ossrhPassword not specified when publishTarget is MavenCentral.') -} - -/* - * Task which signs and uploads the Android artifacts to Nexus OSSRH. - */ -uploadArchives { - signing { - sign configurations.archives - } - repositories.mavenDeployer { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - - pom.groupId = GROUP_ID - pom.artifactId = ARTIFACT_ID - pom.version = version - - pom.project { - name 'Ably Android client library SDK' - description 'An Android Realtime and REST client library SDK for the Ably platform.' - packaging 'aar' - inceptionYear '2015' - url 'https://www.github.com/ably/ably-java' - developers { - developer { - id 'ably' // our company org in GitHub: https://github.com/ably - name 'Ably' // UK based company: Ably Real-time Ltd - email 'support@ably.com' - url 'https://ably.com/' - } - } - scm { - url 'https://github.com/ably/ably-java' - connection 'scm:git:git://github.com/ably/ably-java.git' - developerConnection 'scm:git:ssh://github.com/ably/ably-java.git' - tag = 'v' + version - } - organization { - name 'Ably' // UK based company: Ably Real-time Ltd - url 'https://ably.com/' - } - 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' - } - } - - if (IS_PUBLISHING_TO_MAVEN_CENTRAL) { - 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) - } - } else { - // Export to local Maven cache - repository(url: repositories.mavenLocal().url) - - // Export files to local storage - 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 { - if (IS_PUBLISHING_TO_MAVEN_CENTRAL) { - logger.quiet('✅ Release uploaded to Sonatype Staging Repository') - } else { - 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 - title = 'Ably documentation' - options.overview = '../overview.html' -} - -afterEvaluate { - javadoc.classpath += files(android.libraryVariants.collect { variant -> - variant.javaCompile.classpath.files - }) -} - -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' - from javadoc.destinationDir - javadoc.title = 'Ably documentation' - javadoc.options.overview = '../overview.html' -} - -artifacts { - archives sourcesJar - archives javadocJar -} 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/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..d031f19d9 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,35 @@ +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SonatypeHost + +// 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.maven.publish) 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 { + publishToMavenCentral(SonatypeHost.DEFAULT) + signAllPublications() + } + } +} diff --git a/common.gradle b/common.gradle deleted file mode 100644 index 7d98567bd..000000000 --- a/common.gradle +++ /dev/null @@ -1,12 +0,0 @@ -repositories { - mavenCentral() -} - -group = 'io.ably' -version = '1.2.42' -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 6d1b9ea12..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.5.3' - implementation 'com.google.code.gson:gson:2.9.0' - 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.properties b/gradle.properties index d9cf55df7..b24da7ddf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,20 @@ +GROUP=io.ably +VERSION_NAME=1.2.42 + +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 + 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..241d7195c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,49 @@ +[versions] +agp = "8.5.2" +junit = "4.12" +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 = "0.5" +dexmaker = "1.4" +android-retrostreams = "1.7.4" +maven-publish = "0.29.0" + +[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" } + +[bundles] +common = ["msgpack", "java-websocket", "vcdiff-core"] +tests = ["junit","hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"] +instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"] + +[plugins] +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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 87b738cbd051603d91cc39de6cb000dd98fe6b02..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f 100644 GIT binary patch delta 26193 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

$|c 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∨fd|u2@Kuk2 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 F_djzcFxCJ7 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/java/build.gradle b/java/build.gradle deleted file mode 100644 index a4637f926..000000000 --- a/java/build.gradle +++ /dev/null @@ -1,113 +0,0 @@ -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.8 -targetCompatibility = 1.8 - -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 } - testLogging.exceptionFormat = 'full' -} - -task testRestSuite(type: Test) { - filter { - includeTestsMatching '*RestSuite' - } - beforeTest { descriptor -> - logger.lifecycle("-> $descriptor") - } - outputs.upToDateWhen { false } - testLogging.exceptionFormat = 'full' -} - -/* -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..21c3f87af --- /dev/null +++ b/java/build.gradle.kts @@ -0,0 +1,89 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + alias(libs.plugins.build.config) + alias(libs.plugins.maven.publish) + checkstyle + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +dependencies { + api(libs.gson) + implementation(libs.bundles.common) + 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 + } +} + +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 + } +} + +/* +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 0679702d7..000000000 --- a/java/maven.gradle +++ /dev/null @@ -1,123 +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 = findProperty('ossrhUsername') -final String MAVEN_PASSWORD = findProperty('ossrhPassword') - -final boolean IS_PUBLISHING_TO_MAVEN_CENTRAL = findProperty('publishTarget') == 'MavenCentral' -if (IS_PUBLISHING_TO_MAVEN_CENTRAL && (MAVEN_USER == null || MAVEN_PASSWORD == null)) { - throw new GradleException('Either ossrhUsername or ossrhPassword not specified when publishTarget is MavenCentral.') -} - -/* - * Task which signs and uploads the Java artifacts to Nexus OSSRH. - */ -uploadArchives { - signing { - sign configurations.archives - } - repositories.mavenDeployer { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - - pom.groupId = GROUP_ID - pom.artifactId = ARTIFACT_ID - pom.version = version - - pom.project { - name 'Ably Java client library SDK' - description 'A Java Realtime and REST client library SDK for the Ably platform.' - packaging 'jar' - inceptionYear '2015' - url 'https://www.github.com/ably/ably-java' - developers { - developer { - id 'ably' // our company org in GitHub: https://github.com/ably - name 'Ably' // UK based company: Ably Real-time Ltd - email 'support@ably.com' - url 'https://ably.com/' - } - } - scm { - url 'https://github.com/ably/ably-java' - connection 'scm:git:git://github.com/ably/ably-java.git' - developerConnection 'scm:git:ssh://github.com/ably/ably-java.git' - tag = 'v' + version - } - organization { - name 'Ably' // UK based company: Ably Real-time Ltd - url 'https://ably.com/' - } - 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' - } - } - - if (IS_PUBLISHING_TO_MAVEN_CENTRAL) { - 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) - } - } else { - // Export files to local storage - repository(url: "file://${LOCAL_RELEASE_DESTINATION}") - } - } -} - -task zipRelease(type: Zip) { - from LOCAL_RELEASE_DESTINATION - destinationDir buildDir - archiveName "release-${version}.zip" -} - -task assembleRelease { - doLast { - if (IS_PUBLISHING_TO_MAVEN_CENTRAL) { - logger.quiet('✅ Release uploaded to Sonatype Staging Repository') - } else { - 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 - javadoc.title = 'Ably documentation' - javadoc.options.overview = '../overview.html' -} - -artifacts { - archives sourcesJar - archives javadocJar -} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 9ee5ac941..000000000 --- a/settings.gradle +++ /dev/null @@ -1,4 +0,0 @@ -rootProject.name = 'ably-java' -include 'java', - 'android', - 'gradle-lint' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..220fd80b7 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "ably-java" + +include("java") +include("android") +include("gradle-lint") From 6f38c17cd4c3b839a98c37767a8b88fbb5b30f24 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 23 Sep 2024 16:24:02 +0100 Subject: [PATCH 2/3] refactor: decouple HTTP and WebSocket engines - Extracted HTTP calls and WebSocket listeners into a separate module. - Introduced an abstraction layer for easier implementation swapping. --- .gitignore | 2 + android/build.gradle.kts | 2 + build.gradle.kts | 1 + gradle/libs.versions.toml | 4 +- java/build.gradle.kts | 2 + .../java/io/ably/lib/debug/DebugOptions.java | 4 +- .../main/java/io/ably/lib/http/HttpCore.java | 357 +++++++----------- .../java/io/ably/lib/http/HttpScheduler.java | 10 +- .../lib/transport/WebSocketTransport.java | 111 +++--- .../java/io/ably/lib/types/AblyException.java | 5 +- .../io/ably/lib/util/ClientOptionsUtils.java | 37 ++ .../java/io/ably/lib/test/common/Helpers.java | 12 +- .../java/io/ably/lib/test/rest/HttpTest.java | 26 +- .../io/ably/lib/test/rest/RestAuthTest.java | 6 +- .../lib/test/rest/RestChannelPublishTest.java | 18 +- network-client-core/build.gradle.kts | 9 + .../java/io/ably/lib/network/EngineType.java | 6 + .../network/FailedConnectionException.java | 7 + .../java/io/ably/lib/network/HttpBody.java | 14 + .../java/io/ably/lib/network/HttpCall.java | 6 + .../java/io/ably/lib/network/HttpEngine.java | 6 + .../io/ably/lib/network/HttpEngineConfig.java | 15 + .../ably/lib/network/HttpEngineFactory.java | 35 ++ .../java/io/ably/lib/network/HttpRequest.java | 100 +++++ .../io/ably/lib/network/HttpResponse.java | 21 ++ .../lib/network/NotConnectedException.java | 7 + .../io/ably/lib/network/ProxyAuthType.java | 6 + .../java/io/ably/lib/network/ProxyConfig.java | 22 ++ .../io/ably/lib/network/WebSocketClient.java | 33 ++ .../io/ably/lib/network/WebSocketEngine.java | 5 + .../lib/network/WebSocketEngineConfig.java | 20 + .../lib/network/WebSocketEngineFactory.java | 35 ++ .../ably/lib/network/WebSocketListener.java | 13 + network-client-default/build.gradle.kts | 15 + network-client-default/gradle.properties | 4 + .../io/ably/lib/network/DefaultHttpCall.java | 165 ++++++++ .../ably/lib/network/DefaultHttpEngine.java | 26 ++ .../lib/network/DefaultHttpEngineFactory.java | 14 + .../lib/network/DefaultWebSocketClient.java | 106 ++++++ .../lib/network/DefaultWebSocketEngine.java | 20 + .../DefaultWebSocketEngineFactory.java | 14 + settings.gradle.kts | 2 + 42 files changed, 990 insertions(+), 333 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/util/ClientOptionsUtils.java create mode 100644 network-client-core/build.gradle.kts create mode 100644 network-client-core/src/main/java/io/ably/lib/network/EngineType.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/FailedConnectionException.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpBody.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpCall.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpEngine.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpEngineConfig.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpEngineFactory.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpRequest.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/HttpResponse.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/NotConnectedException.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/ProxyAuthType.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/ProxyConfig.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/WebSocketClient.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineConfig.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java create mode 100644 network-client-core/src/main/java/io/ably/lib/network/WebSocketListener.java create mode 100644 network-client-default/build.gradle.kts create mode 100644 network-client-default/gradle.properties create mode 100644 network-client-default/src/main/java/io/ably/lib/network/DefaultHttpCall.java create mode 100644 network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngine.java create mode 100644 network-client-default/src/main/java/io/ably/lib/network/DefaultHttpEngineFactory.java create mode 100644 network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketClient.java create mode 100644 network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java create mode 100644 network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngineFactory.java 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/android/build.gradle.kts b/android/build.gradle.kts index c66a5ee66..a63917f6d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -52,6 +52,8 @@ dependencies { api(libs.gson) implementation(libs.bundles.common) testImplementation(libs.bundles.tests) + implementation(project(":network-client-core")) + runtimeOnly(project(":network-client-default")) implementation(libs.firebase.messaging) androidTestImplementation(libs.bundles.instrumental.android) } diff --git a/build.gradle.kts b/build.gradle.kts index d031f19d9..9452386ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ import com.vanniktech.maven.publish.SonatypeHost plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.maven.publish) apply false + alias(libs.plugins.lombok) apply false } subprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 241d7195c..3545e89ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ android-test = "0.5" dexmaker = "1.4" android-retrostreams = "1.7.4" maven-publish = "0.29.0" +lombok = "8.10" [libraries] gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } @@ -39,7 +40,7 @@ dexmaker-mockito = { group = "com.crittercism.dexmaker", name = "dexmaker-mockit android-retrostreams = { group = "net.sourceforge.streamsupport", name = "android-retrostreams", version.ref = "android-retrostreams" } [bundles] -common = ["msgpack", "java-websocket", "vcdiff-core"] +common = ["msgpack", "vcdiff-core"] tests = ["junit","hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"] instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"] @@ -47,3 +48,4 @@ instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", 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" } diff --git a/java/build.gradle.kts b/java/build.gradle.kts index 21c3f87af..e537e6cfa 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -19,6 +19,8 @@ tasks.withType { dependencies { api(libs.gson) implementation(libs.bundles.common) + implementation(project(":network-client-core")) + runtimeOnly(project(":network-client-default")) testImplementation(libs.bundles.tests) } 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 0aec7c196..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); } 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 7b3bb64bb..470562b26 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpCore.java +++ b/lib/src/main/java/io/ably/lib/http/HttpCore.java @@ -2,7 +2,13 @@ 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; @@ -14,17 +20,13 @@ 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.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -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; @@ -62,10 +64,9 @@ public class HttpCore { final ClientOptions options; final Hosts hosts; private final Auth auth; - private final ProxyOptions proxyOptions; private final PlatformAgentProvider platformAgentProvider; + private final HttpEngine engine; private HttpAuth proxyAuth; - private Proxy proxy = Proxy.NO_PROXY; /************************* * Public API @@ -78,8 +79,7 @@ public HttpCore(ClientOptions options, Auth auth, PlatformAgentProvider platform 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; + ProxyOptions proxyOptions = options.proxy; if (proxyOptions != null) { String proxyHost = proxyOptions.host; if (proxyHost == null) { @@ -89,7 +89,6 @@ public HttpCore(ClientOptions options, Auth auth, PlatformAgentProvider platform 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)); String proxyUser = proxyOptions.username; if (proxyUser != null) { String proxyPassword = proxyOptions.password; @@ -99,6 +98,9 @@ public HttpCore(ClientOptions options, Auth auth, PlatformAgentProvider platform 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))); } /** @@ -119,7 +121,7 @@ public T httpExecuteWithRetry(URL url, String method, Param[] headers, Reque } while (true) { try { - return httpExecute(url, getProxy(url), method, headers, requestBody, true, responseHandler); + return httpExecute(url, method, headers, requestBody, true, responseHandler); } catch (AuthRequiredException are) { if (are.authChallenge != null && requireAblyAuth) { if (are.expired && renewPending) { @@ -177,7 +179,6 @@ void authorize(boolean renew) throws AblyException { * Make a synchronous HTTP request specified by URL and proxy * * @param url - * @param proxy * @param method * @param headers * @param requestBody @@ -186,25 +187,14 @@ void authorize(boolean renew) throws AblyException { * @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 @@ -213,111 +203,127 @@ 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_PROTOCOL_VERSION_HEADER, Defaults.ABLY_PROTOCOL_VERSION); // RSC7a - conn.setRequestProperty(Defaults.ABLY_AGENT_HEADER, AgentHeaderCreator.create(options.agents, platformAgentProvider)); - if (options.clientId != null) - conn.setRequestProperty(Defaults.ABLY_CLIENT_ID_HEADER, Base64Coder.encodeString(options.clientId)); - /* prepare request body */ - byte[] body = null; - if (requestBody != null) { - body = prepareRequestBody(requestBody, conn); - // Check the logging level to avoid performance hit associated with building the message - if (Log.level <= Log.VERBOSE) - Log.v(TAG, System.lineSeparator() + new String(body)); - } + Response response; - /* log raw request details */ - Map> requestProperties = conn.getRequestProperties(); - // Check the logging level to avoid performance hit associated with building the message - 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); + } - 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) { - 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 + requestHeaders.put(Defaults.ABLY_AGENT_HEADER, AgentHeaderCreator.create(options.agents, 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; } @@ -358,8 +364,8 @@ 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"); + 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)); @@ -389,18 +395,19 @@ private T handleResponse(HttpURLConnection conn, boolean credentialsIncluded } } } + /* handle proxy-authenticate */ 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) { error = ErrorInfo.fromResponseStatus(response.statusLine, response.statusCode); - } else { } Log.e(TAG, "Error response from server: err = " + error); if (responseHandler != null) { @@ -409,44 +416,19 @@ private T handleResponse(HttpURLConnection conn, boolean credentialsIncluded 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()) { @@ -459,84 +441,20 @@ private Response readResponse(HttpURLConnection connection) throws IOException { } } - 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; - } - /** * Interface for an entity that supplies an httpCore request body */ @@ -592,6 +510,19 @@ public List getHeaderFields(String name) { 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 values.get(0); + } } /** 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 55efe19bd..343a7f728 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpScheduler.java +++ b/lib/src/main/java/io/ably/lib/http/HttpScheduler.java @@ -1,6 +1,5 @@ package io.ably.lib.http; -import java.net.HttpURLConnection; import java.net.URL; import java.util.Locale; import java.util.concurrent.ExecutionException; @@ -9,6 +8,7 @@ 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; @@ -331,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; 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 c389be18c..cbcf58b5f 100644 --- a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java +++ b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java @@ -1,24 +1,21 @@ package io.ably.lib.transport; import io.ably.lib.http.HttpUtils; +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.network.NotConnectedException; 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 org.java_websocket.WebSocket; -import org.java_websocket.client.WebSocketClient; -import org.java_websocket.exceptions.WebsocketNotConnectedException; -import org.java_websocket.framing.CloseFrame; -import org.java_websocket.framing.Framedata; -import org.java_websocket.handshake.ServerHandshake; - -import javax.net.ssl.HttpsURLConnection; + import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLSession; -import java.net.URI; import java.nio.ByteBuffer; import java.util.Timer; import java.util.TimerTask; @@ -50,7 +47,7 @@ public class WebSocketTransport implements ITransport { private final boolean channelBinaryMode; private String wsUri; private ConnectListener connectListener; - private WsClient wsConnection; + private WebSocketClient webSocketClient; /****************** * protected constructor ******************/ @@ -81,15 +78,26 @@ public void connect(ConnectListener connectListener) { Log.d(TAG, "connect(); wsUri = " + wsUri); synchronized (this) { - wsConnection = new WsClient(URI.create(wsUri), this::receive); + WebSocketEngineFactory engineFactory = WebSocketEngineFactory.getFirstAvailable(); + Log.v(TAG, String.format("Using %s WebSocket Engine", engineFactory.getEngineType().name())); + + WebSocketEngineConfig.WebSocketEngineConfigBuilder configBuilder = WebSocketEngineConfig.builder(); + configBuilder + .tls(isTls) + .host(params.host) + .proxy(ClientOptionsUtils.convertToProxyConfig(params.getClientOptions())); + if (isTls) { SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, null, null); SafeSSLSocketFactory factory = new SafeSSLSocketFactory(sslContext.getSocketFactory()); - wsConnection.setSocketFactory(factory); + configBuilder.sslSocketFactory(factory); } + + WebSocketEngine engine = engineFactory.create(configBuilder.build()); + webSocketClient = engine.create(wsUri, new WebSocketHandler(this::receive)); } - wsConnection.connect(); + webSocketClient.connect(); } catch (AblyException e) { Log.e(TAG, "Unexpected exception attempting connection; wsUri = " + wsUri, e); connectListener.onTransportUnavailable(this, e.errorInfo); @@ -103,9 +111,9 @@ public void connect(ConnectListener connectListener) { public void close() { Log.d(TAG, "close()"); synchronized (this) { - if (wsConnection != null) { - wsConnection.close(); - wsConnection = null; + if (webSocketClient != null) { + webSocketClient.close(); + webSocketClient = null; } } } @@ -127,14 +135,14 @@ public void send(ProtocolMessage msg) throws AblyException { 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 (WebsocketNotConnectedException e) { + } catch (NotConnectedException e) { if (connectListener != null) { connectListener.onTransportUnavailable(this, AblyException.fromThrowable(e).errorInfo); } else @@ -180,7 +188,7 @@ public WebSocketTransport getTransport(TransportParams params, ConnectionManager * WebSocketHandler methods **************************/ - class WsClient extends WebSocketClient { + class WebSocketHandler implements WebSocketListener { private final WebSocketReceiver receiver; /*************************** * WsClient private members @@ -189,38 +197,16 @@ class WsClient extends WebSocketClient { private Timer timer = new Timer(); private TimerTask activityTimerTask = null; private long lastActivityTime; - private boolean shouldExplicitlyVerifyHostname = true; - WsClient(URI serverUri, WebSocketReceiver receiver) { - super(serverUri); + WebSocketHandler(WebSocketReceiver receiver) { this.receiver = receiver; } @Override - public void onOpen(ServerHandshake handshakedata) { + public void onOpen() { Log.d(TAG, "onOpen()"); - if (params.options.tls && shouldExplicitlyVerifyHostname && !isHostnameVerified(params.host)) { - close(); - } else { - connectListener.onTransportAvailable(WebSocketTransport.this); - flagActivity(); - } - } - - /** - * 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)) { - Log.v(TAG, "Successfully verified hostname"); - return true; - } else { - Log.e(TAG, "Hostname verification failed, expected " + hostname + ", found " + session.getPeerHost()); - return false; - } + connectListener.onTransportAvailable(WebSocketTransport.this); + flagActivity(); } @Override @@ -253,16 +239,14 @@ 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) { @@ -301,23 +285,14 @@ public void onClose(final int wsCode, final String wsReason, final boolean remot } @Override - public void onError(final Exception e) { - Log.e(TAG, "Connection error ", 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 - 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 - Log.w(TAG, "Error when trying to set SSL parameters, most likely due to an old Java API version", exception); - shouldExplicitlyVerifyHostname = true; - } + 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() { @@ -391,7 +366,7 @@ private synchronized void onActivityTimerExpiry() { // If we have no time remaining, then close the connection if (timeRemaining <= 0) { Log.e(TAG, "No activity for " + getActivityTimeout() + "ms, closing connection"); - closeConnection(CloseFrame.ABNORMAL_CLOSE, "timed out"); + webSocketClient.cancel(ABNORMAL_CLOSE, "timed out"); return; } 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/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/test/java/io/ably/lib/test/common/Helpers.java b/lib/src/test/java/io/ably/lib/test/common/Helpers.java index 80d3c5b80..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 @@ -3,7 +3,6 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; @@ -35,6 +34,7 @@ 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; @@ -972,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; @@ -988,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 */ @@ -1001,9 +1000,8 @@ public HttpCore.Response onRawHttpRequest(String id, HttpURLConnection conn, Str } 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; @@ -1076,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; } 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 51e3add6d..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 @@ -36,7 +36,6 @@ import java.io.IOException; import java.net.MalformedURLException; -import java.net.Proxy; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; @@ -137,12 +136,12 @@ public void http_ably_execute_fallback() throws AblyException { 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) { @@ -273,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 */ @@ -316,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 */ @@ -362,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 */ @@ -414,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 */ @@ -458,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 */ @@ -488,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 */ @@ -536,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 */ @@ -559,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 */ @@ -664,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 */ @@ -691,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 */ @@ -733,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 */ @@ -762,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 */ @@ -804,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 */ @@ -841,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 */ @@ -883,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 */ @@ -915,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 */ @@ -970,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 */ @@ -1007,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 */ @@ -1061,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 */ @@ -1092,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 */ @@ -1145,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 */ 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 9ba2a80ce..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 @@ -5,6 +5,7 @@ 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; @@ -33,7 +34,6 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.net.HttpURLConnection; import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.List; @@ -1378,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) { @@ -1443,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) { 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 5f18c5fd3..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 @@ -2,6 +2,7 @@ import io.ably.lib.debug.DebugOptions; 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.Channel; @@ -19,7 +20,6 @@ import org.junit.Before; import org.junit.Test; -import java.net.HttpURLConnection; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -135,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); } @@ -196,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); @@ -254,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) { @@ -343,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")); diff --git a/network-client-core/build.gradle.kts b/network-client-core/build.gradle.kts new file mode 100644 index 000000000..9b3ba996a --- /dev/null +++ b/network-client-core/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + `java-library` + alias(libs.plugins.lombok) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} 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..0d9226cbd --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpCall.java @@ -0,0 +1,6 @@ +package io.ably.lib.network; + +public interface HttpCall { + HttpResponse execute(); + 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..0b4fa29f3 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpEngine.java @@ -0,0 +1,6 @@ +package io.ably.lib.network; + +public interface HttpEngine { + HttpCall call(HttpRequest request); + 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..e93812db9 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/HttpEngineFactory.java @@ -0,0 +1,35 @@ +package io.ably.lib.network; + +import java.lang.reflect.InvocationTargetException; + +public interface HttpEngineFactory { + + HttpEngine create(HttpEngineConfig config); + EngineType getEngineType(); + + 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; + } + } +} 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..b3cd58108 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketClient.java @@ -0,0 +1,33 @@ +package io.ably.lib.network; + +public interface WebSocketClient { + + 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); + + void send(byte[] message); + + 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..32bd92bdb --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngine.java @@ -0,0 +1,5 @@ +package io.ably.lib.network; + +public interface WebSocketEngine { + WebSocketClient create(String url, WebSocketListener listener); +} 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..be0247cb5 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketEngineFactory.java @@ -0,0 +1,35 @@ +package io.ably.lib.network; + +import java.lang.reflect.InvocationTargetException; + +public interface WebSocketEngineFactory { + WebSocketEngine create(WebSocketEngineConfig config); + EngineType getEngineType(); + + 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.OkWebSocketEngineFactory"); + 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; + } + } +} 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..c3c223326 --- /dev/null +++ b/network-client-core/src/main/java/io/ably/lib/network/WebSocketListener.java @@ -0,0 +1,13 @@ +package io.ably.lib.network; + +import java.nio.ByteBuffer; + +public interface WebSocketListener { + void onOpen(); + void onMessage(ByteBuffer blob); + void onMessage(String string); + void onWebsocketPing(); + void onClose(int code, String reason); + void onError(Throwable throwable); + 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..4cf238353 --- /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 { + api(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..e8c5ae00e --- /dev/null +++ b/network-client-default/src/main/java/io/ably/lib/network/DefaultWebSocketEngine.java @@ -0,0 +1,20 @@ +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; + } +} 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/settings.gradle.kts b/settings.gradle.kts index 220fd80b7..e905e3922 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,3 +11,5 @@ rootProject.name = "ably-java" include("java") include("android") include("gradle-lint") +include("network-client-core") +include("network-client-default") From 061eca761bdc31cef22ec6b3282e78ab54edaaf3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 7 Oct 2024 10:22:03 +0100 Subject: [PATCH 3/3] docs: add http engine in-code docs Also add algorithm how to add new engine in the `CONTRIBUTING.md` guide --- CONTRIBUTING.md | 28 ++++++++++++ .../java/io/ably/lib/network/HttpCall.java | 12 +++++ .../java/io/ably/lib/network/HttpEngine.java | 12 +++++ .../ably/lib/network/HttpEngineFactory.java | 19 +++++--- .../io/ably/lib/network/WebSocketClient.java | 21 ++++++++- .../io/ably/lib/network/WebSocketEngine.java | 3 ++ .../lib/network/WebSocketEngineFactory.java | 16 +++++-- .../ably/lib/network/WebSocketListener.java | 44 +++++++++++++++++++ 8 files changed, 144 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bf2ca18c..ae9624b6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,34 @@ The Android-specific library AAR is built with: (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") +``` + +### 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 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 index 0d9226cbd..87e77aa40 100644 --- 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 @@ -1,6 +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 index 0b4fa29f3..eae17fd4a 100644 --- 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 @@ -1,6 +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/HttpEngineFactory.java b/network-client-core/src/main/java/io/ably/lib/network/HttpEngineFactory.java index e93812db9..e388064a0 100644 --- 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 @@ -2,11 +2,14 @@ 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 { - HttpEngine create(HttpEngineConfig config); - EngineType getEngineType(); - static HttpEngineFactory getFirstAvailable() { HttpEngineFactory okHttpFactory = tryGetOkHttpFactory(); if (okHttpFactory != null) return okHttpFactory; @@ -19,7 +22,8 @@ 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) { + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { return null; } } @@ -28,8 +32,13 @@ 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) { + } 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/WebSocketClient.java b/network-client-core/src/main/java/io/ably/lib/network/WebSocketClient.java index b3cd58108..9452fc132 100644 --- 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 @@ -1,7 +1,14 @@ 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(); /** @@ -12,7 +19,7 @@ public interface WebSocketClient { /** * Sends the closing handshake. May be sent in response to any other handshake. * - * @param code the closing code + * @param code the closing code * @param reason the closing message */ void close(int code, String reason); @@ -21,13 +28,23 @@ public interface WebSocketClient { * 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 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 index 32bd92bdb..a4a236757 100644 --- 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 @@ -1,5 +1,8 @@ package io.ably.lib.network; +/** + * Create WebSocket client bind to the specific URL + */ public interface WebSocketEngine { WebSocketClient create(String url, WebSocketListener listener); } 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 index be0247cb5..ce22567b3 100644 --- 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 @@ -2,10 +2,13 @@ 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 { - WebSocketEngine create(WebSocketEngineConfig config); - EngineType getEngineType(); - static WebSocketEngineFactory getFirstAvailable() { WebSocketEngineFactory okWebSocketFactory = tryGetOkWebSocketFactory(); if (okWebSocketFactory != null) return okWebSocketFactory; @@ -28,8 +31,13 @@ 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) { + } 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 index c3c223326..003d2a7bf 100644 --- 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 @@ -2,12 +2,56 @@ 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); }