From 51b0a170a434e0c713dc81da1eb4125ba6b74f48 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Mon, 9 Feb 2026 15:31:34 -0300 Subject: [PATCH 01/23] Http modules scaffolding --- build.gradle | 2 ++ http-domain/.gitignore | 1 + http-domain/README.md | 5 +++++ http-domain/build.gradle | 22 ++++++++++++++++++++ http-domain/consumer-rules.pro | 1 + http-domain/proguard-rules.pro | 21 +++++++++++++++++++ http-domain/src/main/AndroidManifest.xml | 4 ++++ http/.gitignore | 1 + http/README.md | 5 +++++ http/build.gradle | 26 ++++++++++++++++++++++++ http/consumer-rules.pro | 1 + http/proguard-rules.pro | 21 +++++++++++++++++++ http/src/main/AndroidManifest.xml | 4 ++++ main/build.gradle | 2 ++ settings.gradle | 2 ++ sonar-project.properties | 22 ++++++++++++++------ 16 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 http-domain/.gitignore create mode 100644 http-domain/README.md create mode 100644 http-domain/build.gradle create mode 100644 http-domain/consumer-rules.pro create mode 100644 http-domain/proguard-rules.pro create mode 100644 http-domain/src/main/AndroidManifest.xml create mode 100644 http/.gitignore create mode 100644 http/README.md create mode 100644 http/build.gradle create mode 100644 http/consumer-rules.pro create mode 100644 http/proguard-rules.pro create mode 100644 http/src/main/AndroidManifest.xml diff --git a/build.gradle b/build.gradle index f521dda49..64f9cdfbc 100644 --- a/build.gradle +++ b/build.gradle @@ -140,6 +140,8 @@ dependencies { include project(':events') include project(':events-domain') include project(':api') + include project(':http-domain') + include project(':http') } def javadocSourceProjects = providers.provider { diff --git a/http-domain/.gitignore b/http-domain/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/http-domain/.gitignore @@ -0,0 +1 @@ +/build diff --git a/http-domain/README.md b/http-domain/README.md new file mode 100644 index 000000000..362956fbb --- /dev/null +++ b/http-domain/README.md @@ -0,0 +1,5 @@ +# HTTP Domain module + +This module contains public HTTP configuration contracts exposed to consumers of the Split SDK. + +Includes certificate pinning configuration, proxy settings, SSL configuration, and authenticator interfaces. diff --git a/http-domain/build.gradle b/http-domain/build.gradle new file mode 100644 index 000000000..05b24204a --- /dev/null +++ b/http-domain/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.network.domain' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + implementation project(':logger') + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/http-domain/consumer-rules.pro b/http-domain/consumer-rules.pro new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/http-domain/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/http-domain/proguard-rules.pro b/http-domain/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/http-domain/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/http-domain/src/main/AndroidManifest.xml b/http-domain/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/http-domain/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/http/.gitignore b/http/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/http/.gitignore @@ -0,0 +1 @@ +/build diff --git a/http/README.md b/http/README.md new file mode 100644 index 000000000..199f22805 --- /dev/null +++ b/http/README.md @@ -0,0 +1,5 @@ +# HTTP module + +This module provides the HTTP client implementation used internally by the Split SDK. + +Includes request/response lifecycle, certificate pinning runtime, proxy tunnelling, and TLS configuration. Hidden from SDK consumers. diff --git a/http/build.gradle b/http/build.gradle new file mode 100644 index 000000000..85ef7d707 --- /dev/null +++ b/http/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.network.http' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + implementation project(':logger') + implementation project(':http-domain') + + testImplementation libs.junit4 + testImplementation libs.mockitoCore + testImplementation libs.mockitoInline + testImplementation libs.okhttpMockwebserver + testImplementation libs.okhttpTls +} diff --git a/http/consumer-rules.pro b/http/consumer-rules.pro new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/http/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/http/proguard-rules.pro b/http/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/http/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/http/src/main/AndroidManifest.xml b/http/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/http/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/main/build.gradle b/main/build.gradle index 7ec1e3110..70d932be8 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -52,7 +52,9 @@ dependencies { // Public api modules api project(':logger') api project(':api') + api project(':http-domain') // Internal module dependencies + implementation project(':http') implementation project(':events-domain') // External dependencies diff --git a/settings.gradle b/settings.gradle index b584365a6..7eaafe2d3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,8 @@ rootProject.name = 'android-client' include ':api' include ':logger' +include ':http-domain' +include ':http' include ':main' include ':events' include ':events-domain' diff --git a/sonar-project.properties b/sonar-project.properties index f598dd559..a3cc5e80d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,15 +3,17 @@ sonar.projectKey=splitio_android-client sonar.projectName=android-client # Path to source directories (multi-module) -# Root project contains modules: main, events, logger -sonar.sources=main/src/main/java,events/src/main/java,logger/src/main/java +# Root project contains modules: main, events, logger, http-domain, http +sonar.sources=main/src/main/java,events/src/main/java,logger/src/main/java,http-domain/src/main/java,http/src/main/java # Path to compiled classes (multi-module) -# Include binary paths for all modules: main, events, logger +# Include binary paths for all modules: main, events, logger, http-domain, http sonar.java.binaries=\ main/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ events/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ - logger/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes + logger/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + http-domain/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + http/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes # Path to dependency/libraries jars (multi-module) sonar.java.libraries=\ @@ -26,11 +28,19 @@ sonar.java.libraries=\ logger/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ logger/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ logger/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ - logger/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar + logger/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + http-domain/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + http-domain/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + http-domain/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + http-domain/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + http/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + http/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + http/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + http/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar # Path to test directories (multi-module) # Only include test source folders that are guaranteed to exist in all environments -sonar.tests=main/src/test/java,main/src/androidTest/java,main/src/sharedTest/java,events/src/test/java,logger/src/test/java +sonar.tests=main/src/test/java,main/src/androidTest/java,main/src/sharedTest/java,events/src/test/java,logger/src/test/java,http-domain/src/test/java,http/src/test/java # Encoding of the source code sonar.sourceEncoding=UTF-8 From b16802877bf1b7490b1a39b5f2aabe269d42cf58 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Mon, 9 Feb 2026 16:05:23 -0300 Subject: [PATCH 02/23] Move files to http modules --- http-domain/build.gradle | 2 ++ .../android/client/network/Algorithm.java | 7 +++++++ .../client/network/AuthenticatedRequest.java | 2 +- .../android/client/network/Authenticator.java | 9 ++++++++ .../android/client/network/Base64Decoder.java | 2 +- .../network/BasicCredentialsProvider.java | 0 .../network/BearerCredentialsProvider.java | 0 .../network/CertificateCheckerHelper.java | 6 +++--- .../client/network/CertificatePin.java | 6 +----- .../CertificatePinningConfiguration.java | 3 +-- .../CertificatePinningFailureListener.java | 0 .../client/network/DefaultBase64Decoder.java | 20 ++++++++++++++++++ .../client/network/DevelopmentSslConfig.java | 0 .../android/client/network/HttpProxy.java | 0 .../android/client/network/PinEncoder.java | 2 +- .../client/network/PinEncoderImpl.java | 2 +- .../client/network/ProxyConfiguration.java | 0 .../network/ProxyCredentialsProvider.java | 0 .../client/network/SplitAuthenticator.java | 2 +- .../network/CertificateCheckerHelperTest.java | 0 .../CertificatePinningConfigurationTest.java | 0 .../client/network/PinEncoderImplTest.java | 0 .../android/client/network/Algorithm.java | 7 ------- .../android/client/network/Authenticator.java | 9 -------- ...rtificatePinningConfigurationProvider.java | 21 +++++++++++++++---- .../client/network/DefaultBase64Decoder.java | 11 ---------- .../network/SplitAuthenticatedRequest.java | 2 +- .../network/SplitBasicAuthenticator.java | 2 +- .../SplitUrlConnectionAuthenticator.java | 2 +- .../android/client/SplitClientConfigTest.java | 3 ++- .../client/network/HttpClientTest.java | 4 ++-- .../network/SplitAuthenticatorTest.java | 6 +++--- .../network/SplitBasicAuthenticatorTest.java | 2 +- 33 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 http-domain/src/main/java/io/split/android/client/network/Algorithm.java rename {main => http-domain}/src/main/java/io/split/android/client/network/AuthenticatedRequest.java (90%) create mode 100644 http-domain/src/main/java/io/split/android/client/network/Authenticator.java rename {main => http-domain}/src/main/java/io/split/android/client/network/Base64Decoder.java (70%) rename {main => http-domain}/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java (100%) rename {main => http-domain}/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java (100%) rename {main => http-domain}/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java (91%) rename {main => http-domain}/src/main/java/io/split/android/client/network/CertificatePin.java (84%) rename {main => http-domain}/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java (98%) rename {main => http-domain}/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java (100%) create mode 100644 http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java rename {main => http-domain}/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java (100%) rename {main => http-domain}/src/main/java/io/split/android/client/network/HttpProxy.java (100%) rename {main => http-domain}/src/main/java/io/split/android/client/network/PinEncoder.java (84%) rename {main => http-domain}/src/main/java/io/split/android/client/network/PinEncoderImpl.java (96%) rename {main => http-domain}/src/main/java/io/split/android/client/network/ProxyConfiguration.java (100%) rename {main => http-domain}/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java (100%) rename {main => http-domain}/src/main/java/io/split/android/client/network/SplitAuthenticator.java (81%) rename {main => http-domain}/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java (100%) rename {main => http-domain}/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java (100%) rename {main => http-domain}/src/test/java/io/split/android/client/network/PinEncoderImplTest.java (100%) delete mode 100644 main/src/main/java/io/split/android/client/network/Algorithm.java delete mode 100644 main/src/main/java/io/split/android/client/network/Authenticator.java delete mode 100644 main/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java diff --git a/http-domain/build.gradle b/http-domain/build.gradle index 05b24204a..b15da12d4 100644 --- a/http-domain/build.gradle +++ b/http-domain/build.gradle @@ -19,4 +19,6 @@ dependencies { testImplementation libs.junit4 testImplementation libs.mockitoCore + testImplementation libs.mockitoInline + testImplementation libs.okhttpTls } diff --git a/http-domain/src/main/java/io/split/android/client/network/Algorithm.java b/http-domain/src/main/java/io/split/android/client/network/Algorithm.java new file mode 100644 index 000000000..bd3784efe --- /dev/null +++ b/http-domain/src/main/java/io/split/android/client/network/Algorithm.java @@ -0,0 +1,7 @@ +package io.split.android.client.network; + +public class Algorithm { + + public static final String SHA256 = "sha256"; + public static final String SHA1 = "sha1"; +} diff --git a/main/src/main/java/io/split/android/client/network/AuthenticatedRequest.java b/http-domain/src/main/java/io/split/android/client/network/AuthenticatedRequest.java similarity index 90% rename from main/src/main/java/io/split/android/client/network/AuthenticatedRequest.java rename to http-domain/src/main/java/io/split/android/client/network/AuthenticatedRequest.java index f6dfa1a43..6e541e3de 100644 --- a/main/src/main/java/io/split/android/client/network/AuthenticatedRequest.java +++ b/http-domain/src/main/java/io/split/android/client/network/AuthenticatedRequest.java @@ -5,7 +5,7 @@ import java.util.Map; -interface AuthenticatedRequest { +public interface AuthenticatedRequest { void setHeader(@NonNull String name, @NonNull String value); diff --git a/http-domain/src/main/java/io/split/android/client/network/Authenticator.java b/http-domain/src/main/java/io/split/android/client/network/Authenticator.java new file mode 100644 index 000000000..4fab265e4 --- /dev/null +++ b/http-domain/src/main/java/io/split/android/client/network/Authenticator.java @@ -0,0 +1,9 @@ +package io.split.android.client.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public interface Authenticator { + + @Nullable AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request); +} diff --git a/main/src/main/java/io/split/android/client/network/Base64Decoder.java b/http-domain/src/main/java/io/split/android/client/network/Base64Decoder.java similarity index 70% rename from main/src/main/java/io/split/android/client/network/Base64Decoder.java rename to http-domain/src/main/java/io/split/android/client/network/Base64Decoder.java index 387a900f0..f31358046 100644 --- a/main/src/main/java/io/split/android/client/network/Base64Decoder.java +++ b/http-domain/src/main/java/io/split/android/client/network/Base64Decoder.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -interface Base64Decoder { +public interface Base64Decoder { byte[] decode(String base64); } diff --git a/main/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java b/http-domain/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java rename to http-domain/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java diff --git a/main/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java b/http-domain/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java rename to http-domain/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java diff --git a/main/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java b/http-domain/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java similarity index 91% rename from main/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java rename to http-domain/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java index 709534b88..45245ed0d 100644 --- a/main/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java +++ b/http-domain/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java @@ -15,10 +15,10 @@ import io.split.android.client.utils.logger.Logger; -class CertificateCheckerHelper { +public class CertificateCheckerHelper { @Nullable - static Set getPinsForHost(String pattern, Map> configuredPins) { + public static Set getPinsForHost(String pattern, Map> configuredPins) { Set hostPins = configuredPins.get(pattern); Set wildcardPins = new LinkedHashSet<>(); @@ -53,7 +53,7 @@ static Set getPinsForHost(String pattern, Map getPinsFromInputStream(InputStream inputStream, PinEncoder pinEncoder) { + public static Set getPinsFromInputStream(InputStream inputStream, PinEncoder pinEncoder) { try (InputStream stream = inputStream) { CertificateFactory factory = CertificateFactory.getInstance("X.509"); diff --git a/main/src/main/java/io/split/android/client/network/CertificatePin.java b/http-domain/src/main/java/io/split/android/client/network/CertificatePin.java similarity index 84% rename from main/src/main/java/io/split/android/client/network/CertificatePin.java rename to http-domain/src/main/java/io/split/android/client/network/CertificatePin.java index 6056ff7e7..98739d294 100644 --- a/main/src/main/java/io/split/android/client/network/CertificatePin.java +++ b/http-domain/src/main/java/io/split/android/client/network/CertificatePin.java @@ -1,18 +1,14 @@ package io.split.android.client.network; -import com.google.gson.annotations.SerializedName; - import java.util.Arrays; import java.util.Objects; public class CertificatePin { - @SerializedName("pin") private final byte[] mPin; - @SerializedName("algo") private final String mAlgorithm; - CertificatePin(byte[] pin, String algorithm) { + public CertificatePin(byte[] pin, String algorithm) { mPin = pin; mAlgorithm = algorithm; } diff --git a/main/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java b/http-domain/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java similarity index 98% rename from main/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java rename to http-domain/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java index 23ec94d5c..b110ba5be 100644 --- a/main/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java +++ b/http-domain/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java @@ -9,7 +9,6 @@ import java.util.Map; import java.util.Set; -import io.split.android.client.utils.Base64Util; import io.split.android.client.utils.logger.Logger; public class CertificatePinningConfiguration { @@ -160,7 +159,7 @@ public Builder failureListener(@NonNull CertificatePinningFailureListener failur } // Meant to be used only when setting up bg sync jobs - void addPins(String host, Set pins) { + public void addPins(String host, Set pins) { if (host == null || host.trim().isEmpty()) { Logger.e("Host cannot be null or empty. Ignoring entry"); return; diff --git a/main/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java b/http-domain/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java rename to http-domain/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java diff --git a/http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java b/http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java new file mode 100644 index 000000000..486eb5be2 --- /dev/null +++ b/http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java @@ -0,0 +1,20 @@ +package io.split.android.client.network; + +import android.util.Base64; + +import io.split.android.client.utils.logger.Logger; + +public class DefaultBase64Decoder implements Base64Decoder { + + @Override + public byte[] decode(String base64) { + try { + return Base64.decode(base64, Base64.DEFAULT); + } catch (IllegalArgumentException e) { + Logger.e("Received bytes didn't correspond to a valid Base64 encoded string." + e.getLocalizedMessage()); + } catch (Exception e) { + Logger.e("An unknown error has occurred " + e.getLocalizedMessage()); + } + return null; + } +} diff --git a/main/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java b/http-domain/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java rename to http-domain/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java diff --git a/main/src/main/java/io/split/android/client/network/HttpProxy.java b/http-domain/src/main/java/io/split/android/client/network/HttpProxy.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpProxy.java rename to http-domain/src/main/java/io/split/android/client/network/HttpProxy.java diff --git a/main/src/main/java/io/split/android/client/network/PinEncoder.java b/http-domain/src/main/java/io/split/android/client/network/PinEncoder.java similarity index 84% rename from main/src/main/java/io/split/android/client/network/PinEncoder.java rename to http-domain/src/main/java/io/split/android/client/network/PinEncoder.java index 3de8beecf..d34212ca8 100644 --- a/main/src/main/java/io/split/android/client/network/PinEncoder.java +++ b/http-domain/src/main/java/io/split/android/client/network/PinEncoder.java @@ -2,7 +2,7 @@ import androidx.annotation.NonNull; -interface PinEncoder { +public interface PinEncoder { @NonNull byte[] encodeCertPin(String algorithm, byte[] encodedPublicKey); diff --git a/main/src/main/java/io/split/android/client/network/PinEncoderImpl.java b/http-domain/src/main/java/io/split/android/client/network/PinEncoderImpl.java similarity index 96% rename from main/src/main/java/io/split/android/client/network/PinEncoderImpl.java rename to http-domain/src/main/java/io/split/android/client/network/PinEncoderImpl.java index 7132b1828..f1e010d51 100644 --- a/main/src/main/java/io/split/android/client/network/PinEncoderImpl.java +++ b/http-domain/src/main/java/io/split/android/client/network/PinEncoderImpl.java @@ -7,7 +7,7 @@ import io.split.android.client.utils.logger.Logger; -class PinEncoderImpl implements PinEncoder { +public class PinEncoderImpl implements PinEncoder { @Override @NonNull diff --git a/main/src/main/java/io/split/android/client/network/ProxyConfiguration.java b/http-domain/src/main/java/io/split/android/client/network/ProxyConfiguration.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/ProxyConfiguration.java rename to http-domain/src/main/java/io/split/android/client/network/ProxyConfiguration.java diff --git a/main/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java b/http-domain/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java rename to http-domain/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java diff --git a/main/src/main/java/io/split/android/client/network/SplitAuthenticator.java b/http-domain/src/main/java/io/split/android/client/network/SplitAuthenticator.java similarity index 81% rename from main/src/main/java/io/split/android/client/network/SplitAuthenticator.java rename to http-domain/src/main/java/io/split/android/client/network/SplitAuthenticator.java index 542ff42dc..494ba736e 100644 --- a/main/src/main/java/io/split/android/client/network/SplitAuthenticator.java +++ b/http-domain/src/main/java/io/split/android/client/network/SplitAuthenticator.java @@ -1,6 +1,6 @@ package io.split.android.client.network; /** @noinspection unused*/ -public abstract class SplitAuthenticator implements Authenticator { +public abstract class SplitAuthenticator implements Authenticator { } diff --git a/main/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java b/http-domain/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java rename to http-domain/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java diff --git a/main/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java b/http-domain/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java rename to http-domain/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java diff --git a/main/src/test/java/io/split/android/client/network/PinEncoderImplTest.java b/http-domain/src/test/java/io/split/android/client/network/PinEncoderImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/PinEncoderImplTest.java rename to http-domain/src/test/java/io/split/android/client/network/PinEncoderImplTest.java diff --git a/main/src/main/java/io/split/android/client/network/Algorithm.java b/main/src/main/java/io/split/android/client/network/Algorithm.java deleted file mode 100644 index 2e193751f..000000000 --- a/main/src/main/java/io/split/android/client/network/Algorithm.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.split.android.client.network; - -class Algorithm { - - static final String SHA256 = "sha256"; - static final String SHA1 = "sha1"; -} diff --git a/main/src/main/java/io/split/android/client/network/Authenticator.java b/main/src/main/java/io/split/android/client/network/Authenticator.java deleted file mode 100644 index c23a39994..000000000 --- a/main/src/main/java/io/split/android/client/network/Authenticator.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.split.android.client.network; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -interface Authenticator> { - - @Nullable T authenticate(@NonNull T request); -} diff --git a/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java b/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java index aa640fbc5..801baa909 100644 --- a/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java +++ b/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java @@ -1,8 +1,10 @@ package io.split.android.client.network; +import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -13,14 +15,18 @@ public class CertificatePinningConfigurationProvider { public static CertificatePinningConfiguration getCertificatePinningConfiguration(String pinsJson) { try { - Type type = new TypeToken>>() { + Type type = new TypeToken>>() { }.getType(); - Map> certificatePins = Json.fromJson(pinsJson, type); + Map> certificatePins = Json.fromJson(pinsJson, type); if (certificatePins != null && !certificatePins.isEmpty()) { CertificatePinningConfiguration.Builder builder = CertificatePinningConfiguration.builder(); - for (Map.Entry> entry : certificatePins.entrySet()) { - builder.addPins(entry.getKey(), entry.getValue()); + for (Map.Entry> entry : certificatePins.entrySet()) { + Set pins = new HashSet<>(); + for (CertificatePinDto dto : entry.getValue()) { + pins.add(new CertificatePin(dto.pin, dto.algorithm)); + } + builder.addPins(entry.getKey(), pins); } return builder @@ -32,4 +38,11 @@ public static CertificatePinningConfiguration getCertificatePinningConfiguration return null; } + + private static class CertificatePinDto { + @SerializedName("pin") + byte[] pin; + @SerializedName("algo") + String algorithm; + } } diff --git a/main/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java b/main/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java deleted file mode 100644 index c84903fb6..000000000 --- a/main/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.split.android.client.network; - -import io.split.android.client.utils.Base64Util; - -class DefaultBase64Decoder implements Base64Decoder { - - @Override - public byte[] decode(String base64) { - return Base64Util.bytesDecode(base64); - } -} diff --git a/main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java b/main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java index 9b426385c..cddb6370e 100644 --- a/main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java +++ b/main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java @@ -8,7 +8,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -public class SplitAuthenticatedRequest implements AuthenticatedRequest { +public class SplitAuthenticatedRequest implements AuthenticatedRequest { private final String mUrl; private final Map mHeaders = new ConcurrentHashMap<>(); diff --git a/main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java b/main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java index bd49d9ca4..b87c6699f 100644 --- a/main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java +++ b/main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java @@ -19,7 +19,7 @@ class SplitBasicAuthenticator extends SplitAuthenticator { @Nullable @Override - public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { String credential = basic(mUsername, mPassword); request.setHeader(PROXY_AUTHORIZATION_HEADER, credential); diff --git a/main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java b/main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java index fdb97f302..2c0cd3d5a 100644 --- a/main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java +++ b/main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java @@ -12,7 +12,7 @@ class SplitUrlConnectionAuthenticator { } HttpURLConnection authenticate(HttpURLConnection connection) { - SplitAuthenticatedRequest authenticatedRequest = mProxyAuthenticator.authenticate(new SplitAuthenticatedRequest(connection)); + AuthenticatedRequest authenticatedRequest = mProxyAuthenticator.authenticate(new SplitAuthenticatedRequest(connection)); if (authenticatedRequest != null) { Map headers = authenticatedRequest.getHeaders(); diff --git a/main/src/test/java/io/split/android/client/SplitClientConfigTest.java b/main/src/test/java/io/split/android/client/SplitClientConfigTest.java index b97ea6381..4163818ed 100644 --- a/main/src/test/java/io/split/android/client/SplitClientConfigTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientConfigTest.java @@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit; import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.network.AuthenticatedRequest; import io.split.android.client.network.CertificatePinningConfiguration; import io.split.android.client.network.ProxyConfiguration; import io.split.android.client.network.SplitAuthenticatedRequest; @@ -298,7 +299,7 @@ public void proxyAuthenticatorAndProxyConfigurationSetLogWarning() { .proxyAuthenticator(new SplitAuthenticator() { @Nullable @Override - public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { return null; } }) diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTest.java b/main/src/test/java/io/split/android/client/network/HttpClientTest.java index 3ecc24ee2..a2f2c8c86 100644 --- a/main/src/test/java/io/split/android/client/network/HttpClientTest.java +++ b/main/src/test/java/io/split/android/client/network/HttpClientTest.java @@ -320,7 +320,7 @@ public MockResponse dispatch(RecordedRequest request) { .setUrlSanitizer(mUrlSanitizerMock) .setProxyAuthenticator(new SplitAuthenticator() { @Override - public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { authLatch.countDown(); request.setHeader("Proxy-Authorization", "my-auth"); @@ -375,7 +375,7 @@ public MockResponse dispatch(RecordedRequest request) { .setUrlSanitizer(mUrlSanitizerMock) .setProxyAuthenticator(new SplitAuthenticator() { @Override - public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { authLatch.countDown(); request.setHeader("Proxy-Authorization", "my-auth"); diff --git a/main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java b/main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java index 3380c43a1..dae394a09 100644 --- a/main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java +++ b/main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java @@ -18,9 +18,9 @@ public class SplitAuthenticatorTest { @Test public void authenticatorModifiesHeaders() { - Authenticator> splitAuthenticator = new Authenticator>() { + Authenticator splitAuthenticator = new Authenticator() { @Override - public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { request.setHeader("new-header", "value"); return request; @@ -48,7 +48,7 @@ public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequ assertEquals("value", finalHeaders.get("new-header")); } - private static class AuthenticatedMockRequest implements AuthenticatedRequest { + private static class AuthenticatedMockRequest implements AuthenticatedRequest { private final MockRequest mRequest; diff --git a/main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java b/main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java index 7b56e0291..5b0f27531 100644 --- a/main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java +++ b/main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java @@ -29,7 +29,7 @@ public void callingAuthenticateUsesEncoder() { @Test public void callingAuthenticateReturnsCorrectHeaderInRequest() { SplitBasicAuthenticator authenticator = new SplitBasicAuthenticator("user", "pass", mBase64Encoder); - SplitAuthenticatedRequest request = authenticator.authenticate(mock(SplitAuthenticatedRequest.class)); + AuthenticatedRequest request = authenticator.authenticate(mock(SplitAuthenticatedRequest.class)); verify(request).setHeader("Proxy-Authorization", "Basic user:pass"); } From ef7778ba8adcba475289315ea3fc96c76e7588cb Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Mon, 9 Feb 2026 16:25:35 -0300 Subject: [PATCH 03/23] Http client config --- .../network/HttpClientConfiguration.java | 142 ++++++++++++++++++ .../network/HttpClientConfigurationTest.java | 97 ++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 http-domain/src/main/java/io/split/android/client/network/HttpClientConfiguration.java create mode 100644 http-domain/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java diff --git a/http-domain/src/main/java/io/split/android/client/network/HttpClientConfiguration.java b/http-domain/src/main/java/io/split/android/client/network/HttpClientConfiguration.java new file mode 100644 index 000000000..6bd6f7d58 --- /dev/null +++ b/http-domain/src/main/java/io/split/android/client/network/HttpClientConfiguration.java @@ -0,0 +1,142 @@ +package io.split.android.client.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +public class HttpClientConfiguration { + + private final long mConnectionTimeout; + private final long mReadTimeout; + @Nullable + private final HttpProxy mProxy; + @Nullable + private final CertificatePinningConfiguration mCertificatePinningConfiguration; + @Nullable + private final DevelopmentSslConfig mDevelopmentSslConfig; + @Nullable + private final SplitAuthenticator mProxyAuthenticator; + + private HttpClientConfiguration(Builder builder) { + mConnectionTimeout = builder.mConnectionTimeout; + mReadTimeout = builder.mReadTimeout; + mProxy = builder.mProxy; + mCertificatePinningConfiguration = builder.mCertificatePinningConfiguration; + mDevelopmentSslConfig = builder.mDevelopmentSslConfig; + mProxyAuthenticator = builder.mProxyAuthenticator; + } + + public long getConnectionTimeout() { + return mConnectionTimeout; + } + + public long getReadTimeout() { + return mReadTimeout; + } + + @Nullable + public HttpProxy getProxy() { + return mProxy; + } + + @Nullable + public CertificatePinningConfiguration getCertificatePinningConfiguration() { + return mCertificatePinningConfiguration; + } + + @Nullable + public DevelopmentSslConfig getDevelopmentSslConfig() { + return mDevelopmentSslConfig; + } + + @Nullable + public SplitAuthenticator getProxyAuthenticator() { + return mProxyAuthenticator; + } + + @NonNull + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private long mConnectionTimeout; + private long mReadTimeout; + @Nullable + private HttpProxy mProxy; + @Nullable + private CertificatePinningConfiguration mCertificatePinningConfiguration; + @Nullable + private DevelopmentSslConfig mDevelopmentSslConfig; + @Nullable + private SplitAuthenticator mProxyAuthenticator; + + private Builder() { + } + + /** + * Sets the connection timeout in milliseconds. + */ + @NonNull + public Builder connectionTimeout(long connectionTimeout) { + mConnectionTimeout = connectionTimeout; + return this; + } + + /** + * Sets the read timeout in milliseconds. + */ + @NonNull + public Builder readTimeout(long readTimeout) { + mReadTimeout = readTimeout; + return this; + } + + /** + * Sets the HTTP proxy configuration. + */ + @NonNull + public Builder proxy(@Nullable HttpProxy proxy) { + mProxy = proxy; + return this; + } + + /** + * Sets the certificate pinning configuration. + */ + @NonNull + public Builder certificatePinningConfiguration(@Nullable CertificatePinningConfiguration configuration) { + mCertificatePinningConfiguration = configuration; + return this; + } + + /** + * Sets the development SSL configuration. + *

+ * This is intended for development/testing environments only. + */ + @NonNull + public Builder developmentSslConfig(@Nullable DevelopmentSslConfig developmentSslConfig) { + mDevelopmentSslConfig = developmentSslConfig; + return this; + } + + /** + * Sets the proxy authenticator. + */ + @NonNull + public Builder proxyAuthenticator(@Nullable SplitAuthenticator proxyAuthenticator) { + mProxyAuthenticator = proxyAuthenticator; + return this; + } + + /** + * Builds the configuration. + */ + @NonNull + public HttpClientConfiguration build() { + return new HttpClientConfiguration(this); + } + } +} diff --git a/http-domain/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java b/http-domain/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java new file mode 100644 index 000000000..f722a3439 --- /dev/null +++ b/http-domain/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java @@ -0,0 +1,97 @@ +package io.split.android.client.network; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class HttpClientConfigurationTest { + + @Test + public void builderSetsConnectionTimeout() { + HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(15_000) + .build(); + + assertEquals(15_000, config.getConnectionTimeout()); + } + + @Test + public void builderSetsReadTimeout() { + HttpClientConfiguration config = HttpClientConfiguration.builder() + .readTimeout(30_000) + .build(); + + assertEquals(30_000, config.getReadTimeout()); + } + + @Test + public void builderSetsProxy() { + HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080).build(); + HttpClientConfiguration config = HttpClientConfiguration.builder() + .proxy(proxy) + .build(); + + assertNotNull(config.getProxy()); + assertEquals("proxy.example.com", config.getProxy().getHost()); + assertEquals(8080, config.getProxy().getPort()); + } + + @Test + public void builderSetsCertificatePinningConfiguration() { + CertificatePinningConfiguration certConfig = CertificatePinningConfiguration.builder() + .addPin("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + .build(); + HttpClientConfiguration config = HttpClientConfiguration.builder() + .certificatePinningConfiguration(certConfig) + .build(); + + assertNotNull(config.getCertificatePinningConfiguration()); + } + + @Test + public void builderSetsDevelopmentSslConfig() { + // DevelopmentSslConfig requires non-null args; just verify null default + HttpClientConfiguration config = HttpClientConfiguration.builder().build(); + assertNull(config.getDevelopmentSslConfig()); + } + + @Test + public void builderSetsProxyAuthenticator() { + HttpClientConfiguration config = HttpClientConfiguration.builder().build(); + assertNull(config.getProxyAuthenticator()); + } + + @Test + public void defaultValuesAreZeroAndNull() { + HttpClientConfiguration config = HttpClientConfiguration.builder().build(); + + assertEquals(0, config.getConnectionTimeout()); + assertEquals(0, config.getReadTimeout()); + assertNull(config.getProxy()); + assertNull(config.getCertificatePinningConfiguration()); + assertNull(config.getDevelopmentSslConfig()); + assertNull(config.getProxyAuthenticator()); + } + + @Test + public void builderSetsAllFields() { + HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080).build(); + CertificatePinningConfiguration certConfig = CertificatePinningConfiguration.builder() + .addPin("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + .build(); + + HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(10_000) + .readTimeout(20_000) + .proxy(proxy) + .certificatePinningConfiguration(certConfig) + .build(); + + assertEquals(10_000, config.getConnectionTimeout()); + assertEquals(20_000, config.getReadTimeout()); + assertNotNull(config.getProxy()); + assertNotNull(config.getCertificatePinningConfiguration()); + } +} From 153daedaa3af5a14606000da25e1e6a2ba781727 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Mon, 9 Feb 2026 17:24:22 -0300 Subject: [PATCH 04/23] Fix instrumented test --- .../androidTest/java/tests/integration/ProxyFactoryTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/src/androidTest/java/tests/integration/ProxyFactoryTest.java b/main/src/androidTest/java/tests/integration/ProxyFactoryTest.java index 28f80e19d..cfbe02240 100644 --- a/main/src/androidTest/java/tests/integration/ProxyFactoryTest.java +++ b/main/src/androidTest/java/tests/integration/ProxyFactoryTest.java @@ -27,7 +27,7 @@ import io.split.android.client.SplitFactoryBuilder; import io.split.android.client.api.Key; import io.split.android.client.events.SplitEvent; -import io.split.android.client.network.SplitAuthenticatedRequest; +import io.split.android.client.network.AuthenticatedRequest; import io.split.android.client.network.SplitAuthenticator; import io.split.android.client.service.impressions.ImpressionsMode; import io.split.android.client.service.synchronizer.ThreadUtils; @@ -248,7 +248,7 @@ public MockResponse dispatch(RecordedRequest request) { .serviceEndpoints(endpoints) .proxyAuthenticator(new SplitAuthenticator() { @Override - public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { request.setHeader("Proxy-Authorization", "Bearer 1234567890"); return request; } From 8d26f928911d5179954ac026bc0e66fe2f20550d Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Mon, 9 Feb 2026 16:43:29 -0300 Subject: [PATCH 05/23] Populate HTTP module --- http/build.gradle | 2 +- .../android/client/network/Base64Encoder.java | 0 .../client/network/BaseHttpResponse.java | 0 .../client/network/BaseHttpResponseImpl.java | 0 .../client/network/CertificateChecker.java | 0 .../network/CertificateCheckerImpl.java | 0 .../android/client/network/ChainCleaner.java | 0 .../client/network/ChainCleanerImpl.java | 0 .../client/network/DefaultBase64Encoder.java | 22 ++++++++++ .../android/client/network/HttpClient.java | 0 .../client/network/HttpClientImpl.java | 32 ++++---------- .../android/client/network/HttpException.java | 0 .../android/client/network/HttpHeaders.java | 43 +++++++++++++++++++ .../android/client/network/HttpMethod.java | 0 .../network/HttpOverTunnelExecutor.java | 0 .../client/network/HttpQueryParameters.java | 29 +++++++++++++ .../android/client/network/HttpRequest.java | 0 .../client/network/HttpRequestHelper.java | 5 +-- .../client/network/HttpRequestImpl.java | 21 +++++---- .../android/client/network/HttpResponse.java | 0 .../HttpResponseConnectionAdapter.java | 0 .../client/network/HttpResponseImpl.java | 0 .../client/network/HttpStreamRequest.java | 0 .../client/network/HttpStreamRequestImpl.java | 11 +++-- .../client/network/HttpStreamResponse.java | 0 .../network/HttpStreamResponseImpl.java | 0 .../client/network/PercentEscaper.java | 8 ++-- .../network/ProxyCacertConnectionHandler.java | 0 .../ProxySslSocketFactoryProvider.java | 0 .../ProxySslSocketFactoryProviderImpl.java | 4 +- .../client/network/RawHttpResponseParser.java | 0 .../network/SplitAuthenticatedRequest.java | 0 .../network/SplitBasicAuthenticator.java | 0 .../SplitUrlConnectionAuthenticator.java | 0 .../network/SslProxyTunnelEstablisher.java | 0 .../network/Tls12OnlySocketFactory.java | 0 .../android/client/network/TlsUpdater.java | 14 ++++++ .../client/network/TrustManagerProvider.java | 0 .../android/client/network/URIBuilder.java | 24 +++++------ .../client/network/UnicodeEscaper.java | 6 +-- .../android/client/network/UrlEscapers.java | 0 .../android/client/network/UrlSanitizer.java | 0 .../client/network/UrlSanitizerImpl.java | 0 .../network/CertificateCheckerImplTest.java | 0 .../client/network/ChainCleanerImplTest.java | 0 .../network/DefaultBase64EncoderTest.java | 36 ++++++++-------- .../HttpClientTunnellingProxyTest.java | 0 .../network/HttpOverTunnelExecutorTest.java | 0 .../client/network/HttpRequestHelperTest.java | 0 .../HttpResponseConnectionAdapterTest.java | 0 .../network/HttpStreamResponseTest.java | 0 ...ProxySslSocketFactoryProviderImplTest.java | 0 .../network/RawHttpResponseParserTest.java | 0 .../network/SplitAuthenticatorTest.java | 0 .../network/SplitBasicAuthenticatorTest.java | 0 .../SplitUrlConnectionAuthenticatorTest.java | 0 .../SslProxyTunnelEstablisherTest.java | 0 .../network/TrustManagerProviderTest.java | 0 .../android/client/SplitFactoryImpl.java | 3 +- .../client/network/DefaultBase64Encoder.java | 16 ------- .../network/LegacyTlsUpdaterAdapter.java | 29 +++++++++++++ .../client/network/HttpClientTest.java | 43 +++++++------------ 62 files changed, 223 insertions(+), 125 deletions(-) rename {main => http}/src/main/java/io/split/android/client/network/Base64Encoder.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/BaseHttpResponse.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/BaseHttpResponseImpl.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/CertificateChecker.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/CertificateCheckerImpl.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/ChainCleaner.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/ChainCleanerImpl.java (100%) create mode 100644 http/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java rename {main => http}/src/main/java/io/split/android/client/network/HttpClient.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpClientImpl.java (94%) rename {main => http}/src/main/java/io/split/android/client/network/HttpException.java (100%) create mode 100644 http/src/main/java/io/split/android/client/network/HttpHeaders.java rename {main => http}/src/main/java/io/split/android/client/network/HttpMethod.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpOverTunnelExecutor.java (100%) create mode 100644 http/src/main/java/io/split/android/client/network/HttpQueryParameters.java rename {main => http}/src/main/java/io/split/android/client/network/HttpRequest.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpRequestHelper.java (96%) rename {main => http}/src/main/java/io/split/android/client/network/HttpRequestImpl.java (94%) rename {main => http}/src/main/java/io/split/android/client/network/HttpResponse.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpResponseImpl.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpStreamRequest.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java (96%) rename {main => http}/src/main/java/io/split/android/client/network/HttpStreamResponse.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/PercentEscaper.java (97%) rename {main => http}/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProvider.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java (99%) rename {main => http}/src/main/java/io/split/android/client/network/RawHttpResponseParser.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/SslProxyTunnelEstablisher.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/Tls12OnlySocketFactory.java (100%) create mode 100644 http/src/main/java/io/split/android/client/network/TlsUpdater.java rename {main => http}/src/main/java/io/split/android/client/network/TrustManagerProvider.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/URIBuilder.java (76%) rename {main => http}/src/main/java/io/split/android/client/network/UnicodeEscaper.java (98%) rename {main => http}/src/main/java/io/split/android/client/network/UrlEscapers.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/UrlSanitizer.java (100%) rename {main => http}/src/main/java/io/split/android/client/network/UrlSanitizerImpl.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/CertificateCheckerImplTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/ChainCleanerImplTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java (57%) rename {main => http}/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/HttpOverTunnelExecutorTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/HttpStreamResponseTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/ProxySslSocketFactoryProviderImplTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/RawHttpResponseParserTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/SplitUrlConnectionAuthenticatorTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/SslProxyTunnelEstablisherTest.java (100%) rename {main => http}/src/test/java/io/split/android/client/network/TrustManagerProviderTest.java (100%) delete mode 100644 main/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java create mode 100644 main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java diff --git a/http/build.gradle b/http/build.gradle index 85ef7d707..f613652b7 100644 --- a/http/build.gradle +++ b/http/build.gradle @@ -16,7 +16,7 @@ android { dependencies { implementation libs.annotation implementation project(':logger') - implementation project(':http-domain') + api project(':http-domain') testImplementation libs.junit4 testImplementation libs.mockitoCore diff --git a/main/src/main/java/io/split/android/client/network/Base64Encoder.java b/http/src/main/java/io/split/android/client/network/Base64Encoder.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/Base64Encoder.java rename to http/src/main/java/io/split/android/client/network/Base64Encoder.java diff --git a/main/src/main/java/io/split/android/client/network/BaseHttpResponse.java b/http/src/main/java/io/split/android/client/network/BaseHttpResponse.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/BaseHttpResponse.java rename to http/src/main/java/io/split/android/client/network/BaseHttpResponse.java diff --git a/main/src/main/java/io/split/android/client/network/BaseHttpResponseImpl.java b/http/src/main/java/io/split/android/client/network/BaseHttpResponseImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/BaseHttpResponseImpl.java rename to http/src/main/java/io/split/android/client/network/BaseHttpResponseImpl.java diff --git a/main/src/main/java/io/split/android/client/network/CertificateChecker.java b/http/src/main/java/io/split/android/client/network/CertificateChecker.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/CertificateChecker.java rename to http/src/main/java/io/split/android/client/network/CertificateChecker.java diff --git a/main/src/main/java/io/split/android/client/network/CertificateCheckerImpl.java b/http/src/main/java/io/split/android/client/network/CertificateCheckerImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/CertificateCheckerImpl.java rename to http/src/main/java/io/split/android/client/network/CertificateCheckerImpl.java diff --git a/main/src/main/java/io/split/android/client/network/ChainCleaner.java b/http/src/main/java/io/split/android/client/network/ChainCleaner.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/ChainCleaner.java rename to http/src/main/java/io/split/android/client/network/ChainCleaner.java diff --git a/main/src/main/java/io/split/android/client/network/ChainCleanerImpl.java b/http/src/main/java/io/split/android/client/network/ChainCleanerImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/ChainCleanerImpl.java rename to http/src/main/java/io/split/android/client/network/ChainCleanerImpl.java diff --git a/http/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java b/http/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java new file mode 100644 index 000000000..4106c7784 --- /dev/null +++ b/http/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java @@ -0,0 +1,22 @@ +package io.split.android.client.network; + +import android.util.Base64; + +class DefaultBase64Encoder implements Base64Encoder { + + @Override + public String encode(String value) { + if (value == null) { + return null; + } + return Base64.encodeToString(value.getBytes(), Base64.NO_WRAP); + } + + @Override + public String encode(byte[] bytes) { + if (bytes == null) { + return null; + } + return Base64.encodeToString(bytes, Base64.NO_WRAP); + } +} diff --git a/main/src/main/java/io/split/android/client/network/HttpClient.java b/http/src/main/java/io/split/android/client/network/HttpClient.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpClient.java rename to http/src/main/java/io/split/android/client/network/HttpClient.java diff --git a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java b/http/src/main/java/io/split/android/client/network/HttpClientImpl.java similarity index 94% rename from main/src/main/java/io/split/android/client/network/HttpClientImpl.java rename to http/src/main/java/io/split/android/client/network/HttpClientImpl.java index f41271796..d846462b7 100644 --- a/main/src/main/java/io/split/android/client/network/HttpClientImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpClientImpl.java @@ -1,7 +1,5 @@ package io.split.android.client.network; -import android.content.Context; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -20,8 +18,6 @@ import javax.net.ssl.SSLSocketFactory; -import io.split.android.client.utils.Base64Util; -import io.split.android.client.utils.Utils; import io.split.android.client.utils.logger.Logger; public class HttpClientImpl implements HttpClient { @@ -180,7 +176,7 @@ private SplitUrlConnectionAuthenticator initializeProxyAuthenticator(HttpProxy p return null; } else if (proxyAuthenticator != null) { return new SplitUrlConnectionAuthenticator(proxyAuthenticator); - } else if (!Utils.isNullOrEmpty(proxy.getUsername())) { + } else if (proxy.getUsername() != null && !proxy.getUsername().isEmpty()) { return createBasicAuthenticator(proxy.getUsername(), proxy.getPassword()); } @@ -188,18 +184,7 @@ private SplitUrlConnectionAuthenticator initializeProxyAuthenticator(HttpProxy p } private static SplitUrlConnectionAuthenticator createBasicAuthenticator(String username, String password) { - return new SplitUrlConnectionAuthenticator(new SplitBasicAuthenticator(username, password, new Base64Encoder() { - - @Override - public String encode(String value) { - return Base64Util.encode(value); - } - - @Override - public String encode(byte[] bytes) { - return Base64Util.encode(bytes); - } - })); + return new SplitUrlConnectionAuthenticator(new SplitBasicAuthenticator(username, password, new DefaultBase64Encoder())); } public static class Builder { @@ -211,14 +196,15 @@ public static class Builder { private long mConnectionTimeout = -1; private DevelopmentSslConfig mDevelopmentSslConfig = null; private SSLSocketFactory mSslSocketFactory = null; - private Context mHostAppContext; + @Nullable + private TlsUpdater mTlsUpdater; private UrlSanitizer mUrlSanitizer; private CertificatePinningConfiguration mCertificatePinningConfiguration; private CertificateChecker mCertificateChecker; private Base64Decoder mBase64Decoder = new DefaultBase64Decoder(); - public Builder setContext(Context context) { - mHostAppContext = context; + public Builder setTlsUpdater(@Nullable TlsUpdater tlsUpdater) { + mTlsUpdater = tlsUpdater; return this; } @@ -279,13 +265,13 @@ Builder setBase64Decoder(Base64Decoder base64Decoder) { public HttpClient build() { if (mDevelopmentSslConfig == null) { - if (LegacyTlsUpdater.couldBeOld()) { - LegacyTlsUpdater.update(mHostAppContext); + if (mTlsUpdater != null && mTlsUpdater.couldBeOld()) { + mTlsUpdater.update(); } if (mProxy != null) { mSslSocketFactory = createSslSocketFactoryFromProxy(mProxy); - } else if (LegacyTlsUpdater.couldBeOld()) { + } else if (mTlsUpdater != null && mTlsUpdater.couldBeOld()) { try { mSslSocketFactory = new Tls12OnlySocketFactory(); } catch (NoSuchAlgorithmException | KeyManagementException e) { diff --git a/main/src/main/java/io/split/android/client/network/HttpException.java b/http/src/main/java/io/split/android/client/network/HttpException.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpException.java rename to http/src/main/java/io/split/android/client/network/HttpException.java diff --git a/http/src/main/java/io/split/android/client/network/HttpHeaders.java b/http/src/main/java/io/split/android/client/network/HttpHeaders.java new file mode 100644 index 000000000..f4d62cd89 --- /dev/null +++ b/http/src/main/java/io/split/android/client/network/HttpHeaders.java @@ -0,0 +1,43 @@ +package io.split.android.client.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class HttpHeaders { + + private final Map mHeaders; + + public HttpHeaders() { + mHeaders = new LinkedHashMap<>(); + } + + public HttpHeaders(@NonNull Map headers) { + mHeaders = new LinkedHashMap<>(headers); + } + + public void set(@NonNull String name, @NonNull String value) { + mHeaders.put(name, value); + } + + @Nullable + public String get(@NonNull String name) { + return mHeaders.get(name); + } + + @NonNull + public Map asMap() { + return Collections.unmodifiableMap(mHeaders); + } + + public boolean isEmpty() { + return mHeaders.isEmpty(); + } + + public int size() { + return mHeaders.size(); + } +} diff --git a/main/src/main/java/io/split/android/client/network/HttpMethod.java b/http/src/main/java/io/split/android/client/network/HttpMethod.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpMethod.java rename to http/src/main/java/io/split/android/client/network/HttpMethod.java diff --git a/main/src/main/java/io/split/android/client/network/HttpOverTunnelExecutor.java b/http/src/main/java/io/split/android/client/network/HttpOverTunnelExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpOverTunnelExecutor.java rename to http/src/main/java/io/split/android/client/network/HttpOverTunnelExecutor.java diff --git a/http/src/main/java/io/split/android/client/network/HttpQueryParameters.java b/http/src/main/java/io/split/android/client/network/HttpQueryParameters.java new file mode 100644 index 000000000..f2edc62bf --- /dev/null +++ b/http/src/main/java/io/split/android/client/network/HttpQueryParameters.java @@ -0,0 +1,29 @@ +package io.split.android.client.network; + +import androidx.annotation.NonNull; + +import java.util.AbstractMap; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public class HttpQueryParameters { + + private final Set> mParams = new LinkedHashSet<>(); + + @NonNull + public HttpQueryParameters add(@NonNull String key, @NonNull String value) { + mParams.add(new AbstractMap.SimpleEntry<>(key, value)); + return this; + } + + @NonNull + public Set> entries() { + return Collections.unmodifiableSet(mParams); + } + + public boolean isEmpty() { + return mParams.isEmpty(); + } +} diff --git a/main/src/main/java/io/split/android/client/network/HttpRequest.java b/http/src/main/java/io/split/android/client/network/HttpRequest.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpRequest.java rename to http/src/main/java/io/split/android/client/network/HttpRequest.java diff --git a/main/src/main/java/io/split/android/client/network/HttpRequestHelper.java b/http/src/main/java/io/split/android/client/network/HttpRequestHelper.java similarity index 96% rename from main/src/main/java/io/split/android/client/network/HttpRequestHelper.java rename to http/src/main/java/io/split/android/client/network/HttpRequestHelper.java index 4688f00b7..7cefcabaf 100644 --- a/main/src/main/java/io/split/android/client/network/HttpRequestHelper.java +++ b/http/src/main/java/io/split/android/client/network/HttpRequestHelper.java @@ -1,6 +1,5 @@ package io.split.android.client.network; -import static io.split.android.client.utils.Utils.getAsInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -92,11 +91,11 @@ private static HttpURLConnection openConnection(@Nullable Proxy proxy, static void applyTimeouts(long readTimeout, long connectionTimeout, HttpURLConnection connection) { if (readTimeout > 0) { - connection.setReadTimeout(getAsInt(readTimeout)); + connection.setReadTimeout((int) readTimeout); } if (connectionTimeout > 0) { - connection.setConnectTimeout(getAsInt(connectionTimeout)); + connection.setConnectTimeout((int) connectionTimeout); } } diff --git a/main/src/main/java/io/split/android/client/network/HttpRequestImpl.java b/http/src/main/java/io/split/android/client/network/HttpRequestImpl.java similarity index 94% rename from main/src/main/java/io/split/android/client/network/HttpRequestImpl.java rename to http/src/main/java/io/split/android/client/network/HttpRequestImpl.java index 1f2a0c402..669dd8598 100644 --- a/main/src/main/java/io/split/android/client/network/HttpRequestImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpRequestImpl.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import static io.split.android.client.network.HttpRequestHelper.applySslConfig; import static io.split.android.client.network.HttpRequestHelper.applyTimeouts; @@ -29,7 +29,6 @@ import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSocketFactory; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.utils.logger.Logger; public class HttpRequestImpl implements HttpRequest { @@ -37,6 +36,12 @@ public class HttpRequestImpl implements HttpRequest { public static final String CONTENT_TYPE = "Content-Type"; public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json; charset=utf-8"; + /** + * Non-retryable status code for SSL errors. + * Mirrors HttpStatus.INTERNAL_NON_RETRYABLE from :main. + */ + static final int NON_RETRYABLE_STATUS_CODE = 9009; + private final URI mUri; private final String mBody; private final HttpMethod mHttpMethod; @@ -73,11 +78,11 @@ public class HttpRequestImpl implements HttpRequest { @Nullable SSLSocketFactory sslSocketFactory, @NonNull UrlSanitizer urlSanitizer, @Nullable CertificateChecker certificateChecker) { - mUri = checkNotNull(uri); - mHttpMethod = checkNotNull(httpMethod); + mUri = requireNonNull(uri); + mHttpMethod = requireNonNull(httpMethod); mBody = body; - mUrlSanitizer = checkNotNull(urlSanitizer); - mHeaders = new HashMap<>(checkNotNull(headers)); + mUrlSanitizer = requireNonNull(urlSanitizer); + mHeaders = new HashMap<>(requireNonNull(headers)); mProxy = proxy; mHttpProxy = httpProxy; mProxyAuthenticator = proxyAuthenticator; @@ -119,7 +124,7 @@ private HttpResponse getRequest(AtomicBoolean wasRetried) throws HttpException { } catch (ProtocolException e) { throw new HttpException("Http method not allowed: " + e.getLocalizedMessage()); } catch (SSLPeerUnverifiedException e) { - throw new HttpException("SSL Peer Unverified: " + e.getLocalizedMessage(), HttpStatus.INTERNAL_NON_RETRYABLE.getCode()); + throw new HttpException("SSL Peer Unverified: " + e.getLocalizedMessage(), NON_RETRYABLE_STATUS_CODE); } catch (IOException e) { throw new HttpException("Something happened while retrieving data: " + e.getLocalizedMessage()); } finally { @@ -146,7 +151,7 @@ private HttpResponse postRequest(AtomicBoolean wasRetried) throws HttpException response = handleProxyAuthentication(response, false, wasRetried); } } catch (SSLPeerUnverifiedException e) { - throw new HttpException("SSL Peer Unverified: " + e.getLocalizedMessage(), HttpStatus.INTERNAL_NON_RETRYABLE.getCode()); + throw new HttpException("SSL Peer Unverified: " + e.getLocalizedMessage(), NON_RETRYABLE_STATUS_CODE); } catch (IOException e) { throw new HttpException("Something happened while posting data: " + e.getLocalizedMessage()); } finally { diff --git a/main/src/main/java/io/split/android/client/network/HttpResponse.java b/http/src/main/java/io/split/android/client/network/HttpResponse.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpResponse.java rename to http/src/main/java/io/split/android/client/network/HttpResponse.java diff --git a/main/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java b/http/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java rename to http/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java diff --git a/main/src/main/java/io/split/android/client/network/HttpResponseImpl.java b/http/src/main/java/io/split/android/client/network/HttpResponseImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpResponseImpl.java rename to http/src/main/java/io/split/android/client/network/HttpResponseImpl.java diff --git a/main/src/main/java/io/split/android/client/network/HttpStreamRequest.java b/http/src/main/java/io/split/android/client/network/HttpStreamRequest.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpStreamRequest.java rename to http/src/main/java/io/split/android/client/network/HttpStreamRequest.java diff --git a/main/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java b/http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java similarity index 96% rename from main/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java rename to http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java index d6f48b8d9..c8573e235 100644 --- a/main/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java @@ -2,7 +2,7 @@ import static io.split.android.client.network.HttpRequestHelper.checkPins; import static io.split.android.client.network.HttpRequestHelper.createConnection; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import static io.split.android.client.network.HttpRequestHelper.applySslConfig; import static io.split.android.client.network.HttpRequestHelper.applyTimeouts; @@ -28,7 +28,6 @@ import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSocketFactory; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.utils.logger.Logger; public class HttpStreamRequestImpl implements HttpStreamRequest { @@ -72,11 +71,11 @@ public class HttpStreamRequestImpl implements HttpStreamRequest { @Nullable HttpProxy httpProxy, @Nullable ProxyCredentialsProvider proxyCredentialsProvider, @Nullable ProxyCacertConnectionHandler proxyCacertConnectionHandler) { - mUri = checkNotNull(uri); + mUri = requireNonNull(uri); mHttpMethod = HttpMethod.GET; mProxy = proxy; - mUrlSanitizer = checkNotNull(urlSanitizer); - mHeaders = new HashMap<>(checkNotNull(headers)); + mUrlSanitizer = requireNonNull(urlSanitizer); + mHeaders = new HashMap<>(requireNonNull(headers)); mProxyAuthenticator = proxyAuthenticator; mConnectionTimeout = connectionTimeout; mDevelopmentSslConfig = developmentSslConfig; @@ -141,7 +140,7 @@ private HttpStreamResponse getRequest() throws HttpException, IOException { throw new HttpException("Http method not allowed: " + e.getLocalizedMessage()); } catch (SSLPeerUnverifiedException e) { disconnect(); - throw new HttpException("SSL peer not verified: " + e.getLocalizedMessage(), HttpStatus.INTERNAL_NON_RETRYABLE.getCode()); + throw new HttpException("SSL peer not verified: " + e.getLocalizedMessage(), HttpRequestImpl.NON_RETRYABLE_STATUS_CODE); } catch (SocketException e) { disconnect(); // Let socket-related IOExceptions pass through unwrapped for consistent error handling diff --git a/main/src/main/java/io/split/android/client/network/HttpStreamResponse.java b/http/src/main/java/io/split/android/client/network/HttpStreamResponse.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpStreamResponse.java rename to http/src/main/java/io/split/android/client/network/HttpStreamResponse.java diff --git a/main/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java b/http/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java rename to http/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java diff --git a/main/src/main/java/io/split/android/client/network/PercentEscaper.java b/http/src/main/java/io/split/android/client/network/PercentEscaper.java similarity index 97% rename from main/src/main/java/io/split/android/client/network/PercentEscaper.java rename to http/src/main/java/io/split/android/client/network/PercentEscaper.java index b61bed710..9f99ceb8e 100644 --- a/main/src/main/java/io/split/android/client/network/PercentEscaper.java +++ b/http/src/main/java/io/split/android/client/network/PercentEscaper.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; /** * Based on Guava PercentEscaper @@ -37,7 +37,7 @@ final class PercentEscaper extends UnicodeEscaper { * @throws IllegalArgumentException if any of the parameters were invalid */ public PercentEscaper(String safeChars, boolean plusForSpace) { - checkNotNull(safeChars); // eager for GWT. + requireNonNull(safeChars); // eager for GWT. // Avoid any misunderstandings about the behavior of this escaper if (safeChars.matches(".*[0-9A-Za-z].*")) { throw new IllegalArgumentException( @@ -78,7 +78,7 @@ private static boolean[] createSafeOctets(String safeChars) { */ @Override protected int nextEscapeIndex(CharSequence csq, int index, int end) { - checkNotNull(csq); + requireNonNull(csq); for (; index < end; index++) { char c = csq.charAt(index); if (c >= safeOctets.length || !safeOctets[c]) { @@ -94,7 +94,7 @@ protected int nextEscapeIndex(CharSequence csq, int index, int end) { */ @Override public String escape(String s) { - checkNotNull(s); + requireNonNull(s); int slen = s.length(); for (int index = 0; index < slen; index++) { char c = s.charAt(index); diff --git a/main/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java b/http/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java rename to http/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java diff --git a/main/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProvider.java b/http/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProvider.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProvider.java rename to http/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProvider.java diff --git a/main/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java b/http/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java similarity index 99% rename from main/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java rename to http/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java index 49a84c134..8978258cf 100644 --- a/main/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java +++ b/http/src/main/java/io/split/android/client/network/ProxySslSocketFactoryProviderImpl.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -43,7 +43,7 @@ class ProxySslSocketFactoryProviderImpl implements ProxySslSocketFactoryProvider } ProxySslSocketFactoryProviderImpl(@NonNull Base64Decoder base64Decoder) { - mBase64Decoder = checkNotNull(base64Decoder); + mBase64Decoder = requireNonNull(base64Decoder); } @Override diff --git a/main/src/main/java/io/split/android/client/network/RawHttpResponseParser.java b/http/src/main/java/io/split/android/client/network/RawHttpResponseParser.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/RawHttpResponseParser.java rename to http/src/main/java/io/split/android/client/network/RawHttpResponseParser.java diff --git a/main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java b/http/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java rename to http/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java diff --git a/main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java b/http/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java rename to http/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java diff --git a/main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java b/http/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java rename to http/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java diff --git a/main/src/main/java/io/split/android/client/network/SslProxyTunnelEstablisher.java b/http/src/main/java/io/split/android/client/network/SslProxyTunnelEstablisher.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/SslProxyTunnelEstablisher.java rename to http/src/main/java/io/split/android/client/network/SslProxyTunnelEstablisher.java diff --git a/main/src/main/java/io/split/android/client/network/Tls12OnlySocketFactory.java b/http/src/main/java/io/split/android/client/network/Tls12OnlySocketFactory.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/Tls12OnlySocketFactory.java rename to http/src/main/java/io/split/android/client/network/Tls12OnlySocketFactory.java diff --git a/http/src/main/java/io/split/android/client/network/TlsUpdater.java b/http/src/main/java/io/split/android/client/network/TlsUpdater.java new file mode 100644 index 000000000..4fff431f4 --- /dev/null +++ b/http/src/main/java/io/split/android/client/network/TlsUpdater.java @@ -0,0 +1,14 @@ +package io.split.android.client.network; + +public interface TlsUpdater { + + /** + * Return true if the device may need a TLS update. + */ + boolean couldBeOld(); + + /** + * Perform the TLS update. + */ + void update(); +} diff --git a/main/src/main/java/io/split/android/client/network/TrustManagerProvider.java b/http/src/main/java/io/split/android/client/network/TrustManagerProvider.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/TrustManagerProvider.java rename to http/src/main/java/io/split/android/client/network/TrustManagerProvider.java diff --git a/main/src/main/java/io/split/android/client/network/URIBuilder.java b/http/src/main/java/io/split/android/client/network/URIBuilder.java similarity index 76% rename from main/src/main/java/io/split/android/client/network/URIBuilder.java rename to http/src/main/java/io/split/android/client/network/URIBuilder.java index e5aacc0e5..3611aaf27 100644 --- a/main/src/main/java/io/split/android/client/network/URIBuilder.java +++ b/http/src/main/java/io/split/android/client/network/URIBuilder.java @@ -1,25 +1,23 @@ package io.split.android.client.network; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; import androidx.annotation.NonNull; -import androidx.core.util.Pair; - import java.net.URI; import java.net.URISyntaxException; +import java.util.AbstractMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; -import io.split.android.client.utils.Utils; - public class URIBuilder { private final URI mRootURI; - private final Set> mParams; + private final Set> mParams; private String mPath; private String mQueryString; public URIBuilder(@NonNull URI rootURI, String path) { - mRootURI = checkNotNull(rootURI); + mRootURI = requireNonNull(rootURI); String rootPath = mRootURI.getRawPath(); if (path != null && rootPath != null) { mPath = String.format("%s/%s", rootPath, path); @@ -40,13 +38,13 @@ public URIBuilder(@NonNull URI rootURI) { public URIBuilder addParameter(@NonNull String param, @NonNull String value) { if (param != null && value != null) { - mParams.add(new Pair<>(param, value)); + mParams.add(new AbstractMap.SimpleEntry<>(param, value)); } return this; } public URIBuilder defaultQueryString(@NonNull String queryString) { - if (!Utils.isNullOrEmpty(queryString)) { + if (queryString != null && !queryString.isEmpty()) { mQueryString = queryString; } return this; @@ -57,14 +55,14 @@ public URI build() throws URISyntaxException { String params = null; if (mParams.size() > 0) { StringBuilder query = new StringBuilder(); - for (Pair param : mParams) { - query.append(param.first).append("=").append(param.second).append("&"); + for (Map.Entry param : mParams) { + query.append(param.getKey()).append("=").append(param.getValue()).append("&"); } params = query.substring(0, query.length() - 1); } - if (!Utils.isNullOrEmpty(mQueryString)) { - if (!Utils.isNullOrEmpty(params)) { + if (mQueryString != null && !mQueryString.isEmpty()) { + if (params != null && !params.isEmpty()) { if (!"&".equals(mQueryString.substring(0, 1))) { params = params + "&"; } diff --git a/main/src/main/java/io/split/android/client/network/UnicodeEscaper.java b/http/src/main/java/io/split/android/client/network/UnicodeEscaper.java similarity index 98% rename from main/src/main/java/io/split/android/client/network/UnicodeEscaper.java rename to http/src/main/java/io/split/android/client/network/UnicodeEscaper.java index 4ed19ab54..7f3f6fd67 100644 --- a/main/src/main/java/io/split/android/client/network/UnicodeEscaper.java +++ b/http/src/main/java/io/split/android/client/network/UnicodeEscaper.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -import static io.split.android.client.utils.Utils.checkNotNull; +import static java.util.Objects.requireNonNull; /** * Based on Guava UnicodeEscaper @@ -14,7 +14,7 @@ protected UnicodeEscaper() {} protected abstract char[] escape(int cp); public String escape(String string) { - checkNotNull(string); + requireNonNull(string); int end = string.length(); int index = nextEscapeIndex(string, 0, end); return index == end ? string : escapeSlow(string, index); @@ -136,7 +136,7 @@ protected final String escapeSlow(String s, int index) { * surrogate character at the end of the sequence */ protected static int codePointAt(CharSequence seq, int index, int end) { - checkNotNull(seq); + requireNonNull(seq); if (index < end) { char c1 = seq.charAt(index++); if (c1 < Character.MIN_HIGH_SURROGATE || c1 > Character.MAX_LOW_SURROGATE) { diff --git a/main/src/main/java/io/split/android/client/network/UrlEscapers.java b/http/src/main/java/io/split/android/client/network/UrlEscapers.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/UrlEscapers.java rename to http/src/main/java/io/split/android/client/network/UrlEscapers.java diff --git a/main/src/main/java/io/split/android/client/network/UrlSanitizer.java b/http/src/main/java/io/split/android/client/network/UrlSanitizer.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/UrlSanitizer.java rename to http/src/main/java/io/split/android/client/network/UrlSanitizer.java diff --git a/main/src/main/java/io/split/android/client/network/UrlSanitizerImpl.java b/http/src/main/java/io/split/android/client/network/UrlSanitizerImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/UrlSanitizerImpl.java rename to http/src/main/java/io/split/android/client/network/UrlSanitizerImpl.java diff --git a/main/src/test/java/io/split/android/client/network/CertificateCheckerImplTest.java b/http/src/test/java/io/split/android/client/network/CertificateCheckerImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/CertificateCheckerImplTest.java rename to http/src/test/java/io/split/android/client/network/CertificateCheckerImplTest.java diff --git a/main/src/test/java/io/split/android/client/network/ChainCleanerImplTest.java b/http/src/test/java/io/split/android/client/network/ChainCleanerImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/ChainCleanerImplTest.java rename to http/src/test/java/io/split/android/client/network/ChainCleanerImplTest.java diff --git a/main/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java b/http/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java similarity index 57% rename from main/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java rename to http/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java index 738300ce7..ddbbc5078 100644 --- a/main/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java +++ b/http/src/test/java/io/split/android/client/network/DefaultBase64EncoderTest.java @@ -2,6 +2,8 @@ import static org.mockito.Mockito.mockStatic; +import android.util.Base64; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -9,39 +11,37 @@ import java.nio.charset.StandardCharsets; -import io.split.android.client.utils.Base64Util; - public class DefaultBase64EncoderTest { - + private DefaultBase64Encoder encoder; - private MockedStatic mockedBase64Util; - + private MockedStatic mockedBase64; + @Before public void setUp() { encoder = new DefaultBase64Encoder(); - mockedBase64Util = mockStatic(Base64Util.class); + mockedBase64 = mockStatic(Base64.class); } - + @After public void tearDown() { - mockedBase64Util.close(); + mockedBase64.close(); } - + @Test - public void encodeStringUsesBase64Util() { + public void encodeStringUsesAndroidBase64() { String input = "test string"; - + encoder.encode(input); - - mockedBase64Util.verify(() -> Base64Util.encode(input)); + + mockedBase64.verify(() -> Base64.encodeToString(input.getBytes(), Base64.NO_WRAP)); } - + @Test - public void encodeByteArrayUsesBase64Util() { + public void encodeByteArrayUsesAndroidBase64() { byte[] input = "test bytes".getBytes(StandardCharsets.UTF_8); - + encoder.encode(input); - - mockedBase64Util.verify(() -> Base64Util.encode(input)); + + mockedBase64.verify(() -> Base64.encodeToString(input, Base64.NO_WRAP)); } } diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java b/http/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java rename to http/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java diff --git a/main/src/test/java/io/split/android/client/network/HttpOverTunnelExecutorTest.java b/http/src/test/java/io/split/android/client/network/HttpOverTunnelExecutorTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/HttpOverTunnelExecutorTest.java rename to http/src/test/java/io/split/android/client/network/HttpOverTunnelExecutorTest.java diff --git a/main/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java b/http/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java rename to http/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java diff --git a/main/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java b/http/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java rename to http/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java diff --git a/main/src/test/java/io/split/android/client/network/HttpStreamResponseTest.java b/http/src/test/java/io/split/android/client/network/HttpStreamResponseTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/HttpStreamResponseTest.java rename to http/src/test/java/io/split/android/client/network/HttpStreamResponseTest.java diff --git a/main/src/test/java/io/split/android/client/network/ProxySslSocketFactoryProviderImplTest.java b/http/src/test/java/io/split/android/client/network/ProxySslSocketFactoryProviderImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/ProxySslSocketFactoryProviderImplTest.java rename to http/src/test/java/io/split/android/client/network/ProxySslSocketFactoryProviderImplTest.java diff --git a/main/src/test/java/io/split/android/client/network/RawHttpResponseParserTest.java b/http/src/test/java/io/split/android/client/network/RawHttpResponseParserTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/RawHttpResponseParserTest.java rename to http/src/test/java/io/split/android/client/network/RawHttpResponseParserTest.java diff --git a/main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java b/http/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java rename to http/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java diff --git a/main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java b/http/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java rename to http/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java diff --git a/main/src/test/java/io/split/android/client/network/SplitUrlConnectionAuthenticatorTest.java b/http/src/test/java/io/split/android/client/network/SplitUrlConnectionAuthenticatorTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/SplitUrlConnectionAuthenticatorTest.java rename to http/src/test/java/io/split/android/client/network/SplitUrlConnectionAuthenticatorTest.java diff --git a/main/src/test/java/io/split/android/client/network/SslProxyTunnelEstablisherTest.java b/http/src/test/java/io/split/android/client/network/SslProxyTunnelEstablisherTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/SslProxyTunnelEstablisherTest.java rename to http/src/test/java/io/split/android/client/network/SslProxyTunnelEstablisherTest.java diff --git a/main/src/test/java/io/split/android/client/network/TrustManagerProviderTest.java b/http/src/test/java/io/split/android/client/network/TrustManagerProviderTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/network/TrustManagerProviderTest.java rename to http/src/test/java/io/split/android/client/network/TrustManagerProviderTest.java diff --git a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java index 8bb12d71f..0016b6a76 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -33,6 +33,7 @@ import io.split.android.client.lifecycle.SplitLifecycleManagerImpl; import io.split.android.client.network.HttpClient; import io.split.android.client.network.HttpClientImpl; +import io.split.android.client.network.LegacyTlsUpdaterAdapter; import io.split.android.client.service.CleanUpDatabaseTask; import io.split.android.client.service.SplitApiFacade; import io.split.android.client.service.executor.SplitSingleThreadTaskExecutor; @@ -385,7 +386,7 @@ private static HttpClient getHttpClient(@NonNull String apiToken, .setConnectionTimeout(config.connectionTimeout()) .setReadTimeout(config.readTimeout()) .setDevelopmentSslConfig(config.developmentSslConfig()) - .setContext(context) + .setTlsUpdater(new LegacyTlsUpdaterAdapter(context)) .setProxyAuthenticator(config.authenticator()); if (config.proxy() != null) { builder.setProxy(config.proxy()); diff --git a/main/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java b/main/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java deleted file mode 100644 index e1333ca80..000000000 --- a/main/src/main/java/io/split/android/client/network/DefaultBase64Encoder.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.split.android.client.network; - -import io.split.android.client.utils.Base64Util; - -class DefaultBase64Encoder implements Base64Encoder { - - @Override - public String encode(String value) { - return Base64Util.encode(value); - } - - @Override - public String encode(byte[] bytes) { - return Base64Util.encode(bytes); - } -} diff --git a/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java b/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java new file mode 100644 index 000000000..6a1bf7d42 --- /dev/null +++ b/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java @@ -0,0 +1,29 @@ +package io.split.android.client.network; + +import android.content.Context; + +import androidx.annotation.Nullable; + +/** + * Adapter that bridges the :http module's {@link TlsUpdater} SPI with the + * :main module's {@link LegacyTlsUpdater}. + */ +public class LegacyTlsUpdaterAdapter implements TlsUpdater { + + @Nullable + private final Context mContext; + + public LegacyTlsUpdaterAdapter(@Nullable Context context) { + mContext = context; + } + + @Override + public boolean couldBeOld() { + return LegacyTlsUpdater.couldBeOld(); + } + + @Override + public void update() { + LegacyTlsUpdater.update(mContext); + } +} diff --git a/main/src/test/java/io/split/android/client/network/HttpClientTest.java b/main/src/test/java/io/split/android/client/network/HttpClientTest.java index a2f2c8c86..6a4610127 100644 --- a/main/src/test/java/io/split/android/client/network/HttpClientTest.java +++ b/main/src/test/java/io/split/android/client/network/HttpClientTest.java @@ -10,8 +10,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import android.content.Context; - import androidx.annotation.NonNull; import com.google.gson.reflect.TypeToken; @@ -281,7 +279,6 @@ public MockResponse dispatch(RecordedRequest request) { mProxyServer.start(); HttpClient client = new HttpClientImpl.Builder() - .setContext(mock(Context.class)) .setUrlSanitizer(mUrlSanitizerMock) .setProxy(HttpProxy.newBuilder(mProxyServer.getHostName(), mProxyServer.getPort()).buildLegacy()) .build(); @@ -316,7 +313,6 @@ public MockResponse dispatch(RecordedRequest request) { mProxyServer.start(); HttpClient client = new HttpClientImpl.Builder() - .setContext(mock(Context.class)) .setUrlSanitizer(mUrlSanitizerMock) .setProxyAuthenticator(new SplitAuthenticator() { @Override @@ -371,7 +367,6 @@ public MockResponse dispatch(RecordedRequest request) { mProxyServer.start(); HttpClient client = new HttpClientImpl.Builder() - .setContext(mock(Context.class)) .setUrlSanitizer(mUrlSanitizerMock) .setProxyAuthenticator(new SplitAuthenticator() { @Override @@ -407,36 +402,30 @@ public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) @Test public void buildUsesTls12FactoryWhenLegacyAndNoProxy() throws Exception { - Context context = mock(Context.class); - - try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { - legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(true); + TlsUpdater tlsUpdater = mock(TlsUpdater.class); + when(tlsUpdater.couldBeOld()).thenReturn(true); - HttpClient legacyClient = new HttpClientImpl.Builder() - .setContext(context) - .setUrlSanitizer(mUrlSanitizerMock) - .build(); + HttpClient legacyClient = new HttpClientImpl.Builder() + .setTlsUpdater(tlsUpdater) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); - legacyMock.verify(() -> LegacyTlsUpdater.update(context)); - assertTrue(((HttpClientImpl) legacyClient).getSslSocketFactory() instanceof Tls12OnlySocketFactory); - } + Mockito.verify(tlsUpdater).update(); + assertTrue(((HttpClientImpl) legacyClient).getSslSocketFactory() instanceof Tls12OnlySocketFactory); } @Test public void buildUsesDefaultSslWhenNotLegacyAndNoProxy() throws Exception { - Context context = mock(Context.class); + TlsUpdater tlsUpdater = mock(TlsUpdater.class); + when(tlsUpdater.couldBeOld()).thenReturn(false); - try (MockedStatic legacyMock = Mockito.mockStatic(LegacyTlsUpdater.class)) { - legacyMock.when(LegacyTlsUpdater::couldBeOld).thenReturn(false); - - HttpClient modernClient = new HttpClientImpl.Builder() - .setContext(context) - .setUrlSanitizer(mUrlSanitizerMock) - .build(); + HttpClient modernClient = new HttpClientImpl.Builder() + .setTlsUpdater(tlsUpdater) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); - legacyMock.verify(() -> LegacyTlsUpdater.update(context), Mockito.never()); - assertNull(((HttpClientImpl) modernClient).getSslSocketFactory()); - } + Mockito.verify(tlsUpdater, Mockito.never()).update(); + assertNull(((HttpClientImpl) modernClient).getSslSocketFactory()); } From 3317ea865c968c450bf7ba742a8f1c86a8949d63 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Mon, 9 Feb 2026 16:49:30 -0300 Subject: [PATCH 06/23] Update http README --- http/README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/http/README.md b/http/README.md index 199f22805..8ece8d648 100644 --- a/http/README.md +++ b/http/README.md @@ -1,5 +1,58 @@ # HTTP module -This module provides the HTTP client implementation used internally by the Split SDK. +Internal HTTP client for the Split SDK. Not exposed to SDK consumers. -Includes request/response lifecycle, certificate pinning runtime, proxy tunnelling, and TLS configuration. Hidden from SDK consumers. +## Building an `HttpClient` + +Use `HttpClientImpl.Builder` to create an instance: + +```java +HttpClient client = new HttpClientImpl.Builder() + .setConnectionTimeout(15_000) + .setReadTimeout(15_000) + .setTlsUpdater(tlsUpdater) // optional – TlsUpdater SPI + .setProxy(httpProxy) // optional – proxy config from :http-domain + .setProxyAuthenticator(authenticator) // optional – SplitAuthenticator from :http-domain + .setCertificatePinningConfiguration(pinConfig) // optional – cert pins from :http-domain + .setDevelopmentSslConfig(devSslConfig) // optional – dev/test SSL overrides + .build(); +``` + +## Making requests + +```java +// Simple GET +HttpRequest req = client.request(uri, HttpMethod.GET); +HttpResponse resp = req.execute(); + +// POST with body and extra headers +HttpRequest post = client.request(uri, HttpMethod.POST, jsonBody, extraHeaders); +HttpResponse resp = post.execute(); + +// SSE streaming +HttpStreamRequest stream = client.streamRequest(uri); +HttpStreamResponse streamResp = stream.execute(); +``` + +## Global headers + +```java +client.setHeader("Authorization", "Bearer " + apiKey); +client.addHeaders(commonHeaders); + +// Streaming-specific headers (only applied to streamRequest calls) +client.setStreamingHeader("SplitSDKClientKey", clientKey); +``` + +## TLS on older devices + +Implement the `TlsUpdater` SPI and pass it to the builder. The client calls `couldBeOld()` to decide whether to force TLS 1.2 via `Tls12OnlySocketFactory`. + +## URI building + +```java +URI uri = new URIBuilder("https://sdk.split.io/api") + .addPath("splitChanges") + .addParameter("since", "-1") + .build(); +``` From 666e6666d3f4777fa27973f74432ab1b9b446db0 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 10 Feb 2026 10:04:09 -0300 Subject: [PATCH 07/23] Remove unused classes; move helper to private method --- .../android/client/network/HttpHeaders.java | 43 ------------------- .../client/network/HttpQueryParameters.java | 29 ------------- .../client/network/HttpRequestHelper.java | 11 ++++- .../io/split/android/client/utils/Utils.java | 8 ---- 4 files changed, 9 insertions(+), 82 deletions(-) delete mode 100644 http/src/main/java/io/split/android/client/network/HttpHeaders.java delete mode 100644 http/src/main/java/io/split/android/client/network/HttpQueryParameters.java diff --git a/http/src/main/java/io/split/android/client/network/HttpHeaders.java b/http/src/main/java/io/split/android/client/network/HttpHeaders.java deleted file mode 100644 index f4d62cd89..000000000 --- a/http/src/main/java/io/split/android/client/network/HttpHeaders.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.split.android.client.network; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -public class HttpHeaders { - - private final Map mHeaders; - - public HttpHeaders() { - mHeaders = new LinkedHashMap<>(); - } - - public HttpHeaders(@NonNull Map headers) { - mHeaders = new LinkedHashMap<>(headers); - } - - public void set(@NonNull String name, @NonNull String value) { - mHeaders.put(name, value); - } - - @Nullable - public String get(@NonNull String name) { - return mHeaders.get(name); - } - - @NonNull - public Map asMap() { - return Collections.unmodifiableMap(mHeaders); - } - - public boolean isEmpty() { - return mHeaders.isEmpty(); - } - - public int size() { - return mHeaders.size(); - } -} diff --git a/http/src/main/java/io/split/android/client/network/HttpQueryParameters.java b/http/src/main/java/io/split/android/client/network/HttpQueryParameters.java deleted file mode 100644 index f2edc62bf..000000000 --- a/http/src/main/java/io/split/android/client/network/HttpQueryParameters.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.split.android.client.network; - -import androidx.annotation.NonNull; - -import java.util.AbstractMap; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; - -public class HttpQueryParameters { - - private final Set> mParams = new LinkedHashSet<>(); - - @NonNull - public HttpQueryParameters add(@NonNull String key, @NonNull String value) { - mParams.add(new AbstractMap.SimpleEntry<>(key, value)); - return this; - } - - @NonNull - public Set> entries() { - return Collections.unmodifiableSet(mParams); - } - - public boolean isEmpty() { - return mParams.isEmpty(); - } -} diff --git a/http/src/main/java/io/split/android/client/network/HttpRequestHelper.java b/http/src/main/java/io/split/android/client/network/HttpRequestHelper.java index 7cefcabaf..14e5a5b06 100644 --- a/http/src/main/java/io/split/android/client/network/HttpRequestHelper.java +++ b/http/src/main/java/io/split/android/client/network/HttpRequestHelper.java @@ -91,14 +91,21 @@ private static HttpURLConnection openConnection(@Nullable Proxy proxy, static void applyTimeouts(long readTimeout, long connectionTimeout, HttpURLConnection connection) { if (readTimeout > 0) { - connection.setReadTimeout((int) readTimeout); + connection.setReadTimeout(getAsInt(readTimeout)); } if (connectionTimeout > 0) { - connection.setConnectTimeout((int) connectionTimeout); + connection.setConnectTimeout(getAsInt(connectionTimeout)); } } + private static int getAsInt(long value) { + if (value > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + return (int) value; + } + static void applySslConfig(SSLSocketFactory sslSocketFactory, DevelopmentSslConfig developmentSslConfig, HttpURLConnection connection) { if (sslSocketFactory != null) { if (connection instanceof HttpsURLConnection) { diff --git a/main/src/main/java/io/split/android/client/utils/Utils.java b/main/src/main/java/io/split/android/client/utils/Utils.java index 8341d776c..ff8e7d4eb 100644 --- a/main/src/main/java/io/split/android/client/utils/Utils.java +++ b/main/src/main/java/io/split/android/client/utils/Utils.java @@ -55,14 +55,6 @@ public static void checkArgument(boolean expression) { } } - public static int getAsInt(long value) { - if (value > Integer.MAX_VALUE) { - return Integer.MAX_VALUE; - } else { - return (int) value; - } - } - public static List> partition(List list, int size) { if (list == null) { return new ArrayList<>(); From a92c45c35cf5314fef1e8846d3f349235bbf1554 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 10 Feb 2026 10:06:46 -0300 Subject: [PATCH 08/23] Fix Javadoc --- .../split/android/client/network/LegacyTlsUpdaterAdapter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java b/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java index 6a1bf7d42..162fcee9b 100644 --- a/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java +++ b/main/src/main/java/io/split/android/client/network/LegacyTlsUpdaterAdapter.java @@ -5,8 +5,8 @@ import androidx.annotation.Nullable; /** - * Adapter that bridges the :http module's {@link TlsUpdater} SPI with the - * :main module's {@link LegacyTlsUpdater}. + * Adapter that bridges the :http module's {@link TlsUpdater} interface with the + * :main module's {@link LegacyTlsUpdater} class. */ public class LegacyTlsUpdaterAdapter implements TlsUpdater { From 6cbe55ae487ab9aec049c7fa318799e6b3bd84cd Mon Sep 17 00:00:00 2001 From: gthea Date: Fri, 13 Feb 2026 15:05:09 -0300 Subject: [PATCH 09/23] Rename http-domain to http-api (#866) --- .github/workflows/sonarqube.yml | 3 - build.gradle | 2 +- {http-domain => http-api}/.gitignore | 0 http-api/README.md | 100 +++++++++++ {http-domain => http-api}/build.gradle | 2 +- {http-domain => http-api}/consumer-rules.pro | 0 {http-domain => http-api}/proguard-rules.pro | 0 .../src/main/AndroidManifest.xml | 0 .../android/client/network/Algorithm.java | 2 +- .../client/network/AuthenticatedRequest.java | 0 .../android/client/network/Authenticator.java | 0 .../android/client/network/Base64Decoder.java | 2 +- .../network/BasicCredentialsProvider.java | 0 .../network/BearerCredentialsProvider.java | 0 .../network/CertificateCheckerHelper.java | 2 +- .../client/network/CertificatePin.java | 0 .../CertificatePinningConfiguration.java | 0 .../CertificatePinningFailureListener.java | 0 .../client/network/DefaultBase64Decoder.java | 2 +- .../client/network/DevelopmentSslConfig.java | 0 .../network/HttpClientConfiguration.java | 0 .../android/client/network/HttpProxy.java | 8 +- .../android/client/network/PinEncoder.java | 2 +- .../client/network/PinEncoderImpl.java | 2 +- .../client/network/ProxyConfiguration.java | 0 .../network/ProxyCredentialsProvider.java | 0 .../client/network/SplitAuthenticator.java | 0 .../network/CertificateCheckerHelperTest.java | 0 .../CertificatePinningConfigurationTest.java | 0 .../network/HttpClientConfigurationTest.java | 0 .../client/network/PinEncoderImplTest.java | 0 http-domain/README.md | 5 - http/README.md | 92 ++++++++-- http/build.gradle | 2 +- .../client/network/HttpClientImpl.java | 70 +++++++- .../client/network/HttpRequestImpl.java | 2 +- .../client/network/HttpStreamRequestImpl.java | 2 +- .../network/HttpStreamResponseImpl.java | 2 +- .../android/client/network/UrlEscapers.java | 2 +- ...ttpClientImplBuilderConfigurationTest.java | 160 ++++++++++++++++++ main/build.gradle | 2 +- .../android/client/SplitFactoryImpl.java | 34 ++-- .../network/CertificatePinSerializer.java | 67 ++++++++ ...rtificatePinningConfigurationProvider.java | 21 +-- .../io/split/android/client/utils/Json.java | 3 + .../SplitFactoryImplConfigMappingTest.java | 118 +++++++++++++ .../network/CertificatePinSerializerTest.java | 129 ++++++++++++++ settings.gradle | 2 +- sonar-project.properties | 28 ++- 49 files changed, 788 insertions(+), 80 deletions(-) rename {http-domain => http-api}/.gitignore (100%) create mode 100644 http-api/README.md rename {http-domain => http-api}/build.gradle (89%) rename {http-domain => http-api}/consumer-rules.pro (100%) rename {http-domain => http-api}/proguard-rules.pro (100%) rename {http-domain => http-api}/src/main/AndroidManifest.xml (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/Algorithm.java (84%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/AuthenticatedRequest.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/Authenticator.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/Base64Decoder.java (70%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java (98%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/CertificatePin.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java (90%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/HttpClientConfiguration.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/HttpProxy.java (93%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/PinEncoder.java (84%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/PinEncoderImpl.java (96%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/ProxyConfiguration.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java (100%) rename {http-domain => http-api}/src/main/java/io/split/android/client/network/SplitAuthenticator.java (100%) rename {http-domain => http-api}/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java (100%) rename {http-domain => http-api}/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java (100%) rename {http-domain => http-api}/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java (100%) rename {http-domain => http-api}/src/test/java/io/split/android/client/network/PinEncoderImplTest.java (100%) delete mode 100644 http-domain/README.md create mode 100644 http/src/test/java/io/split/android/client/network/HttpClientImplBuilderConfigurationTest.java create mode 100644 main/src/main/java/io/split/android/client/network/CertificatePinSerializer.java create mode 100644 main/src/test/java/io/split/android/client/SplitFactoryImplConfigMappingTest.java create mode 100644 main/src/test/java/io/split/android/client/network/CertificatePinSerializerTest.java diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c2f306a64..94a0bf3c3 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -9,9 +9,6 @@ on: pull_request: branches: - '*' - push: - branches: - - master concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/build.gradle b/build.gradle index 64f9cdfbc..a947d08b6 100644 --- a/build.gradle +++ b/build.gradle @@ -140,7 +140,7 @@ dependencies { include project(':events') include project(':events-domain') include project(':api') - include project(':http-domain') + include project(':http-api') include project(':http') } diff --git a/http-domain/.gitignore b/http-api/.gitignore similarity index 100% rename from http-domain/.gitignore rename to http-api/.gitignore diff --git a/http-api/README.md b/http-api/README.md new file mode 100644 index 000000000..1d3cc5caf --- /dev/null +++ b/http-api/README.md @@ -0,0 +1,100 @@ +# HTTP API module + +Public contracts and configuration types for the HTTP client. +These types are exposed to SDK consumers through the `:main` module's `api` dependency. + +## `HttpClientConfiguration` + +Bundles all HTTP client settings into a single object: + +```java +HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(15_000) + .readTimeout(15_000) + .proxy(proxy) + .proxyAuthenticator(authenticator) + .certificatePinningConfiguration(pinConfig) + .developmentSslConfig(devSsl) + .build(); +``` + +## Proxy configuration + +### Basic auth + +```java +HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080) + .basicAuth("user", "pass") + .build(); +``` + +### mTLS with custom CA + +```java +HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8443) + .proxyCacert(caCertInputStream) + .mtls(clientCertInputStream, clientKeyInputStream) + .build(); +``` + +### Custom credentials provider + +```java +// Bearer token +HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080) + .credentialsProvider(() -> fetchBearerToken()) + .build(); + +// Basic credentials +HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080) + .credentialsProvider(new BasicCredentialsProvider() { + public String getUsername() { return "user"; } + public String getPassword() { return "pass"; } + }) + .build(); +``` + +## Custom proxy authenticator + +Implement `SplitAuthenticator` to handle proxy challenge/response flows: + +```java +SplitAuthenticator authenticator = new SplitAuthenticator() { + @Override + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { + request.setHeader("Proxy-Authorization", "Bearer " + getToken()); + return request; + } +}; +``` + +The `AuthenticatedRequest` gives access to existing headers and the request URL, so the authenticator can make decisions based on context. + +## Certificate pinning + +```java +CertificatePinningConfiguration pinConfig = CertificatePinningConfiguration.builder() + // Pin by hash (sha256 or sha1) + .addPin("sdk.split.io", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + // Pin from a certificate file (derives hashes automatically) + .addPin("*.split.io", certInputStream) + // Optional: get notified on pin failures + .failureListener((host, certificateChain) -> { + Log.w("Split", "Pin failed for " + host + + ", chain size: " + certificateChain.size()); + }) + .build(); +``` + +Wildcard hosts are supported: `*.example.com` matches one subdomain, `**.example.com` matches any depth. + +## Development SSL overrides + +For test environments with self-signed certificates: + +```java +DevelopmentSslConfig devSsl = new DevelopmentSslConfig(trustManager, hostnameVerifier); + +// Or, if you already have an SSLSocketFactory: +DevelopmentSslConfig devSsl = new DevelopmentSslConfig(sslSocketFactory, trustManager, hostnameVerifier); +``` diff --git a/http-domain/build.gradle b/http-api/build.gradle similarity index 89% rename from http-domain/build.gradle rename to http-api/build.gradle index b15da12d4..7e915b5f3 100644 --- a/http-domain/build.gradle +++ b/http-api/build.gradle @@ -5,7 +5,7 @@ plugins { apply from: "$rootDir/gradle/common-android-library.gradle" android { - namespace 'io.split.android.client.network.domain' + namespace 'io.split.android.client.network.api' compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/http-domain/consumer-rules.pro b/http-api/consumer-rules.pro similarity index 100% rename from http-domain/consumer-rules.pro rename to http-api/consumer-rules.pro diff --git a/http-domain/proguard-rules.pro b/http-api/proguard-rules.pro similarity index 100% rename from http-domain/proguard-rules.pro rename to http-api/proguard-rules.pro diff --git a/http-domain/src/main/AndroidManifest.xml b/http-api/src/main/AndroidManifest.xml similarity index 100% rename from http-domain/src/main/AndroidManifest.xml rename to http-api/src/main/AndroidManifest.xml diff --git a/http-domain/src/main/java/io/split/android/client/network/Algorithm.java b/http-api/src/main/java/io/split/android/client/network/Algorithm.java similarity index 84% rename from http-domain/src/main/java/io/split/android/client/network/Algorithm.java rename to http-api/src/main/java/io/split/android/client/network/Algorithm.java index bd3784efe..e0d669e05 100644 --- a/http-domain/src/main/java/io/split/android/client/network/Algorithm.java +++ b/http-api/src/main/java/io/split/android/client/network/Algorithm.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -public class Algorithm { +class Algorithm { public static final String SHA256 = "sha256"; public static final String SHA1 = "sha1"; diff --git a/http-domain/src/main/java/io/split/android/client/network/AuthenticatedRequest.java b/http-api/src/main/java/io/split/android/client/network/AuthenticatedRequest.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/AuthenticatedRequest.java rename to http-api/src/main/java/io/split/android/client/network/AuthenticatedRequest.java diff --git a/http-domain/src/main/java/io/split/android/client/network/Authenticator.java b/http-api/src/main/java/io/split/android/client/network/Authenticator.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/Authenticator.java rename to http-api/src/main/java/io/split/android/client/network/Authenticator.java diff --git a/http-domain/src/main/java/io/split/android/client/network/Base64Decoder.java b/http-api/src/main/java/io/split/android/client/network/Base64Decoder.java similarity index 70% rename from http-domain/src/main/java/io/split/android/client/network/Base64Decoder.java rename to http-api/src/main/java/io/split/android/client/network/Base64Decoder.java index f31358046..387a900f0 100644 --- a/http-domain/src/main/java/io/split/android/client/network/Base64Decoder.java +++ b/http-api/src/main/java/io/split/android/client/network/Base64Decoder.java @@ -1,6 +1,6 @@ package io.split.android.client.network; -public interface Base64Decoder { +interface Base64Decoder { byte[] decode(String base64); } diff --git a/http-domain/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java b/http-api/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java rename to http-api/src/main/java/io/split/android/client/network/BasicCredentialsProvider.java diff --git a/http-domain/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java b/http-api/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java rename to http-api/src/main/java/io/split/android/client/network/BearerCredentialsProvider.java diff --git a/http-domain/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java b/http-api/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java similarity index 98% rename from http-domain/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java rename to http-api/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java index 45245ed0d..09504f9e2 100644 --- a/http-domain/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java +++ b/http-api/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java @@ -15,7 +15,7 @@ import io.split.android.client.utils.logger.Logger; -public class CertificateCheckerHelper { +class CertificateCheckerHelper { @Nullable public static Set getPinsForHost(String pattern, Map> configuredPins) { diff --git a/http-domain/src/main/java/io/split/android/client/network/CertificatePin.java b/http-api/src/main/java/io/split/android/client/network/CertificatePin.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/CertificatePin.java rename to http-api/src/main/java/io/split/android/client/network/CertificatePin.java diff --git a/http-domain/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java b/http-api/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java rename to http-api/src/main/java/io/split/android/client/network/CertificatePinningConfiguration.java diff --git a/http-domain/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java b/http-api/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java rename to http-api/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java diff --git a/http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java b/http-api/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java similarity index 90% rename from http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java rename to http-api/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java index 486eb5be2..b46f38309 100644 --- a/http-domain/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java +++ b/http-api/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java @@ -4,7 +4,7 @@ import io.split.android.client.utils.logger.Logger; -public class DefaultBase64Decoder implements Base64Decoder { +class DefaultBase64Decoder implements Base64Decoder { @Override public byte[] decode(String base64) { diff --git a/http-domain/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java b/http-api/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java rename to http-api/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java diff --git a/http-domain/src/main/java/io/split/android/client/network/HttpClientConfiguration.java b/http-api/src/main/java/io/split/android/client/network/HttpClientConfiguration.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/HttpClientConfiguration.java rename to http-api/src/main/java/io/split/android/client/network/HttpClientConfiguration.java diff --git a/http-domain/src/main/java/io/split/android/client/network/HttpProxy.java b/http-api/src/main/java/io/split/android/client/network/HttpProxy.java similarity index 93% rename from http-domain/src/main/java/io/split/android/client/network/HttpProxy.java rename to http-api/src/main/java/io/split/android/client/network/HttpProxy.java index a6dc011fa..969f69176 100644 --- a/http-domain/src/main/java/io/split/android/client/network/HttpProxy.java +++ b/http-api/src/main/java/io/split/android/client/network/HttpProxy.java @@ -29,7 +29,7 @@ private HttpProxy(Builder builder, boolean isLegacy) { mIsLegacy = isLegacy; } - public @Nullable String getHost() { + public @NonNull String getHost() { return mHost; } @@ -61,7 +61,7 @@ public int getPort() { return mCredentialsProvider; } - public static Builder newBuilder(@Nullable String host, int port) { + public static Builder newBuilder(@NonNull String host, int port) { return new Builder(host, port); } @@ -70,7 +70,7 @@ public boolean isLegacy() { } public static class Builder { - private final @Nullable String mHost; + private final @NonNull String mHost; private final int mPort; private @Nullable String mUsername; private @Nullable String mPassword; @@ -80,7 +80,7 @@ public static class Builder { @Nullable private ProxyCredentialsProvider mCredentialsProvider; - private Builder(@Nullable String host, int port) { + private Builder(@NonNull String host, int port) { mHost = host; mPort = port; } diff --git a/http-domain/src/main/java/io/split/android/client/network/PinEncoder.java b/http-api/src/main/java/io/split/android/client/network/PinEncoder.java similarity index 84% rename from http-domain/src/main/java/io/split/android/client/network/PinEncoder.java rename to http-api/src/main/java/io/split/android/client/network/PinEncoder.java index d34212ca8..3de8beecf 100644 --- a/http-domain/src/main/java/io/split/android/client/network/PinEncoder.java +++ b/http-api/src/main/java/io/split/android/client/network/PinEncoder.java @@ -2,7 +2,7 @@ import androidx.annotation.NonNull; -public interface PinEncoder { +interface PinEncoder { @NonNull byte[] encodeCertPin(String algorithm, byte[] encodedPublicKey); diff --git a/http-domain/src/main/java/io/split/android/client/network/PinEncoderImpl.java b/http-api/src/main/java/io/split/android/client/network/PinEncoderImpl.java similarity index 96% rename from http-domain/src/main/java/io/split/android/client/network/PinEncoderImpl.java rename to http-api/src/main/java/io/split/android/client/network/PinEncoderImpl.java index f1e010d51..7132b1828 100644 --- a/http-domain/src/main/java/io/split/android/client/network/PinEncoderImpl.java +++ b/http-api/src/main/java/io/split/android/client/network/PinEncoderImpl.java @@ -7,7 +7,7 @@ import io.split.android.client.utils.logger.Logger; -public class PinEncoderImpl implements PinEncoder { +class PinEncoderImpl implements PinEncoder { @Override @NonNull diff --git a/http-domain/src/main/java/io/split/android/client/network/ProxyConfiguration.java b/http-api/src/main/java/io/split/android/client/network/ProxyConfiguration.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/ProxyConfiguration.java rename to http-api/src/main/java/io/split/android/client/network/ProxyConfiguration.java diff --git a/http-domain/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java b/http-api/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java rename to http-api/src/main/java/io/split/android/client/network/ProxyCredentialsProvider.java diff --git a/http-domain/src/main/java/io/split/android/client/network/SplitAuthenticator.java b/http-api/src/main/java/io/split/android/client/network/SplitAuthenticator.java similarity index 100% rename from http-domain/src/main/java/io/split/android/client/network/SplitAuthenticator.java rename to http-api/src/main/java/io/split/android/client/network/SplitAuthenticator.java diff --git a/http-domain/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java b/http-api/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java similarity index 100% rename from http-domain/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java rename to http-api/src/test/java/io/split/android/client/network/CertificateCheckerHelperTest.java diff --git a/http-domain/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java b/http-api/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java similarity index 100% rename from http-domain/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java rename to http-api/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java diff --git a/http-domain/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java b/http-api/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java similarity index 100% rename from http-domain/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java rename to http-api/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java diff --git a/http-domain/src/test/java/io/split/android/client/network/PinEncoderImplTest.java b/http-api/src/test/java/io/split/android/client/network/PinEncoderImplTest.java similarity index 100% rename from http-domain/src/test/java/io/split/android/client/network/PinEncoderImplTest.java rename to http-api/src/test/java/io/split/android/client/network/PinEncoderImplTest.java diff --git a/http-domain/README.md b/http-domain/README.md deleted file mode 100644 index 362956fbb..000000000 --- a/http-domain/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# HTTP Domain module - -This module contains public HTTP configuration contracts exposed to consumers of the Split SDK. - -Includes certificate pinning configuration, proxy settings, SSL configuration, and authenticator interfaces. diff --git a/http/README.md b/http/README.md index 8ece8d648..12e59f39f 100644 --- a/http/README.md +++ b/http/README.md @@ -1,23 +1,89 @@ # HTTP module -Internal HTTP client for the Split SDK. Not exposed to SDK consumers. +HTTP client for the Split SDK. ## Building an `HttpClient` -Use `HttpClientImpl.Builder` to create an instance: +### Minimal ```java HttpClient client = new HttpClientImpl.Builder() .setConnectionTimeout(15_000) .setReadTimeout(15_000) - .setTlsUpdater(tlsUpdater) // optional – TlsUpdater SPI - .setProxy(httpProxy) // optional – proxy config from :http-domain - .setProxyAuthenticator(authenticator) // optional – SplitAuthenticator from :http-domain - .setCertificatePinningConfiguration(pinConfig) // optional – cert pins from :http-domain - .setDevelopmentSslConfig(devSslConfig) // optional – dev/test SSL overrides .build(); ``` +### With `HttpClientConfiguration` (preferred) + +Bundle all settings into a single config object from `:http-api`: + +```java +HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(15_000) + .readTimeout(15_000) + .proxy(proxy) // optional + .proxyAuthenticator(authenticator) // optional + .certificatePinningConfiguration(pinConfig) // optional + .developmentSslConfig(devSsl) // optional + .build(); + +HttpClient client = new HttpClientImpl.Builder() + .setConfiguration(config) + .setTlsUpdater(tlsUpdater) // optional – TlsUpdater + .build(); +``` + +Individual setter calls on the builder take precedence over the configuration object. + +### Proxy + +```java +// Basic auth proxy +HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080) + .basicAuth("user", "pass") + .build(); + +// mTLS proxy with custom CA +HttpProxy mtlsProxy = HttpProxy.newBuilder("proxy.example.com", 8443) + .proxyCacert(caCertInputStream) + .mtls(clientCertInputStream, clientKeyInputStream) + .build(); + +// With a credentials provider (e.g. bearer token) +HttpProxy bearerProxy = HttpProxy.newBuilder("proxy.example.com", 8080) + .credentialsProvider(new BearerCredentialsProvider(tokenSupplier)) + .build(); +``` + +### Certificate pinning + +```java +CertificatePinningConfiguration pinConfig = CertificatePinningConfiguration.builder() + .addPin("sdk.split.io", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + .addPin("*.split.io", certInputStream) // derive pins from a certificate file + .failureListener(failedHost -> { + Log.w("Split", "Certificate pinning failed for " + failedHost); + }) + .build(); +``` + +### Development SSL overrides + +For test environments where the server uses a self-signed certificate: + +```java +DevelopmentSslConfig devSsl = new DevelopmentSslConfig(trustManager, hostnameVerifier); +``` + +### TLS on older devices + +Implement the `TlsUpdater` SPI and pass it to the builder. +The client calls `couldBeOld()` to decide whether to force TLS 1.2 via `Tls12OnlySocketFactory`. + +```java +TlsUpdater tlsUpdater = new LegacyTlsUpdaterAdapter(context); // provided by :main +``` + ## Making requests ```java @@ -25,6 +91,10 @@ HttpClient client = new HttpClientImpl.Builder() HttpRequest req = client.request(uri, HttpMethod.GET); HttpResponse resp = req.execute(); +// POST with body +HttpRequest post = client.request(uri, HttpMethod.POST, jsonBody); +HttpResponse resp = post.execute(); + // POST with body and extra headers HttpRequest post = client.request(uri, HttpMethod.POST, jsonBody, extraHeaders); HttpResponse resp = post.execute(); @@ -42,17 +112,13 @@ client.addHeaders(commonHeaders); // Streaming-specific headers (only applied to streamRequest calls) client.setStreamingHeader("SplitSDKClientKey", clientKey); +client.addStreamingHeaders(streamingHeaders); ``` -## TLS on older devices - -Implement the `TlsUpdater` SPI and pass it to the builder. The client calls `couldBeOld()` to decide whether to force TLS 1.2 via `Tls12OnlySocketFactory`. - ## URI building ```java -URI uri = new URIBuilder("https://sdk.split.io/api") - .addPath("splitChanges") +URI uri = new URIBuilder(new URI("https://sdk.split.io/api"), "splitChanges") .addParameter("since", "-1") .build(); ``` diff --git a/http/build.gradle b/http/build.gradle index f613652b7..a7367b06e 100644 --- a/http/build.gradle +++ b/http/build.gradle @@ -16,7 +16,7 @@ android { dependencies { implementation libs.annotation implementation project(':logger') - api project(':http-domain') + api project(':http-api') testImplementation libs.junit4 testImplementation libs.mockitoCore diff --git a/http/src/main/java/io/split/android/client/network/HttpClientImpl.java b/http/src/main/java/io/split/android/client/network/HttpClientImpl.java index d846462b7..8fbff1270 100644 --- a/http/src/main/java/io/split/android/client/network/HttpClientImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpClientImpl.java @@ -161,6 +161,40 @@ SSLSocketFactory getSslSocketFactory() { return mSslSocketFactory; } + @VisibleForTesting + long getReadTimeout() { + return mReadTimeout; + } + + @VisibleForTesting + long getConnectionTimeout() { + return mConnectionTimeout; + } + + @VisibleForTesting + @Nullable + HttpProxy getHttpProxy() { + return mHttpProxy; + } + + @VisibleForTesting + @Nullable + SplitUrlConnectionAuthenticator getProxyAuthenticator() { + return mProxyAuthenticator; + } + + @VisibleForTesting + @Nullable + DevelopmentSslConfig getDevelopmentSslConfig() { + return mDevelopmentSslConfig; + } + + @VisibleForTesting + @Nullable + CertificateChecker getCertificateChecker() { + return mCertificateChecker; + } + private Proxy initializeProxy(HttpProxy proxy) { if (proxy != null) { return new Proxy( @@ -202,13 +236,15 @@ public static class Builder { private CertificatePinningConfiguration mCertificatePinningConfiguration; private CertificateChecker mCertificateChecker; private Base64Decoder mBase64Decoder = new DefaultBase64Decoder(); + @Nullable + private HttpClientConfiguration mConfiguration; public Builder setTlsUpdater(@Nullable TlsUpdater tlsUpdater) { mTlsUpdater = tlsUpdater; return this; } - public Builder setProxy(HttpProxy proxy) { + public Builder setProxy(@NonNull HttpProxy proxy) { mProxy = proxy; mProxyCredentialsProvider = proxy.getCredentialsProvider(); return this; @@ -263,7 +299,16 @@ Builder setBase64Decoder(Base64Decoder base64Decoder) { return this; } + public Builder setConfiguration(@NonNull HttpClientConfiguration configuration) { + mConfiguration = configuration; + return this; + } + public HttpClient build() { + if (mConfiguration != null) { + applyConfiguration(mConfiguration); + } + if (mDevelopmentSslConfig == null) { if (mTlsUpdater != null && mTlsUpdater.couldBeOld()) { mTlsUpdater.update(); @@ -310,6 +355,29 @@ public HttpClient build() { certificateChecker); } + // Configuration timeout values of 0 or less are intentionally ignored by + // setConnectionTimeout / setReadTimeout, leaving the platform default in place. + private void applyConfiguration(@NonNull HttpClientConfiguration configuration) { + if (mConnectionTimeout == -1) { + setConnectionTimeout(configuration.getConnectionTimeout()); + } + if (mReadTimeout == -1) { + setReadTimeout(configuration.getReadTimeout()); + } + if (mProxy == null && configuration.getProxy() != null) { + setProxy(configuration.getProxy()); + } + if (mCertificatePinningConfiguration == null && configuration.getCertificatePinningConfiguration() != null) { + setCertificatePinningConfiguration(configuration.getCertificatePinningConfiguration()); + } + if (mDevelopmentSslConfig == null) { + setDevelopmentSslConfig(configuration.getDevelopmentSslConfig()); + } + if (mProxyAuthenticator == null) { + setProxyAuthenticator(configuration.getProxyAuthenticator()); + } + } + private SSLSocketFactory createSslSocketFactoryFromProxy(HttpProxy proxyParams) { ProxySslSocketFactoryProviderImpl factoryProvider = new ProxySslSocketFactoryProviderImpl(mBase64Decoder); try { diff --git a/http/src/main/java/io/split/android/client/network/HttpRequestImpl.java b/http/src/main/java/io/split/android/client/network/HttpRequestImpl.java index 669dd8598..864a9836f 100644 --- a/http/src/main/java/io/split/android/client/network/HttpRequestImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpRequestImpl.java @@ -31,7 +31,7 @@ import io.split.android.client.utils.logger.Logger; -public class HttpRequestImpl implements HttpRequest { +class HttpRequestImpl implements HttpRequest { public static final String CONTENT_TYPE = "Content-Type"; public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json; charset=utf-8"; diff --git a/http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java b/http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java index c8573e235..08d4f5376 100644 --- a/http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java @@ -30,7 +30,7 @@ import io.split.android.client.utils.logger.Logger; -public class HttpStreamRequestImpl implements HttpStreamRequest { +class HttpStreamRequestImpl implements HttpStreamRequest { private static final int STREAMING_READ_TIMEOUT_IN_MILLISECONDS = 80000; diff --git a/http/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java b/http/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java index bf24d0e74..bae64f68d 100644 --- a/http/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java +++ b/http/src/main/java/io/split/android/client/network/HttpStreamResponseImpl.java @@ -8,7 +8,7 @@ import io.split.android.client.utils.logger.Logger; -public class HttpStreamResponseImpl extends BaseHttpResponseImpl implements HttpStreamResponse { +class HttpStreamResponseImpl extends BaseHttpResponseImpl implements HttpStreamResponse { private final BufferedReader mData; diff --git a/http/src/main/java/io/split/android/client/network/UrlEscapers.java b/http/src/main/java/io/split/android/client/network/UrlEscapers.java index d12a6f995..11098b8e0 100644 --- a/http/src/main/java/io/split/android/client/network/UrlEscapers.java +++ b/http/src/main/java/io/split/android/client/network/UrlEscapers.java @@ -3,7 +3,7 @@ /** * Based on Guava UrlEscapers */ -final class UrlEscapers { +public final class UrlEscapers { private UrlEscapers() {} private static final String URL_PATH_OTHER_SAFE_CHARS_LACKING_PLUS = diff --git a/http/src/test/java/io/split/android/client/network/HttpClientImplBuilderConfigurationTest.java b/http/src/test/java/io/split/android/client/network/HttpClientImplBuilderConfigurationTest.java new file mode 100644 index 000000000..d85907743 --- /dev/null +++ b/http/src/test/java/io/split/android/client/network/HttpClientImplBuilderConfigurationTest.java @@ -0,0 +1,160 @@ +package io.split.android.client.network; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.junit.Test; + +public class HttpClientImplBuilderConfigurationTest { + + @Test + public void configurationAppliesAllValuesWhenBuilderHasDefaults() { + HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080).build(); + SplitAuthenticator authenticator = new SplitAuthenticator() { + @Nullable + @Override + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { + return request; + } + }; + CertificatePinningConfiguration pinConfig = mock(CertificatePinningConfiguration.class); + DevelopmentSslConfig devSsl = mock(DevelopmentSslConfig.class); + + HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(5000) + .readTimeout(10000) + .proxy(proxy) + .proxyAuthenticator(authenticator) + .certificatePinningConfiguration(pinConfig) + .developmentSslConfig(devSsl) + .build(); + + HttpClientImpl client = (HttpClientImpl) new HttpClientImpl.Builder() + .setConfiguration(config) + .build(); + + assertEquals(5000, client.getConnectionTimeout()); + assertEquals(10000, client.getReadTimeout()); + assertNotNull(client.getHttpProxy()); + assertEquals("proxy.example.com", client.getHttpProxy().getHost()); + assertEquals(8080, client.getHttpProxy().getPort()); + assertNotNull(client.getProxyAuthenticator()); + assertNotNull(client.getCertificateChecker()); + assertNotNull(client.getDevelopmentSslConfig()); + } + + @Test + public void builderValuesTakePrecedenceOverConfiguration() { + HttpProxy configProxy = HttpProxy.newBuilder("config.proxy.com", 9090).build(); + HttpProxy builderProxy = HttpProxy.newBuilder("builder.proxy.com", 7070).build(); + + HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(5000) + .readTimeout(10000) + .proxy(configProxy) + .build(); + + HttpClientImpl client = (HttpClientImpl) new HttpClientImpl.Builder() + .setConnectionTimeout(1000) + .setReadTimeout(2000) + .setProxy(builderProxy) + .setConfiguration(config) + .build(); + + // Builder values should win + assertEquals(1000, client.getConnectionTimeout()); + assertEquals(2000, client.getReadTimeout()); + assertEquals("builder.proxy.com", client.getHttpProxy().getHost()); + assertEquals(7070, client.getHttpProxy().getPort()); + } + + @Test + public void configurationWithNullOptionalFieldsDoesNotOverrideBuilderDefaults() { + HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(3000) + .readTimeout(6000) + .build(); + + HttpClientImpl client = (HttpClientImpl) new HttpClientImpl.Builder() + .setConfiguration(config) + .build(); + + assertEquals(3000, client.getConnectionTimeout()); + assertEquals(6000, client.getReadTimeout()); + assertNull(client.getHttpProxy()); + assertNull(client.getProxyAuthenticator()); + assertNull(client.getCertificateChecker()); + assertNull(client.getDevelopmentSslConfig()); + } + + @Test + public void buildWithoutConfigurationUsesBuilderDefaults() { + HttpClientImpl client = (HttpClientImpl) new HttpClientImpl.Builder() + .setConnectionTimeout(4000) + .setReadTimeout(8000) + .build(); + + assertEquals(4000, client.getConnectionTimeout()); + assertEquals(8000, client.getReadTimeout()); + assertNull(client.getHttpProxy()); + assertNull(client.getProxyAuthenticator()); + assertNull(client.getCertificateChecker()); + assertNull(client.getDevelopmentSslConfig()); + } + + @Test + public void builderAuthenticatorTakesPrecedenceOverConfiguration() { + SplitAuthenticator configAuth = new SplitAuthenticator() { + @Nullable + @Override + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { + request.setHeader("Source", "config"); + return request; + } + }; + SplitAuthenticator builderAuth = new SplitAuthenticator() { + @Nullable + @Override + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { + request.setHeader("Source", "builder"); + return request; + } + }; + + HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080).build(); + + HttpClientConfiguration config = HttpClientConfiguration.builder() + .proxy(proxy) + .proxyAuthenticator(configAuth) + .build(); + + HttpClientImpl client = (HttpClientImpl) new HttpClientImpl.Builder() + .setProxy(proxy) + .setProxyAuthenticator(builderAuth) + .setConfiguration(config) + .build(); + + // Builder authenticator should win — proxy authenticator should not be null + assertNotNull(client.getProxyAuthenticator()); + } + + @Test + public void configurationWithNullProxyDoesNotSetProxy() { + HttpClientConfiguration config = HttpClientConfiguration.builder() + .connectionTimeout(1000) + .readTimeout(2000) + .proxy(null) + .build(); + + HttpClientImpl client = (HttpClientImpl) new HttpClientImpl.Builder() + .setConfiguration(config) + .build(); + + assertNull(client.getHttpProxy()); + } +} diff --git a/main/build.gradle b/main/build.gradle index 70d932be8..63f91db29 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -52,7 +52,7 @@ dependencies { // Public api modules api project(':logger') api project(':api') - api project(':http-domain') + api project(':http-api') // Internal module dependencies implementation project(':http') implementation project(':events-domain') diff --git a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java index 0016b6a76..3cd4f501b 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -20,6 +20,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; +import androidx.annotation.VisibleForTesting; + import io.split.android.client.main.BuildConfig; import io.split.android.client.api.Key; import io.split.android.client.common.CompressionUtilProvider; @@ -32,6 +34,7 @@ import io.split.android.client.lifecycle.SplitLifecycleManager; import io.split.android.client.lifecycle.SplitLifecycleManagerImpl; import io.split.android.client.network.HttpClient; +import io.split.android.client.network.HttpClientConfiguration; import io.split.android.client.network.HttpClientImpl; import io.split.android.client.network.LegacyTlsUpdaterAdapter; import io.split.android.client.service.CleanUpDatabaseTask; @@ -382,20 +385,12 @@ private static HttpClient getHttpClient(@NonNull String apiToken, @Nullable GeneralInfoStorage generalInfoStorage) { HttpClient defaultHttpClient; if (httpClient == null) { - HttpClientImpl.Builder builder = new HttpClientImpl.Builder() - .setConnectionTimeout(config.connectionTimeout()) - .setReadTimeout(config.readTimeout()) - .setDevelopmentSslConfig(config.developmentSslConfig()) - .setTlsUpdater(new LegacyTlsUpdaterAdapter(context)) - .setProxyAuthenticator(config.authenticator()); - if (config.proxy() != null) { - builder.setProxy(config.proxy()); - } - if (config.certificatePinningConfiguration() != null) { - builder.setCertificatePinningConfiguration(config.certificatePinningConfiguration()); - } + HttpClientConfiguration httpConfig = buildHttpClientConfiguration(config); - defaultHttpClient = builder.build(); + defaultHttpClient = new HttpClientImpl.Builder() + .setConfiguration(httpConfig) + .setTlsUpdater(new LegacyTlsUpdaterAdapter(context)) + .build(); // This should be extracted; has nothing to do with the method. if (config.proxy() != null && generalInfoStorage != null) { @@ -412,6 +407,19 @@ private static HttpClient getHttpClient(@NonNull String apiToken, return defaultHttpClient; } + @VisibleForTesting + @NonNull + static HttpClientConfiguration buildHttpClientConfiguration(@NonNull SplitClientConfig config) { + return HttpClientConfiguration.builder() + .connectionTimeout(config.connectionTimeout()) + .readTimeout(config.readTimeout()) + .developmentSslConfig(config.developmentSslConfig()) + .proxy(config.proxy()) + .certificatePinningConfiguration(config.certificatePinningConfiguration()) + .proxyAuthenticator(config.authenticator()) + .build(); + } + private static String getFlagsSpec(@Nullable TestingConfig testingConfig) { if (testingConfig == null) { return BuildConfig.FLAGS_SPEC; diff --git a/main/src/main/java/io/split/android/client/network/CertificatePinSerializer.java b/main/src/main/java/io/split/android/client/network/CertificatePinSerializer.java new file mode 100644 index 000000000..494ed6177 --- /dev/null +++ b/main/src/main/java/io/split/android/client/network/CertificatePinSerializer.java @@ -0,0 +1,67 @@ +package io.split.android.client.network; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +/** + * Custom Gson {@link TypeAdapter} for {@link CertificatePin} that uses + * {@code "algo"} and {@code "pin"} as JSON keys instead of the raw field names. + */ +public class CertificatePinSerializer extends TypeAdapter { + + @Override + public void write(JsonWriter out, CertificatePin src) throws IOException { + out.beginObject(); + out.name("algo").value(src.getAlgorithm()); + out.name("pin"); + out.beginArray(); + for (byte b : src.getPin()) { + out.value(b); + } + out.endArray(); + out.endObject(); + } + + @Override + public CertificatePin read(JsonReader in) throws IOException { + String algorithm = null; + byte[] pin = null; + + in.beginObject(); + while (in.hasNext()) { + String name = in.nextName(); + switch (name) { + case "algo": + algorithm = in.nextString(); + break; + case "pin": + pin = readByteArray(in); + break; + default: + in.skipValue(); + break; + } + } + in.endObject(); + + return new CertificatePin(pin, algorithm); + } + + private static byte[] readByteArray(JsonReader in) throws IOException { + java.util.List bytes = new java.util.ArrayList<>(); + in.beginArray(); + while (in.hasNext()) { + bytes.add((byte) in.nextInt()); + } + in.endArray(); + + byte[] result = new byte[bytes.size()]; + for (int i = 0; i < bytes.size(); i++) { + result[i] = bytes.get(i); + } + return result; + } +} diff --git a/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java b/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java index 801baa909..aa640fbc5 100644 --- a/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java +++ b/main/src/main/java/io/split/android/client/network/CertificatePinningConfigurationProvider.java @@ -1,10 +1,8 @@ package io.split.android.client.network; -import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; -import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -15,18 +13,14 @@ public class CertificatePinningConfigurationProvider { public static CertificatePinningConfiguration getCertificatePinningConfiguration(String pinsJson) { try { - Type type = new TypeToken>>() { + Type type = new TypeToken>>() { }.getType(); - Map> certificatePins = Json.fromJson(pinsJson, type); + Map> certificatePins = Json.fromJson(pinsJson, type); if (certificatePins != null && !certificatePins.isEmpty()) { CertificatePinningConfiguration.Builder builder = CertificatePinningConfiguration.builder(); - for (Map.Entry> entry : certificatePins.entrySet()) { - Set pins = new HashSet<>(); - for (CertificatePinDto dto : entry.getValue()) { - pins.add(new CertificatePin(dto.pin, dto.algorithm)); - } - builder.addPins(entry.getKey(), pins); + for (Map.Entry> entry : certificatePins.entrySet()) { + builder.addPins(entry.getKey(), entry.getValue()); } return builder @@ -38,11 +32,4 @@ public static CertificatePinningConfiguration getCertificatePinningConfiguration return null; } - - private static class CertificatePinDto { - @SerializedName("pin") - byte[] pin; - @SerializedName("algo") - String algorithm; - } } diff --git a/main/src/main/java/io/split/android/client/utils/Json.java b/main/src/main/java/io/split/android/client/utils/Json.java index a4c4e2e9c..bb97eea95 100644 --- a/main/src/main/java/io/split/android/client/utils/Json.java +++ b/main/src/main/java/io/split/android/client/utils/Json.java @@ -15,6 +15,8 @@ import java.util.Set; import io.split.android.client.dtos.KeyImpression; +import io.split.android.client.network.CertificatePin; +import io.split.android.client.network.CertificatePinSerializer; import io.split.android.client.service.impressions.KeyImpressionSerializer; import io.split.android.client.utils.serializer.DoubleSerializer; @@ -24,6 +26,7 @@ public class Json { .serializeNulls() .registerTypeAdapter(Double.class, new DoubleSerializer()) .registerTypeAdapter(KeyImpression.class, new KeyImpressionSerializer()) + .registerTypeAdapter(CertificatePin.class, new CertificatePinSerializer()) .create(); private static volatile Gson mNonNullJson; diff --git a/main/src/test/java/io/split/android/client/SplitFactoryImplConfigMappingTest.java b/main/src/test/java/io/split/android/client/SplitFactoryImplConfigMappingTest.java new file mode 100644 index 000000000..900f67f10 --- /dev/null +++ b/main/src/test/java/io/split/android/client/SplitFactoryImplConfigMappingTest.java @@ -0,0 +1,118 @@ +package io.split.android.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.junit.Test; + +import io.split.android.client.network.AuthenticatedRequest; +import io.split.android.client.network.CertificatePinningConfiguration; +import io.split.android.client.network.DevelopmentSslConfig; +import io.split.android.client.network.HttpClientConfiguration; +import io.split.android.client.network.HttpProxy; +import io.split.android.client.network.SplitAuthenticator; + +public class SplitFactoryImplConfigMappingTest { + + @Test + public void buildHttpClientConfigurationMapsAllFields() { + SplitClientConfig splitConfig = mock(SplitClientConfig.class); + HttpProxy proxy = HttpProxy.newBuilder("proxy.example.com", 8080).build(); + CertificatePinningConfiguration pinConfig = mock(CertificatePinningConfiguration.class); + DevelopmentSslConfig devSsl = mock(DevelopmentSslConfig.class); + SplitAuthenticator authenticator = new SplitAuthenticator() { + @Nullable + @Override + public AuthenticatedRequest authenticate(@NonNull AuthenticatedRequest request) { + return request; + } + }; + + when(splitConfig.connectionTimeout()).thenReturn(5000); + when(splitConfig.readTimeout()).thenReturn(10000); + when(splitConfig.proxy()).thenReturn(proxy); + when(splitConfig.certificatePinningConfiguration()).thenReturn(pinConfig); + when(splitConfig.developmentSslConfig()).thenReturn(devSsl); + when(splitConfig.authenticator()).thenReturn(authenticator); + + HttpClientConfiguration result = SplitFactoryImpl.buildHttpClientConfiguration(splitConfig); + + assertEquals(5000, result.getConnectionTimeout()); + assertEquals(10000, result.getReadTimeout()); + assertNotNull(result.getProxy()); + assertEquals("proxy.example.com", result.getProxy().getHost()); + assertEquals(8080, result.getProxy().getPort()); + assertSame(pinConfig, result.getCertificatePinningConfiguration()); + assertSame(devSsl, result.getDevelopmentSslConfig()); + assertSame(authenticator, result.getProxyAuthenticator()); + } + + @Test + public void buildHttpClientConfigurationWithNullOptionals() { + SplitClientConfig splitConfig = mock(SplitClientConfig.class); + + when(splitConfig.connectionTimeout()).thenReturn(3000); + when(splitConfig.readTimeout()).thenReturn(6000); + when(splitConfig.proxy()).thenReturn(null); + when(splitConfig.certificatePinningConfiguration()).thenReturn(null); + when(splitConfig.developmentSslConfig()).thenReturn(null); + when(splitConfig.authenticator()).thenReturn(null); + + HttpClientConfiguration result = SplitFactoryImpl.buildHttpClientConfiguration(splitConfig); + + assertEquals(3000, result.getConnectionTimeout()); + assertEquals(6000, result.getReadTimeout()); + assertNull(result.getProxy()); + assertNull(result.getCertificatePinningConfiguration()); + assertNull(result.getDevelopmentSslConfig()); + assertNull(result.getProxyAuthenticator()); + } + + @Test + public void buildHttpClientConfigurationWithZeroTimeouts() { + SplitClientConfig splitConfig = mock(SplitClientConfig.class); + + when(splitConfig.connectionTimeout()).thenReturn(0); + when(splitConfig.readTimeout()).thenReturn(0); + when(splitConfig.proxy()).thenReturn(null); + when(splitConfig.certificatePinningConfiguration()).thenReturn(null); + when(splitConfig.developmentSslConfig()).thenReturn(null); + when(splitConfig.authenticator()).thenReturn(null); + + HttpClientConfiguration result = SplitFactoryImpl.buildHttpClientConfiguration(splitConfig); + + assertEquals(0, result.getConnectionTimeout()); + assertEquals(0, result.getReadTimeout()); + } + + @Test + public void buildHttpClientConfigurationWithOnlyProxy() { + SplitClientConfig splitConfig = mock(SplitClientConfig.class); + HttpProxy proxy = HttpProxy.newBuilder("myproxy.local", 3128).build(); + + when(splitConfig.connectionTimeout()).thenReturn(15000); + when(splitConfig.readTimeout()).thenReturn(15000); + when(splitConfig.proxy()).thenReturn(proxy); + when(splitConfig.certificatePinningConfiguration()).thenReturn(null); + when(splitConfig.developmentSslConfig()).thenReturn(null); + when(splitConfig.authenticator()).thenReturn(null); + + HttpClientConfiguration result = SplitFactoryImpl.buildHttpClientConfiguration(splitConfig); + + assertEquals(15000, result.getConnectionTimeout()); + assertEquals(15000, result.getReadTimeout()); + assertNotNull(result.getProxy()); + assertEquals("myproxy.local", result.getProxy().getHost()); + assertEquals(3128, result.getProxy().getPort()); + assertNull(result.getCertificatePinningConfiguration()); + assertNull(result.getDevelopmentSslConfig()); + assertNull(result.getProxyAuthenticator()); + } +} diff --git a/main/src/test/java/io/split/android/client/network/CertificatePinSerializerTest.java b/main/src/test/java/io/split/android/client/network/CertificatePinSerializerTest.java new file mode 100644 index 000000000..0dfc1aad5 --- /dev/null +++ b/main/src/test/java/io/split/android/client/network/CertificatePinSerializerTest.java @@ -0,0 +1,129 @@ +package io.split.android.client.network; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Type; +import java.util.Map; +import java.util.Set; + +public class CertificatePinSerializerTest { + + private Gson mGson; + + @Before + public void setUp() { + mGson = new GsonBuilder() + .registerTypeAdapter(CertificatePin.class, new CertificatePinSerializer()) + .create(); + } + + @Test + public void serializeSinglePin() { + CertificatePin pin = new CertificatePin(new byte[]{1, 2, 3}, "sha256"); + + String json = mGson.toJson(pin); + + assertEquals("{\"algo\":\"sha256\",\"pin\":[1,2,3]}", json); + } + + @Test + public void serializeNegativeByteValues() { + CertificatePin pin = new CertificatePin(new byte[]{-80, 50, -99, -126, 11}, "sha256"); + + String json = mGson.toJson(pin); + + assertEquals("{\"algo\":\"sha256\",\"pin\":[-80,50,-99,-126,11]}", json); + } + + @Test + public void deserializeSinglePin() { + String json = "{\"algo\":\"sha1\",\"pin\":[-116,-73,-94,-80,55]}"; + + CertificatePin pin = mGson.fromJson(json, CertificatePin.class); + + assertNotNull(pin); + assertEquals("sha1", pin.getAlgorithm()); + assertArrayEquals(new byte[]{-116, -73, -94, -80, 55}, pin.getPin()); + } + + @Test + public void roundTripPreservesData() { + CertificatePin original = new CertificatePin(new byte[]{-116, -123, 30, -25}, "sha256"); + + String json = mGson.toJson(original); + CertificatePin deserialized = mGson.fromJson(json, CertificatePin.class); + + assertNotNull(deserialized); + assertEquals(original.getAlgorithm(), deserialized.getAlgorithm()); + assertArrayEquals(original.getPin(), deserialized.getPin()); + } + + @Test + public void roundTripMapOfSets() { + String expectedJson = "{\"events.split.io\":[{\"algo\":\"sha256\",\"pin\":[-80,50,-99,-126,11]},{\"algo\":\"sha1\",\"pin\":[-116,-73,-94,-80,55]}],\"sdk.split.io\":[{\"algo\":\"sha256\",\"pin\":[-116,-123,30,-25]}]}"; + + Type type = new TypeToken>>() { + }.getType(); + Map> deserialized = mGson.fromJson(expectedJson, type); + + assertNotNull(deserialized); + assertEquals(2, deserialized.size()); + assertEquals(2, deserialized.get("events.split.io").size()); + assertEquals(1, deserialized.get("sdk.split.io").size()); + + // Re-serialize and deserialize + String reserialized = mGson.toJson(deserialized, type); + Map> roundTripped = mGson.fromJson(reserialized, type); + + assertNotNull(roundTripped); + assertEquals(deserialized.size(), roundTripped.size()); + for (Map.Entry> entry : deserialized.entrySet()) { + Set originalPins = entry.getValue(); + Set roundTrippedPins = roundTripped.get(entry.getKey()); + assertNotNull(roundTrippedPins); + assertEquals(originalPins.size(), roundTrippedPins.size()); + assertEquals(originalPins, roundTrippedPins); + } + } + + @Test + public void deserializeWithUnknownFieldsIsIgnored() { + String json = "{\"algo\":\"sha256\",\"pin\":[1,2],\"extra\":\"ignored\"}"; + + CertificatePin pin = mGson.fromJson(json, CertificatePin.class); + + assertNotNull(pin); + assertEquals("sha256", pin.getAlgorithm()); + assertArrayEquals(new byte[]{1, 2}, pin.getPin()); + } + + @Test + public void deserializeMissingFieldsResultsInNulls() { + String json = "{}"; + + CertificatePin pin = mGson.fromJson(json, CertificatePin.class); + + assertNotNull(pin); + assertNull(pin.getAlgorithm()); + assertNull(pin.getPin()); + } + + @Test + public void serializeEmptyPinArray() { + CertificatePin pin = new CertificatePin(new byte[]{}, "sha256"); + + String json = mGson.toJson(pin); + + assertEquals("{\"algo\":\"sha256\",\"pin\":[]}", json); + } +} diff --git a/settings.gradle b/settings.gradle index 7eaafe2d3..9ddd50403 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,7 +2,7 @@ rootProject.name = 'android-client' include ':api' include ':logger' -include ':http-domain' +include ':http-api' include ':http' include ':main' include ':events' diff --git a/sonar-project.properties b/sonar-project.properties index a3cc5e80d..85a95779d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,16 +3,18 @@ sonar.projectKey=splitio_android-client sonar.projectName=android-client # Path to source directories (multi-module) -# Root project contains modules: main, events, logger, http-domain, http -sonar.sources=main/src/main/java,events/src/main/java,logger/src/main/java,http-domain/src/main/java,http/src/main/java +# Root project contains modules: api, events-domain, main, events, logger, http-api, http +sonar.sources=api/src/main/java,events-domain/src/main/java,main/src/main/java,events/src/main/java,logger/src/main/java,http-api/src/main/java,http/src/main/java # Path to compiled classes (multi-module) -# Include binary paths for all modules: main, events, logger, http-domain, http +# Include binary paths for all modules: api, events-domain, main, events, logger, http-api, http sonar.java.binaries=\ + api/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + events-domain/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ main/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ events/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ logger/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ - http-domain/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + http-api/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ http/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes # Path to dependency/libraries jars (multi-module) @@ -29,10 +31,18 @@ sonar.java.libraries=\ logger/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ logger/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ logger/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ - http-domain/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ - http-domain/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ - http-domain/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ - http-domain/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + api/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + api/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + api/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + api/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + events-domain/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + events-domain/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + events-domain/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + events-domain/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + http-api/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + http-api/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + http-api/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + http-api/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ http/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ http/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ http/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ @@ -40,7 +50,7 @@ sonar.java.libraries=\ # Path to test directories (multi-module) # Only include test source folders that are guaranteed to exist in all environments -sonar.tests=main/src/test/java,main/src/androidTest/java,main/src/sharedTest/java,events/src/test/java,logger/src/test/java,http-domain/src/test/java,http/src/test/java +sonar.tests=api/src/test/java,events-domain/src/test/java,main/src/test/java,main/src/androidTest/java,main/src/sharedTest/java,events/src/test/java,logger/src/test/java,http-api/src/test/java,http/src/test/java # Encoding of the source code sonar.sourceEncoding=UTF-8 From 1e604f797f9fa073d862991a56e12d1ee4598383 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Wed, 28 Jan 2026 11:48:31 -0300 Subject: [PATCH 10/23] WIP --- main/build.gradle | 1 + .../android/client/SplitFactoryHelper.java | 40 ++- .../HttpClientStreamingTransport.java | 92 ++++++ .../HttpFetcherStreamingAuthFetcher.java | 35 ++ .../NotificationProcessorUpdateListener.java | 24 ++ .../SplitTaskExecutorStreamingScheduler.java | 55 +++ .../sseclient/SseDisconnectionTimer.java | 40 --- ...etryRuntimeProducerStreamingTelemetry.java | 105 ++++++ settings.gradle | 1 + streaming/build.gradle | 26 ++ streaming/src/main/AndroidManifest.xml | 3 + .../client/common/CompressionType.java | 9 + .../common/CompressionUtilProvider.java | 43 +++ .../android/client/network/URIBuilder.java | 85 +++++ .../service/sseclient/BackoffCounter.java | 0 .../service/sseclient/EventStreamParser.java | 0 .../FixedIntervalBackoffCounter.java | 0 .../sseclient/InvalidJwtTokenException.java | 0 .../sseclient/ReconnectBackoffCounter.java | 0 .../service/sseclient/SseAuthToken.java | 0 .../sseclient/SseAuthenticationResponse.java | 0 .../service/sseclient/SseJwtParser.java | 0 .../client/service/sseclient/SseJwtToken.java | 0 .../service/sseclient/StreamingConstants.java | 21 ++ .../BroadcastedEventListener.java | 0 .../feedbackchannel/DelayStatusEvent.java | 0 .../PushManagerEventBroadcaster.java | 0 .../feedbackchannel/PushStatusEvent.java | 0 .../notifications/ControlNotification.java | 0 .../notifications/HashingAlgorithm.java | 0 .../notifications/IncomingNotification.java | 0 .../IncomingNotificationType.java | 0 .../InstantUpdateChangeNotification.java | 0 .../sseclient/notifications/KeyList.java | 0 .../notifications/MembershipNotification.java | 0 .../MySegmentUpdateStrategy.java | 0 .../notifications/NotificationParser.java | 0 .../notifications/NotificationType.java | 0 .../notifications/OccupancyNotification.java | 0 .../notifications/RawNotification.java | 0 .../RuleBasedSegmentChangeNotification.java | 0 .../notifications/SplitKillNotification.java | 0 .../SplitsChangeNotification.java | 0 .../notifications/StreamingError.java | 0 .../sseclient/spi/StreamingAuthException.java | 32 ++ .../sseclient/spi/StreamingAuthFetcher.java | 23 ++ .../sseclient/spi/StreamingScheduler.java | 40 +++ .../sseclient/spi/StreamingTelemetry.java | 104 ++++++ .../sseclient/spi/StreamingTransport.java | 110 ++++++ .../spi/UpdateNotificationListener.java | 25 ++ .../sseclient/NotificationManagerKeeper.java | 21 +- .../sseclient/PushNotificationManager.java | 62 ++-- .../sseclient/sseclient/SseAuthenticator.java | 32 +- .../sseclient/sseclient/SseClient.java | 0 .../sseclient/sseclient/SseClientImpl.java | 63 ++-- .../sseclient/SseDisconnectionTimer.java | 35 ++ .../sseclient/sseclient/SseHandler.java | 36 +- .../sseclient/SseRefreshTokenTimer.java | 37 +-- .../android/client/utils/Base64Util.java | 47 +++ .../android/client/utils/CompressionUtil.java | 5 + .../io/split/android/client/utils/Gzip.java | 49 +++ .../io/split/android/client/utils/Json.java | 29 ++ .../android/client/utils/MurmurHash3.java | 312 ++++++++++++++++++ .../android/client/utils/StringHelper.java | 54 +++ .../io/split/android/client/utils/Utils.java | 36 ++ .../io/split/android/client/utils/Zlib.java | 30 ++ 66 files changed, 1575 insertions(+), 187 deletions(-) create mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpClientStreamingTransport.java create mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpFetcherStreamingAuthFetcher.java create mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationProcessorUpdateListener.java create mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java delete mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java create mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetry.java create mode 100644 streaming/build.gradle create mode 100644 streaming/src/main/AndroidManifest.xml create mode 100644 streaming/src/main/java/io/split/android/client/common/CompressionType.java create mode 100644 streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java create mode 100644 streaming/src/main/java/io/split/android/client/network/URIBuilder.java rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/EventStreamParser.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java (100%) create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java (100%) create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTransport.java create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java (80%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java (78%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java (77%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java (100%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java (74%) create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java (80%) rename {main => streaming}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java (53%) create mode 100644 streaming/src/main/java/io/split/android/client/utils/Base64Util.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/Gzip.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/Json.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/StringHelper.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/Utils.java create mode 100644 streaming/src/main/java/io/split/android/client/utils/Zlib.java diff --git a/main/build.gradle b/main/build.gradle index 63f91db29..2344a00c4 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -56,6 +56,7 @@ dependencies { // Internal module dependencies implementation project(':http') implementation project(':events-domain') + api project(':streaming') // External dependencies implementation libs.roomRuntime diff --git a/main/src/main/java/io/split/android/client/SplitFactoryHelper.java b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java index 4360c873c..b8af0fe32 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryHelper.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java @@ -54,13 +54,21 @@ import io.split.android.client.service.sseclient.reactor.MySegmentsUpdateWorkerRegistry; import io.split.android.client.service.sseclient.reactor.SplitUpdatesWorker; import io.split.android.client.service.sseclient.sseclient.BackoffCounterTimer; +import io.split.android.client.service.sseclient.sseclient.HttpFetcherStreamingAuthFetcher; +import io.split.android.client.service.sseclient.sseclient.NotificationProcessorUpdateListener; import io.split.android.client.service.sseclient.sseclient.PushNotificationManager; import io.split.android.client.service.sseclient.sseclient.SseAuthenticator; import io.split.android.client.service.sseclient.sseclient.SseClient; +import io.split.android.client.service.sseclient.sseclient.HttpClientStreamingTransport; import io.split.android.client.service.sseclient.sseclient.SseClientImpl; import io.split.android.client.service.sseclient.sseclient.SseHandler; import io.split.android.client.service.sseclient.sseclient.SseRefreshTokenTimer; +import io.split.android.client.service.sseclient.sseclient.SplitTaskExecutorStreamingScheduler; import io.split.android.client.service.sseclient.sseclient.StreamingComponents; +import io.split.android.client.service.sseclient.sseclient.TelemetryRuntimeProducerStreamingTelemetry; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; +import io.split.android.client.service.sseclient.spi.UpdateNotificationListener; import io.split.android.client.service.synchronizer.RolloutCacheManager; import io.split.android.client.service.synchronizer.RolloutCacheManagerImpl; import io.split.android.client.service.synchronizer.SyncGuardian; @@ -288,18 +296,19 @@ SyncManager buildSyncManager(SplitClientConfig config, } @NonNull - PushNotificationManager getPushNotificationManager(SplitTaskExecutor splitTaskExecutor, + PushNotificationManager getPushNotificationManager(StreamingScheduler scheduler, SseAuthenticator sseAuthenticator, PushManagerEventBroadcaster pushManagerEventBroadcaster, SseClient sseClient, - TelemetryRuntimeProducer telemetryRuntimeProducer, + StreamingTelemetry telemetry, long defaultSseConnectionDelayInSecs, int sseDisconnectionDelayInSecs) { return new PushNotificationManager(pushManagerEventBroadcaster, sseAuthenticator, sseClient, - new SseRefreshTokenTimer(splitTaskExecutor, pushManagerEventBroadcaster), - telemetryRuntimeProducer, + new SseRefreshTokenTimer(scheduler, pushManagerEventBroadcaster), + scheduler, + telemetry, defaultSseConnectionDelayInSecs, sseDisconnectionDelayInSecs, null); @@ -307,17 +316,17 @@ PushNotificationManager getPushNotificationManager(SplitTaskExecutor splitTaskEx public SseClient getSseClient(String streamingServiceUrlString, NotificationParser notificationParser, - NotificationProcessor notificationProcessor, - TelemetryRuntimeProducer telemetryRuntimeProducer, + UpdateNotificationListener updateListener, + StreamingTelemetry telemetry, PushManagerEventBroadcaster pushManagerEventBroadcaster, HttpClient httpClient) { SseHandler sseHandler = new SseHandler(notificationParser, - notificationProcessor, - telemetryRuntimeProducer, + updateListener, + telemetry, pushManagerEventBroadcaster); return new SseClientImpl(URI.create(streamingServiceUrlString), - httpClient, + new HttpClientStreamingTransport(httpClient), new EventStreamParser(), sseHandler); } @@ -396,22 +405,25 @@ public StreamingComponents buildStreamingComponents(@NonNull SplitTaskExecutor s notificationParser, splitsUpdateNotificationQueue); PushManagerEventBroadcaster pushManagerEventBroadcaster = new PushManagerEventBroadcaster(); + StreamingScheduler scheduler = new SplitTaskExecutorStreamingScheduler(splitTaskExecutor); + StreamingTelemetry streamingTelemetry = new TelemetryRuntimeProducerStreamingTelemetry(storageContainer.getTelemetryStorage()); + UpdateNotificationListener updateListener = new NotificationProcessorUpdateListener(notificationProcessor); SseClient sseClient = getSseClient(config.streamingServiceUrl(), notificationParser, - notificationProcessor, - storageContainer.getTelemetryStorage(), + updateListener, + streamingTelemetry, pushManagerEventBroadcaster, defaultHttpClient); - SseAuthenticator sseAuthenticator = new SseAuthenticator(splitApiFacade.getSseAuthenticationFetcher(), + SseAuthenticator sseAuthenticator = new SseAuthenticator(new HttpFetcherStreamingAuthFetcher(splitApiFacade.getSseAuthenticationFetcher()), new SseJwtParser(), flagsSpec); - PushNotificationManager pushNotificationManager = getPushNotificationManager(splitTaskExecutor, + PushNotificationManager pushNotificationManager = getPushNotificationManager(scheduler, sseAuthenticator, pushManagerEventBroadcaster, sseClient, - storageContainer.getTelemetryStorage(), + streamingTelemetry, config.defaultSSEConnectionDelay(), config.sseDisconnectionDelay()); diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpClientStreamingTransport.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpClientStreamingTransport.java new file mode 100644 index 000000000..aca2d3eba --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpClientStreamingTransport.java @@ -0,0 +1,92 @@ +package io.split.android.client.service.sseclient.sseclient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedReader; +import java.io.IOException; +import java.net.URI; + +import io.split.android.client.network.HttpClient; +import io.split.android.client.network.HttpException; +import io.split.android.client.network.HttpStreamRequest; +import io.split.android.client.network.HttpStreamResponse; +import io.split.android.client.service.sseclient.spi.StreamingTransport; + +/** + * Adapter that implements StreamingTransport using HttpClient. + */ +public class HttpClientStreamingTransport implements StreamingTransport { + + private final HttpClient mHttpClient; + + public HttpClientStreamingTransport(@NonNull HttpClient httpClient) { + mHttpClient = httpClient; + } + + @NonNull + @Override + public StreamingConnection connect(@NonNull URI uri) { + return new HttpClientStreamingConnection(mHttpClient.streamRequest(uri)); + } + + private static class HttpClientStreamingConnection implements StreamingConnection { + private final HttpStreamRequest mRequest; + + HttpClientStreamingConnection(HttpStreamRequest request) { + mRequest = request; + } + + @NonNull + @Override + public StreamingResponse execute() throws StreamingTransportException { + try { + HttpStreamResponse response = mRequest.execute(); + return new HttpClientStreamingResponse(response); + } catch (HttpException e) { + throw new StreamingTransportException(e.getMessage(), e, e.getStatusCode()); + } catch (IOException e) { + throw new StreamingTransportException(e.getMessage(), e); + } + } + + @Override + public void close() { + mRequest.close(); + } + } + + private static class HttpClientStreamingResponse implements StreamingResponse { + private final HttpStreamResponse mResponse; + + HttpClientStreamingResponse(HttpStreamResponse response) { + mResponse = response; + } + + @Override + public boolean isSuccess() { + return mResponse.isSuccess(); + } + + @Override + public int getHttpStatus() { + return mResponse.getHttpStatus(); + } + + @Override + public boolean isClientRelatedError() { + return mResponse.isClientRelatedError(); + } + + @Nullable + @Override + public BufferedReader getBufferedReader() { + return mResponse.getBufferedReader(); + } + + @Override + public void close() throws IOException { + mResponse.close(); + } + } +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpFetcherStreamingAuthFetcher.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpFetcherStreamingAuthFetcher.java new file mode 100644 index 000000000..fee0d7987 --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/HttpFetcherStreamingAuthFetcher.java @@ -0,0 +1,35 @@ +package io.split.android.client.service.sseclient.sseclient; + +import androidx.annotation.NonNull; + +import java.util.Map; + +import io.split.android.client.service.http.HttpFetcher; +import io.split.android.client.service.http.HttpFetcherException; +import io.split.android.client.service.sseclient.SseAuthenticationResponse; +import io.split.android.client.service.sseclient.spi.StreamingAuthException; +import io.split.android.client.service.sseclient.spi.StreamingAuthFetcher; + +/** + * Adapter that implements StreamingAuthFetcher using HttpFetcher. + */ +public class HttpFetcherStreamingAuthFetcher implements StreamingAuthFetcher { + + private final HttpFetcher mAuthFetcher; + + public HttpFetcherStreamingAuthFetcher(@NonNull HttpFetcher authFetcher) { + mAuthFetcher = authFetcher; + } + + @NonNull + @Override + public SseAuthenticationResponse execute(@NonNull Map params) throws StreamingAuthException { + try { + return mAuthFetcher.execute(params, null); + } catch (HttpFetcherException e) { + throw new StreamingAuthException(e.getLocalizedMessage(), e, e.getHttpStatus()); + } catch (Exception e) { + throw new StreamingAuthException(e.getLocalizedMessage(), e); + } + } +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationProcessorUpdateListener.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationProcessorUpdateListener.java new file mode 100644 index 000000000..2d4aaa0bc --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationProcessorUpdateListener.java @@ -0,0 +1,24 @@ +package io.split.android.client.service.sseclient.sseclient; + +import androidx.annotation.NonNull; + +import io.split.android.client.service.sseclient.notifications.IncomingNotification; +import io.split.android.client.service.sseclient.notifications.NotificationProcessor; +import io.split.android.client.service.sseclient.spi.UpdateNotificationListener; + +/** + * Adapter that forwards update notifications to NotificationProcessor. + */ +public class NotificationProcessorUpdateListener implements UpdateNotificationListener { + + private final NotificationProcessor mNotificationProcessor; + + public NotificationProcessorUpdateListener(@NonNull NotificationProcessor notificationProcessor) { + mNotificationProcessor = notificationProcessor; + } + + @Override + public void onUpdateNotification(@NonNull IncomingNotification notification) { + mNotificationProcessor.process(notification); + } +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java new file mode 100644 index 000000000..404883669 --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java @@ -0,0 +1,55 @@ +package io.split.android.client.service.sseclient.sseclient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.split.android.client.service.executor.SplitTask; +import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskExecutionListener; +import io.split.android.client.service.executor.SplitTaskExecutionStatus; +import io.split.android.client.service.executor.SplitTaskExecutor; +import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; + +/** + * Adapter that implements StreamingScheduler using SplitTaskExecutor. + */ +public class SplitTaskExecutorStreamingScheduler implements StreamingScheduler { + + private final SplitTaskExecutor mTaskExecutor; + + public SplitTaskExecutorStreamingScheduler(@NonNull SplitTaskExecutor taskExecutor) { + mTaskExecutor = taskExecutor; + } + + @NonNull + @Override + public String schedule(@NonNull Runnable task, long delaySeconds, @Nullable TaskExecutionListener listener) { + return mTaskExecutor.schedule(new SplitTask() { + @NonNull + @Override + public SplitTaskExecutionInfo execute() { + try { + task.run(); + return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); + } catch (Exception e) { + return SplitTaskExecutionInfo.error(SplitTaskType.GENERIC_TASK, SplitTaskExecutionStatus.ERROR, e.getMessage()); + } + } + }, delaySeconds, new SplitTaskExecutionListener() { + @Override + public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { + if (listener != null) { + listener.onTaskExecuted(); + } + } + }); + } + + @Override + public void cancel(@Nullable String taskId) { + if (taskId != null) { + mTaskExecutor.stopTask(taskId); + } + } +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java deleted file mode 100644 index 7b196202d..000000000 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.split.android.client.service.sseclient.sseclient; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import androidx.annotation.NonNull; - -import io.split.android.client.service.executor.SplitTask; -import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskExecutionListener; -import io.split.android.client.service.executor.SplitTaskExecutor; -import io.split.android.client.utils.logger.Logger; - -public class SseDisconnectionTimer implements SplitTaskExecutionListener { - - private final SplitTaskExecutor mTaskExecutor; - private final int mInitialDelayInSeconds; - private String mTaskId; - - public SseDisconnectionTimer(@NonNull SplitTaskExecutor taskExecutor, int initialDelayInSeconds) { - mTaskExecutor = checkNotNull(taskExecutor); - mInitialDelayInSeconds = initialDelayInSeconds; - } - - public void cancel() { - if (mTaskId != null) { - mTaskExecutor.stopTask(mTaskId); - } - } - - public void schedule(SplitTask task) { - Logger.v("Scheduling disconnection in " + mInitialDelayInSeconds + " seconds"); - cancel(); - mTaskId = mTaskExecutor.schedule(task, mInitialDelayInSeconds, this); - } - - @Override - public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { - mTaskId = null; - } -} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetry.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetry.java new file mode 100644 index 000000000..6cb127fda --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetry.java @@ -0,0 +1,105 @@ +package io.split.android.client.service.sseclient.sseclient; + +import androidx.annotation.NonNull; + +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; +import io.split.android.client.telemetry.model.OperationType; +import io.split.android.client.telemetry.model.streaming.AblyErrorStreamingEvent; +import io.split.android.client.telemetry.model.streaming.OccupancyPriStreamingEvent; +import io.split.android.client.telemetry.model.streaming.OccupancySecStreamingEvent; +import io.split.android.client.telemetry.model.streaming.SseConnectionErrorStreamingEvent; +import io.split.android.client.telemetry.model.streaming.StreamingStatusStreamingEvent; +import io.split.android.client.telemetry.model.streaming.SyncModeUpdateStreamingEvent; +import io.split.android.client.telemetry.model.streaming.TokenRefreshStreamingEvent; +import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; + +/** + * Adapter that implements StreamingTelemetry using TelemetryRuntimeProducer. + */ +public class TelemetryRuntimeProducerStreamingTelemetry implements StreamingTelemetry { + + private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; + + public TelemetryRuntimeProducerStreamingTelemetry(@NonNull TelemetryRuntimeProducer telemetryRuntimeProducer) { + mTelemetryRuntimeProducer = telemetryRuntimeProducer; + } + + @Override + public void recordTokenSyncLatency(long latencyMillis) { + mTelemetryRuntimeProducer.recordSyncLatency(OperationType.TOKEN, latencyMillis); + } + + @Override + public void recordTokenSuccessfulSync(long timestamp) { + mTelemetryRuntimeProducer.recordSuccessfulSync(OperationType.TOKEN, timestamp); + } + + @Override + public void recordTokenSyncError(Integer httpStatus) { + mTelemetryRuntimeProducer.recordSyncError(OperationType.TOKEN, httpStatus); + } + + @Override + public void recordAuthRejections() { + mTelemetryRuntimeProducer.recordAuthRejections(); + } + + @Override + public void recordTokenRefreshes() { + mTelemetryRuntimeProducer.recordTokenRefreshes(); + } + + @Override + public void recordTokenRefreshEvent(long expirationTime, long timestamp) { + mTelemetryRuntimeProducer.recordStreamingEvents(new TokenRefreshStreamingEvent(expirationTime, timestamp)); + } + + @Override + public void recordSyncModeUpdate(boolean streaming, long timestamp) { + SyncModeUpdateStreamingEvent.Mode mode = streaming + ? SyncModeUpdateStreamingEvent.Mode.STREAMING + : SyncModeUpdateStreamingEvent.Mode.POLLING; + mTelemetryRuntimeProducer.recordStreamingEvents(new SyncModeUpdateStreamingEvent(mode, timestamp)); + } + + @Override + public void recordConnectionError(boolean retryable, long timestamp) { + SseConnectionErrorStreamingEvent.Status status = retryable + ? SseConnectionErrorStreamingEvent.Status.REQUESTED + : SseConnectionErrorStreamingEvent.Status.NON_REQUESTED; + mTelemetryRuntimeProducer.recordStreamingEvents(new SseConnectionErrorStreamingEvent(status, timestamp)); + } + + @Override + public void recordAblyError(int errorCode, long timestamp) { + mTelemetryRuntimeProducer.recordStreamingEvents(new AblyErrorStreamingEvent(errorCode, timestamp)); + } + + @Override + public void recordOccupancyPri(int publisherCount, long timestamp) { + mTelemetryRuntimeProducer.recordStreamingEvents(new OccupancyPriStreamingEvent(publisherCount, timestamp)); + } + + @Override + public void recordOccupancySec(int publisherCount, long timestamp) { + mTelemetryRuntimeProducer.recordStreamingEvents(new OccupancySecStreamingEvent(publisherCount, timestamp)); + } + + @Override + public void recordStreamingStatus(StreamingStatus status, long timestamp) { + StreamingStatusStreamingEvent.Status telemetryStatus; + switch (status) { + case PAUSED: + telemetryStatus = StreamingStatusStreamingEvent.Status.PAUSED; + break; + case DISABLED: + telemetryStatus = StreamingStatusStreamingEvent.Status.DISABLED; + break; + case ENABLED: + default: + telemetryStatus = StreamingStatusStreamingEvent.Status.ENABLED; + break; + } + mTelemetryRuntimeProducer.recordStreamingEvents(new StreamingStatusStreamingEvent(telemetryStatus, timestamp)); + } +} diff --git a/settings.gradle b/settings.gradle index 9ddd50403..0cd73347a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,3 +7,4 @@ include ':http' include ':main' include ':events' include ':events-domain' +include ':streaming' \ No newline at end of file diff --git a/streaming/build.gradle b/streaming/build.gradle new file mode 100644 index 000000000..670903666 --- /dev/null +++ b/streaming/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.streaming' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + compileOnly libs.jetbrainsAnnotations + implementation libs.annotation + implementation libs.gson + + // Logger module for logging + api project(':logger') + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/streaming/src/main/AndroidManifest.xml b/streaming/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9a40236b9 --- /dev/null +++ b/streaming/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/streaming/src/main/java/io/split/android/client/common/CompressionType.java b/streaming/src/main/java/io/split/android/client/common/CompressionType.java new file mode 100644 index 000000000..6d8bcf7f3 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/common/CompressionType.java @@ -0,0 +1,9 @@ +package io.split.android.client.common; + +import com.google.gson.annotations.SerializedName; + +public enum CompressionType { + @SerializedName("0") NONE, + @SerializedName("1") GZIP, + @SerializedName("2") ZLIB +} diff --git a/streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java b/streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java new file mode 100644 index 000000000..d6b8721c6 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java @@ -0,0 +1,43 @@ +package io.split.android.client.common; + +import androidx.annotation.Nullable; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.split.android.client.utils.CompressionUtil; +import io.split.android.client.utils.Gzip; +import io.split.android.client.utils.logger.Logger; +import io.split.android.client.utils.Zlib; + +public class CompressionUtilProvider { + Map mCompressionUtils = new ConcurrentHashMap<>(); + + @Nullable + public CompressionUtil get(CompressionType type) { + CompressionUtil util = mCompressionUtils.get(type); + return (util != null ? util : create(type)); + } + + // Using a method instead of a factory to avoid + // a complex architecture. + @Nullable + private CompressionUtil create(CompressionType type) { + switch (type) { + case NONE: + return new CompressionUtil() { + @Override + public byte[] decompress(byte[] compressed) { + return compressed; + } + }; + case GZIP: + return new Gzip(); + case ZLIB: + return new Zlib(); + default: + Logger.d("Unavailable compression algorithm: " + type); + } + return null; + } +} diff --git a/streaming/src/main/java/io/split/android/client/network/URIBuilder.java b/streaming/src/main/java/io/split/android/client/network/URIBuilder.java new file mode 100644 index 000000000..9837ef614 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/network/URIBuilder.java @@ -0,0 +1,85 @@ +package io.split.android.client.network; + +import static io.split.android.client.utils.Utils.checkNotNull; + +import androidx.annotation.NonNull; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.AbstractMap; +import java.util.LinkedHashSet; +import java.util.Set; + +import io.split.android.client.utils.Utils; + +public class URIBuilder { + private final URI mRootURI; + private final Set> mParams; + private String mPath; + private String mQueryString; + + public URIBuilder(@NonNull URI rootURI, String path) { + mRootURI = checkNotNull(rootURI); + String rootPath = mRootURI.getRawPath(); + if (path != null && rootPath != null) { + mPath = String.format("%s/%s", rootPath, path); + mPath = mPath.replace("///", "/"); + mPath = mPath.replace("//", "/"); + } else if (rootPath != null) { + mPath = rootPath; + mQueryString = rootURI.getQuery(); + } else { + mPath = path; + } + mParams = new LinkedHashSet<>(); + } + + public URIBuilder(@NonNull URI rootURI) { + this(rootURI, null); + } + + public URIBuilder addParameter(@NonNull String param, @NonNull String value) { + if (param != null && value != null) { + mParams.add(new AbstractMap.SimpleEntry<>(param, value)); + } + return this; + } + + public URIBuilder defaultQueryString(@NonNull String queryString) { + if (!Utils.isNullOrEmpty(queryString)) { + mQueryString = queryString; + } + return this; + } + + public URI build() throws URISyntaxException { + + String params = null; + if (mParams.size() > 0) { + StringBuilder query = new StringBuilder(); + for (AbstractMap.SimpleEntry param : mParams) { + query.append(param.getKey()).append("=").append(param.getValue()).append("&"); + } + params = query.substring(0, query.length() - 1); + } + + if (!Utils.isNullOrEmpty(mQueryString)) { + if (!Utils.isNullOrEmpty(params)) { + if (!"&".equals(mQueryString.substring(0, 1))) { + params = params + "&"; + } + params = params + mQueryString; + } else { + params = mQueryString; + } + } + + return new URI(mRootURI.getScheme(), + null, + mRootURI.getHost(), + mRootURI.getPort(), + mPath, + params, + null); + } +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java b/streaming/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/EventStreamParser.java b/streaming/src/main/java/io/split/android/client/service/sseclient/EventStreamParser.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/EventStreamParser.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/EventStreamParser.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java b/streaming/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java b/streaming/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java b/streaming/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java b/streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java b/streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java b/streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java b/streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java b/streaming/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java new file mode 100644 index 000000000..58c1b5e6a --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java @@ -0,0 +1,21 @@ +package io.split.android.client.service.sseclient; + +/** + * Constants used by the streaming module. + */ +public final class StreamingConstants { + + private StreamingConstants() { + // Utility class + } + + /** + * Buffer size for segment data decompression. + */ + public static final int SEGMENT_DATA_BUFFER_SIZE = 1024 * 10; // 10KB + + /** + * Query param for flags spec in streaming auth. + */ + public static final String FLAGS_SPEC_PARAM = "s"; +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java b/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java b/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java b/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java b/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java b/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java new file mode 100644 index 000000000..de3a8c2b0 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java @@ -0,0 +1,32 @@ +package io.split.android.client.service.sseclient.spi; + +import androidx.annotation.Nullable; + +/** + * Exception thrown by streaming auth fetchers. + */ +public class StreamingAuthException extends Exception { + + @Nullable + private final Integer mStatusCode; + + public StreamingAuthException(String message) { + super(message); + mStatusCode = null; + } + + public StreamingAuthException(String message, Throwable cause) { + super(message, cause); + mStatusCode = null; + } + + public StreamingAuthException(String message, Throwable cause, Integer statusCode) { + super(message, cause); + mStatusCode = statusCode; + } + + @Nullable + public Integer getStatusCode() { + return mStatusCode; + } +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java new file mode 100644 index 000000000..e722c1962 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java @@ -0,0 +1,23 @@ +package io.split.android.client.service.sseclient.spi; + +import androidx.annotation.NonNull; + +import java.util.Map; + +import io.split.android.client.service.sseclient.SseAuthenticationResponse; + +/** + * Abstraction for fetching streaming authentication tokens. + */ +public interface StreamingAuthFetcher { + + /** + * Executes the auth request with the provided parameters. + * + * @param params request parameters + * @return authentication response + * @throws StreamingAuthException when request fails + */ + @NonNull + SseAuthenticationResponse execute(@NonNull Map params) throws StreamingAuthException; +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java new file mode 100644 index 000000000..ff1e2d185 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java @@ -0,0 +1,40 @@ +package io.split.android.client.service.sseclient.spi; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Interface for scheduling delayed tasks within the streaming module. + * Implementations should provide timer/scheduling capabilities backed + * by the host application's task executor. + */ +public interface StreamingScheduler { + + /** + * Schedules a task to run after the specified delay. + * + * @param task the runnable to execute + * @param delaySeconds delay before execution in seconds + * @param listener optional listener to be notified when task completes + * @return a unique task ID that can be used to cancel the task + */ + @NonNull + String schedule(@NonNull Runnable task, long delaySeconds, @Nullable TaskExecutionListener listener); + + /** + * Cancels a previously scheduled task. + * + * @param taskId the ID returned by schedule() + */ + void cancel(@Nullable String taskId); + + /** + * Listener interface for task completion notifications. + */ + interface TaskExecutionListener { + /** + * Called when a scheduled task has completed execution. + */ + void onTaskExecuted(); + } +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java new file mode 100644 index 000000000..506f8229e --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java @@ -0,0 +1,104 @@ +package io.split.android.client.service.sseclient.spi; + +/** + * Interface for recording streaming-related telemetry. + * Implementations should bridge to the host application's telemetry system. + */ +public interface StreamingTelemetry { + + /** + * Records a sync latency measurement for token operations. + * + * @param latencyMillis the latency in milliseconds + */ + void recordTokenSyncLatency(long latencyMillis); + + /** + * Records a successful token sync operation. + * + * @param timestamp the timestamp of the sync + */ + void recordTokenSuccessfulSync(long timestamp); + + /** + * Records a token sync error. + * + * @param httpStatus the HTTP status code + */ + void recordTokenSyncError(Integer httpStatus); + + /** + * Records an authentication rejection. + */ + void recordAuthRejections(); + + /** + * Records a token refresh. + */ + void recordTokenRefreshes(); + + /** + * Records a token refresh streaming event. + * + * @param expirationTime the token expiration time + * @param timestamp the timestamp + */ + void recordTokenRefreshEvent(long expirationTime, long timestamp); + + /** + * Records a sync mode update (streaming enabled). + * + * @param streaming true if streaming mode, false if polling + * @param timestamp the timestamp + */ + void recordSyncModeUpdate(boolean streaming, long timestamp); + + /** + * Records an SSE connection error. + * + * @param retryable true if the error is retryable + * @param timestamp the timestamp + */ + void recordConnectionError(boolean retryable, long timestamp); + + /** + * Records an Ably error. + * + * @param errorCode the error code + * @param timestamp the timestamp + */ + void recordAblyError(int errorCode, long timestamp); + + /** + * Records an occupancy event on the primary channel. + * + * @param publisherCount the publisher count + * @param timestamp the timestamp + */ + void recordOccupancyPri(int publisherCount, long timestamp); + + /** + * Records an occupancy event on the secondary channel. + * + * @param publisherCount the publisher count + * @param timestamp the timestamp + */ + void recordOccupancySec(int publisherCount, long timestamp); + + /** + * Records a streaming status change. + * + * @param status the new status (ENABLED, PAUSED, DISABLED) + * @param timestamp the timestamp + */ + void recordStreamingStatus(StreamingStatus status, long timestamp); + + /** + * Streaming status values. + */ + enum StreamingStatus { + ENABLED, + PAUSED, + DISABLED + } +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTransport.java b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTransport.java new file mode 100644 index 000000000..a1e4888f2 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTransport.java @@ -0,0 +1,110 @@ +package io.split.android.client.service.sseclient.spi; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.net.URI; + +/** + * Interface for SSE streaming transport. Implementations should provide + * the ability to open streaming connections and return response objects + * that expose buffered readers for line-by-line reading. + */ +public interface StreamingTransport { + + /** + * Opens a streaming connection to the given URI. + * + * @param uri the target URI + * @return a StreamingConnection that can be used to execute the request + */ + @NonNull + StreamingConnection connect(@NonNull URI uri); + + /** + * Represents a streaming connection that can be executed to obtain a response. + */ + interface StreamingConnection { + + /** + * Executes the streaming request and returns the response. + * + * @return the streaming response + * @throws StreamingTransportException if an error occurs during the request + */ + @NonNull + StreamingResponse execute() throws StreamingTransportException; + + /** + * Closes this connection and releases associated resources. + */ + void close(); + } + + /** + * Represents the response from a streaming connection. + */ + interface StreamingResponse extends Closeable { + + /** + * @return true if the connection was successful (HTTP 2xx) + */ + boolean isSuccess(); + + /** + * @return the HTTP status code + */ + int getHttpStatus(); + + /** + * @return true if the error is client-related (4xx except 408) + */ + boolean isClientRelatedError(); + + /** + * @return the buffered reader for reading the stream, or null if not available + */ + @Nullable + BufferedReader getBufferedReader(); + } + + /** + * Exception thrown by streaming transport operations. + */ + class StreamingTransportException extends Exception { + + @Nullable + private final Integer mStatusCode; + + public StreamingTransportException(String message) { + super(message); + mStatusCode = null; + } + + public StreamingTransportException(String message, Throwable cause) { + super(message, cause); + mStatusCode = null; + } + + public StreamingTransportException(String message, int statusCode) { + super(message); + mStatusCode = statusCode; + } + + public StreamingTransportException(String message, Throwable cause, int statusCode) { + super(message, cause); + mStatusCode = statusCode; + } + + /** + * @return the HTTP status code if available, null otherwise + */ + @Nullable + public Integer getStatusCode() { + return mStatusCode; + } + } +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java new file mode 100644 index 000000000..66e3402e0 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java @@ -0,0 +1,25 @@ +package io.split.android.client.service.sseclient.spi; + +import androidx.annotation.NonNull; + +import io.split.android.client.service.sseclient.notifications.IncomingNotification; + +/** + * Listener interface for update notifications from the streaming module. + * Host applications implement this to handle split/RBS/kill/membership updates. + */ +public interface UpdateNotificationListener { + + /** + * Called when an update notification is received. + * The notification type can be checked to determine the specific update type: + * - SPLIT_UPDATE + * - SPLIT_KILL + * - RULE_BASED_SEGMENT_UPDATE + * - MEMBERSHIPS_MS_UPDATE + * - MEMBERSHIPS_LS_UPDATE + * + * @param notification the incoming update notification + */ + void onUpdateNotification(@NonNull IncomingNotification notification); +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java similarity index 80% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java index eeba8744d..da8f9a9ef 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java @@ -15,10 +15,7 @@ import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent.EventType; import io.split.android.client.service.sseclient.notifications.ControlNotification; import io.split.android.client.service.sseclient.notifications.OccupancyNotification; -import io.split.android.client.telemetry.model.streaming.OccupancyPriStreamingEvent; -import io.split.android.client.telemetry.model.streaming.OccupancySecStreamingEvent; -import io.split.android.client.telemetry.model.streaming.StreamingStatusStreamingEvent; -import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; import io.split.android.client.utils.logger.Logger; public class NotificationManagerKeeper { @@ -40,11 +37,11 @@ public Publisher(int count, long lastTimestamp) { private final PushManagerEventBroadcaster mBroadcasterChannel; private final AtomicLong mLastControlTimestamp = new AtomicLong(0); private final AtomicBoolean mIsStreamingActive = new AtomicBoolean(true); - private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; + private final StreamingTelemetry mTelemetry; - public NotificationManagerKeeper(PushManagerEventBroadcaster broadcasterChannel, TelemetryRuntimeProducer telemetryRuntimeProducer) { + public NotificationManagerKeeper(PushManagerEventBroadcaster broadcasterChannel, StreamingTelemetry telemetry) { mBroadcasterChannel = broadcasterChannel; - mTelemetryRuntimeProducer = telemetryRuntimeProducer; + mTelemetry = telemetry; /// By default we consider one publisher en primary channel available mPublishers.put(CHANNEL_PRI_KEY, new Publisher(1, 0)); mPublishers.put(CHANNEL_SEC_KEY, new Publisher(0, 0)); @@ -60,20 +57,20 @@ public void handleControlNotification(ControlNotification notification) { case STREAMING_PAUSED: mIsStreamingActive.set(false); mBroadcasterChannel.pushMessage(new PushStatusEvent(EventType.PUSH_SUBSYSTEM_DOWN)); - mTelemetryRuntimeProducer.recordStreamingEvents(new StreamingStatusStreamingEvent(StreamingStatusStreamingEvent.Status.PAUSED, System.currentTimeMillis())); + mTelemetry.recordStreamingStatus(StreamingTelemetry.StreamingStatus.PAUSED, System.currentTimeMillis()); break; case STREAMING_DISABLED: mIsStreamingActive.set(false); mBroadcasterChannel.pushMessage(new PushStatusEvent(EventType.PUSH_DISABLED)); - mTelemetryRuntimeProducer.recordStreamingEvents(new StreamingStatusStreamingEvent(StreamingStatusStreamingEvent.Status.DISABLED, System.currentTimeMillis())); + mTelemetry.recordStreamingStatus(StreamingTelemetry.StreamingStatus.DISABLED, System.currentTimeMillis()); break; case STREAMING_RESUMED: mIsStreamingActive.set(true); if (publishersCount() > 0) { mBroadcasterChannel.pushMessage(new PushStatusEvent(EventType.PUSH_SUBSYSTEM_UP)); - mTelemetryRuntimeProducer.recordStreamingEvents(new StreamingStatusStreamingEvent(StreamingStatusStreamingEvent.Status.ENABLED, System.currentTimeMillis())); + mTelemetry.recordStreamingStatus(StreamingTelemetry.StreamingStatus.ENABLED, System.currentTimeMillis()); } break; @@ -103,9 +100,9 @@ public void handleOccupancyNotification(OccupancyNotification notification) { updateChannelInfo(channelKey, notification.getMetrics().getPublishers(), notification.getTimestamp()); if (CHANNEL_PRI_KEY.equals(channelKey)) { - mTelemetryRuntimeProducer.recordStreamingEvents(new OccupancyPriStreamingEvent(publishersCount(), System.currentTimeMillis())); + mTelemetry.recordOccupancyPri(publishersCount(), System.currentTimeMillis()); } else if (CHANNEL_SEC_KEY.equals(channelKey)) { - mTelemetryRuntimeProducer.recordStreamingEvents(new OccupancySecStreamingEvent(publishersCount(), System.currentTimeMillis())); + mTelemetry.recordOccupancySec(publishersCount(), System.currentTimeMillis()); } if (publishersCount() == 0 && prevPublishersCount > 0) { diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java similarity index 78% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java index 5217889b2..f4b374f4e 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java @@ -13,20 +13,13 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import io.split.android.client.service.executor.SplitSingleThreadTaskExecutor; -import io.split.android.client.service.executor.SplitTask; -import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskType; -import io.split.android.client.service.executor.ThreadFactoryBuilder; import io.split.android.client.service.sseclient.SseJwtToken; import io.split.android.client.service.sseclient.feedbackchannel.DelayStatusEvent; import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent; import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent.EventType; -import io.split.android.client.telemetry.model.OperationType; -import io.split.android.client.telemetry.model.streaming.SyncModeUpdateStreamingEvent; -import io.split.android.client.telemetry.model.streaming.TokenRefreshStreamingEvent; -import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; import io.split.android.client.utils.logger.Logger; public class PushNotificationManager { @@ -37,21 +30,22 @@ public class PushNotificationManager { private final PushManagerEventBroadcaster mBroadcasterChannel; private final SseAuthenticator mSseAuthenticator; private final SseClient mSseClient; - private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; + private final StreamingTelemetry mTelemetry; private final SseRefreshTokenTimer mRefreshTokenTimer; private final SseDisconnectionTimer mDisconnectionTimer; private final AtomicBoolean mIsPaused; private final AtomicBoolean mIsStopped; private Future mConnectionTask; - private final SplitTask mBackgroundDisconnectionTask; + private final Runnable mBackgroundDisconnectionTask; private final long mDefaultSSEConnectionDelayInSecs; public PushNotificationManager(@NonNull PushManagerEventBroadcaster pushManagerEventBroadcaster, @NonNull SseAuthenticator sseAuthenticator, @NonNull SseClient sseClient, @NonNull SseRefreshTokenTimer refreshTokenTimer, - @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer, + @NonNull StreamingScheduler scheduler, + @NonNull StreamingTelemetry telemetry, long defaultSSEConnectionDelayInSecs, int sseDisconnectionDelayInSecs, @Nullable ScheduledExecutorService executorService) { @@ -59,8 +53,8 @@ public PushNotificationManager(@NonNull PushManagerEventBroadcaster pushManagerE sseAuthenticator, sseClient, refreshTokenTimer, - new SseDisconnectionTimer(new SplitSingleThreadTaskExecutor(), sseDisconnectionDelayInSecs), - telemetryRuntimeProducer, + new SseDisconnectionTimer(scheduler, sseDisconnectionDelayInSecs), + telemetry, defaultSSEConnectionDelayInSecs, executorService); } @@ -71,7 +65,7 @@ public PushNotificationManager(@NonNull PushManagerEventBroadcaster broadcasterC @NonNull SseClient sseClient, @NonNull SseRefreshTokenTimer refreshTokenTimer, @NonNull SseDisconnectionTimer disconnectionTimer, - @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer, + @NonNull StreamingTelemetry telemetry, long defaultSSEConnectionDelayInSecs, @Nullable ScheduledExecutorService executor) { mBroadcasterChannel = checkNotNull(broadcasterChannel); @@ -79,7 +73,7 @@ public PushNotificationManager(@NonNull PushManagerEventBroadcaster broadcasterC mSseClient = checkNotNull(sseClient); mRefreshTokenTimer = checkNotNull(refreshTokenTimer); mDisconnectionTimer = checkNotNull(disconnectionTimer); - mTelemetryRuntimeProducer = checkNotNull(telemetryRuntimeProducer); + mTelemetry = checkNotNull(telemetry); mIsStopped = new AtomicBoolean(false); mIsPaused = new AtomicBoolean(false); mBackgroundDisconnectionTask = new BackgroundDisconnectionTask(mSseClient, mRefreshTokenTimer); @@ -92,7 +86,7 @@ public PushNotificationManager(@NonNull PushManagerEventBroadcaster broadcasterC } public synchronized void start() { - mTelemetryRuntimeProducer.recordStreamingEvents(new SyncModeUpdateStreamingEvent(SyncModeUpdateStreamingEvent.Mode.STREAMING, System.currentTimeMillis())); + mTelemetry.recordSyncModeUpdate(true, System.currentTimeMillis()); Logger.d("Push notification manager started"); connect(); } @@ -157,17 +151,13 @@ private void shutdownAndAwaitTermination() { } private ScheduledThreadPoolExecutor buildExecutor() { - ThreadFactoryBuilder threadFactoryBuilder = new ThreadFactoryBuilder(); - threadFactoryBuilder.setDaemon(true); - threadFactoryBuilder.setNameFormat("split-sse_client-%d"); - threadFactoryBuilder.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - Logger.e(e, "Error in thread: %s", t.getName()); - } + return new ScheduledThreadPoolExecutor(POOL_SIZE, runnable -> { + Thread thread = new Thread(runnable); + thread.setDaemon(true); + thread.setName("split-sse_client-" + thread.getId()); + thread.setUncaughtExceptionHandler((t, e) -> Logger.e(e, "Error in thread: %s", t.getName())); + return thread; }); - - return new ScheduledThreadPoolExecutor(POOL_SIZE, threadFactoryBuilder.build()); } private class StreamingConnection implements Runnable { @@ -183,7 +173,7 @@ public void run() { long startTime = System.currentTimeMillis(); SseAuthenticationResult authResult = mSseAuthenticator.authenticate(mDefaultSSEConnectionDelayInSecs); - mTelemetryRuntimeProducer.recordSyncLatency(OperationType.TOKEN, System.currentTimeMillis() - startTime); + mTelemetry.recordTokenSyncLatency(System.currentTimeMillis() - startTime); if (authResult.isSuccess() && !authResult.isPushEnabled()) { handlePushDisabled(); @@ -231,9 +221,9 @@ public void onConnectionSuccess() { } private void recordSuccessfulSyncAndTokenRefreshes(SseJwtToken token) { - mTelemetryRuntimeProducer.recordSuccessfulSync(OperationType.TOKEN, System.currentTimeMillis()); - mTelemetryRuntimeProducer.recordStreamingEvents(new TokenRefreshStreamingEvent(token.getExpirationTime(), System.currentTimeMillis())); - mTelemetryRuntimeProducer.recordTokenRefreshes(); + mTelemetry.recordTokenSuccessfulSync(System.currentTimeMillis()); + mTelemetry.recordTokenRefreshEvent(token.getExpirationTime(), System.currentTimeMillis()); + mTelemetry.recordTokenRefreshes(); } private void handlePushDisabled() { @@ -249,9 +239,9 @@ private void handleNonRetryableError(SseAuthenticationResult authResult) { } private void recordNonRetryableError(SseAuthenticationResult authResult) { - mTelemetryRuntimeProducer.recordAuthRejections(); + mTelemetry.recordAuthRejections(); if (authResult.getHttpStatus() != null) { - mTelemetryRuntimeProducer.recordSyncError(OperationType.TOKEN, authResult.getHttpStatus()); + mTelemetry.recordTokenSyncError(authResult.getHttpStatus()); } } @@ -275,7 +265,7 @@ private boolean delay(long seconds) { } } - public static class BackgroundDisconnectionTask implements SplitTask { + public static class BackgroundDisconnectionTask implements Runnable { private final SseClient mSseClient; private final SseRefreshTokenTimer mRefreshTokenTimer; @@ -286,13 +276,11 @@ public BackgroundDisconnectionTask(SseClient sseClient, SseRefreshTokenTimer ref mRefreshTokenTimer = refreshTokenTimer; } - @NonNull @Override - public SplitTaskExecutionInfo execute() { + public void run() { Logger.d("Disconnecting streaming while in background"); mSseClient.disconnect(); mRefreshTokenTimer.cancel(); - return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); } } } diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java similarity index 77% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java index 755388e9c..fe889d4f5 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java @@ -1,6 +1,5 @@ package io.split.android.client.service.sseclient.sseclient; -import static io.split.android.client.service.ServiceConstants.FLAGS_SPEC_PARAM; import static io.split.android.client.utils.Utils.checkNotNull; import androidx.annotation.NonNull; @@ -12,23 +11,23 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import io.split.android.client.service.http.HttpFetcher; -import io.split.android.client.service.http.HttpFetcherException; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.service.sseclient.InvalidJwtTokenException; import io.split.android.client.service.sseclient.SseAuthenticationResponse; import io.split.android.client.service.sseclient.SseJwtParser; +import io.split.android.client.service.sseclient.StreamingConstants; +import io.split.android.client.service.sseclient.spi.StreamingAuthException; +import io.split.android.client.service.sseclient.spi.StreamingAuthFetcher; import io.split.android.client.utils.logger.Logger; public class SseAuthenticator { private static final String USER_KEY_PARAM = "users"; - private final HttpFetcher mAuthFetcher; + private final StreamingAuthFetcher mAuthFetcher; private final Set mUserKeys; private final SseJwtParser mJwtParser; private final String mFlagsSpec; - public SseAuthenticator(@NonNull HttpFetcher authFetcher, + public SseAuthenticator(@NonNull StreamingAuthFetcher authFetcher, @NonNull SseJwtParser jwtParser, @Nullable String flagsSpec) { mAuthFetcher = checkNotNull(authFetcher); @@ -42,19 +41,19 @@ public SseAuthenticationResult authenticate(long defaultSseConnectionDelaySecs) try { Map params = new LinkedHashMap<>(); if (mFlagsSpec != null && !mFlagsSpec.trim().isEmpty()) { - params.put(FLAGS_SPEC_PARAM, mFlagsSpec); + params.put(StreamingConstants.FLAGS_SPEC_PARAM, mFlagsSpec); } params.put(USER_KEY_PARAM, mUserKeys); - authResponse = mAuthFetcher.execute(params, null); + authResponse = mAuthFetcher.execute(params); - } catch (HttpFetcherException httpFetcherException) { - logError("Unexpected " + httpFetcherException.getLocalizedMessage()); - if (httpFetcherException.getHttpStatus() != null) { - if (HttpStatus.isNotRetryable(HttpStatus.fromCode(httpFetcherException.getHttpStatus()))) { + } catch (StreamingAuthException authException) { + logError("Unexpected " + authException.getLocalizedMessage()); + if (authException.getStatusCode() != null) { + if (isNotRetryable(authException.getStatusCode())) { return unsuccessfulAuthenticationUnrecoverableError(); } - return unexpectedHttpError(httpFetcherException.getHttpStatus()); + return unexpectedHttpError(authException.getStatusCode()); } else { return unexpectedError(); } @@ -109,4 +108,11 @@ private SseAuthenticationResult unexpectedError() { private SseAuthenticationResult unexpectedHttpError(int httpStatus) { return new SseAuthenticationResult(httpStatus); } + + private boolean isNotRetryable(int httpStatus) { + return httpStatus == 400 + || httpStatus == 403 + || httpStatus == 414 + || httpStatus == 9009; + } } diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java similarity index 74% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java index 78a8f316b..72f87f5f8 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java @@ -13,14 +13,13 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import io.split.android.client.network.HttpClient; -import io.split.android.client.network.HttpException; -import io.split.android.client.network.HttpStreamRequest; -import io.split.android.client.network.HttpStreamResponse; import io.split.android.client.network.URIBuilder; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.service.sseclient.EventStreamParser; import io.split.android.client.service.sseclient.SseJwtToken; +import io.split.android.client.service.sseclient.spi.StreamingTransport; +import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingConnection; +import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingResponse; +import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingTransportException; import io.split.android.client.utils.StringHelper; import io.split.android.client.utils.logger.Logger; @@ -28,15 +27,15 @@ public class SseClientImpl implements SseClient { private final URI mTargetUrl; private final AtomicInteger mStatus; - private final HttpClient mHttpClient; + private final StreamingTransport mStreamingTransport; private final EventStreamParser mEventStreamParser; private final AtomicBoolean mIsDisconnectCalled; private final SseHandler mSseHandler; private final StringHelper mStringHelper; - private HttpStreamRequest mHttpStreamRequest = null; - private HttpStreamResponse mHttpStreamResponse = null; + private StreamingConnection mStreamingConnection = null; + private StreamingResponse mStreamingResponse = null; private static final String PUSH_NOTIFICATION_CHANNELS_PARAM = "channel"; private static final String PUSH_NOTIFICATION_TOKEN_PARAM = "accessToken"; @@ -44,11 +43,11 @@ public class SseClientImpl implements SseClient { private static final String PUSH_NOTIFICATION_VERSION_VALUE = "1.1"; public SseClientImpl(@NonNull URI uri, - @NonNull HttpClient httpClient, + @NonNull StreamingTransport streamingTransport, @NonNull EventStreamParser eventStreamParser, @NonNull SseHandler sseHandler) { mTargetUrl = checkNotNull(uri); - mHttpClient = checkNotNull(httpClient); + mStreamingTransport = checkNotNull(streamingTransport); mEventStreamParser = checkNotNull(eventStreamParser); mSseHandler = checkNotNull(sseHandler); mStatus = new AtomicInteger(DISCONNECTED); @@ -72,21 +71,21 @@ public void disconnect() { private void close() { Logger.d("Disconnecting SSE client"); if (mStatus.getAndSet(DISCONNECTED) != DISCONNECTED) { - // Close the HttpStreamResponse first to clean up sockets - if (mHttpStreamResponse != null) { + // Close the StreamingResponse first to clean up sockets + if (mStreamingResponse != null) { try { - mHttpStreamResponse.close(); - Logger.v("HttpStreamResponse closed successfully"); + mStreamingResponse.close(); + Logger.v("StreamingResponse closed successfully"); } catch (IOException e) { - Logger.w("Failed to close HttpStreamResponse: " + e.getMessage()); + Logger.w("Failed to close StreamingResponse: " + e.getMessage()); } - mHttpStreamResponse = null; + mStreamingResponse = null; } - // Close the HttpStreamRequest - if (mHttpStreamRequest != null) { - mHttpStreamRequest.close(); - mHttpStreamRequest = null; + // Close the StreamingConnection + if (mStreamingConnection != null) { + mStreamingConnection.close(); + mStreamingConnection = null; } Logger.d("SSE client disconnected"); } @@ -107,10 +106,10 @@ public void connect(SseJwtToken token, ConnectionListener connectionListener) { .addParameter(PUSH_NOTIFICATION_CHANNELS_PARAM, channels) .addParameter(PUSH_NOTIFICATION_TOKEN_PARAM, rawToken) .build(); - mHttpStreamRequest = mHttpClient.streamRequest(url); - mHttpStreamResponse = mHttpStreamRequest.execute(); - if (mHttpStreamResponse.isSuccess()) { - bufferedReader = mHttpStreamResponse.getBufferedReader(); + mStreamingConnection = mStreamingTransport.connect(url); + mStreamingResponse = mStreamingConnection.execute(); + if (mStreamingResponse.isSuccess()) { + bufferedReader = mStreamingResponse.getBufferedReader(); if (bufferedReader != null) { Logger.d("Streaming connection opened"); mStatus.set(CONNECTED); @@ -140,15 +139,15 @@ public void connect(SseJwtToken token, ConnectionListener connectionListener) { throw (new IOException("Buffer is null")); } } else { - Logger.e("Streaming connection error. Http return code " + mHttpStreamResponse.getHttpStatus()); - isErrorRetryable = !mHttpStreamResponse.isClientRelatedError(); + Logger.e("Streaming connection error. Http return code " + mStreamingResponse.getHttpStatus()); + isErrorRetryable = !mStreamingResponse.isClientRelatedError(); } } catch (URISyntaxException e) { logError("An error has occurred while creating stream Url ", e); isErrorRetryable = false; - } catch (HttpException e) { + } catch (StreamingTransportException e) { logError("An error has occurred while creating stream Url ", e); - isErrorRetryable = !HttpStatus.isNotRetryable(HttpStatus.fromCode(e.getStatusCode())); + isErrorRetryable = !isNotRetryableStatusCode(e.getStatusCode()); } catch (IOException e) { Logger.d("An error has occurred while parsing stream: " + e.getLocalizedMessage()); isErrorRetryable = true; @@ -163,6 +162,14 @@ public void connect(SseJwtToken token, ConnectionListener connectionListener) { } } + private boolean isNotRetryableStatusCode(Integer statusCode) { + if (statusCode == null) { + return false; + } + // Not retryable: 4xx errors except 408 (Request Timeout) + return statusCode >= 400 && statusCode < 500 && statusCode != 408; + } + private static void logError(String message, Exception e) { Logger.e(message + " : " + e.getLocalizedMessage()); } diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java new file mode 100644 index 000000000..16d5c824a --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java @@ -0,0 +1,35 @@ +package io.split.android.client.service.sseclient.sseclient; + +import static io.split.android.client.utils.Utils.checkNotNull; + +import androidx.annotation.NonNull; + +import io.split.android.client.service.sseclient.spi.StreamingScheduler; +import io.split.android.client.utils.logger.Logger; + +public class SseDisconnectionTimer { + + private final StreamingScheduler mScheduler; + private final int mInitialDelayInSeconds; + private String mTaskId; + + public SseDisconnectionTimer(@NonNull StreamingScheduler scheduler, int initialDelayInSeconds) { + mScheduler = checkNotNull(scheduler); + mInitialDelayInSeconds = initialDelayInSeconds; + } + + public void cancel() { + mScheduler.cancel(mTaskId); + } + + public void schedule(Runnable task) { + Logger.v("Scheduling disconnection in " + mInitialDelayInSeconds + " seconds"); + cancel(); + mTaskId = mScheduler.schedule(task, mInitialDelayInSeconds, new StreamingScheduler.TaskExecutionListener() { + @Override + public void onTaskExecuted() { + mTaskId = null; + } + }); + } +} diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java similarity index 80% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java index c8b967d9a..0ae3e6542 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java @@ -16,40 +16,38 @@ import io.split.android.client.service.sseclient.notifications.ControlNotification; import io.split.android.client.service.sseclient.notifications.IncomingNotification; import io.split.android.client.service.sseclient.notifications.NotificationParser; -import io.split.android.client.service.sseclient.notifications.NotificationProcessor; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; +import io.split.android.client.service.sseclient.spi.UpdateNotificationListener; import io.split.android.client.service.sseclient.notifications.OccupancyNotification; import io.split.android.client.service.sseclient.notifications.StreamingError; -import io.split.android.client.telemetry.model.streaming.AblyErrorStreamingEvent; -import io.split.android.client.telemetry.model.streaming.SseConnectionErrorStreamingEvent; -import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; import io.split.android.client.utils.logger.Logger; public class SseHandler { private final PushManagerEventBroadcaster mBroadcasterChannel; private final NotificationParser mNotificationParser; - private final NotificationProcessor mNotificationProcessor; + private final UpdateNotificationListener mUpdateListener; private final NotificationManagerKeeper mNotificationManagerKeeper; - private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; + private final StreamingTelemetry mTelemetry; public SseHandler(@NonNull NotificationParser notificationParser, - @NonNull NotificationProcessor notificationProcessor, - @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer, + @NonNull UpdateNotificationListener updateListener, + @NonNull StreamingTelemetry telemetry, @NonNull PushManagerEventBroadcaster broadcasterChannel) { - this(notificationParser, notificationProcessor, new NotificationManagerKeeper(broadcasterChannel, telemetryRuntimeProducer), broadcasterChannel, telemetryRuntimeProducer); + this(notificationParser, updateListener, new NotificationManagerKeeper(broadcasterChannel, telemetry), broadcasterChannel, telemetry); } @VisibleForTesting public SseHandler(@NonNull NotificationParser notificationParser, - @NonNull NotificationProcessor notificationProcessor, + @NonNull UpdateNotificationListener updateListener, @NonNull NotificationManagerKeeper managerKeeper, @NonNull PushManagerEventBroadcaster broadcasterChannel, - @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer) { + @NonNull StreamingTelemetry telemetry) { mNotificationParser = checkNotNull(notificationParser); - mNotificationProcessor = checkNotNull(notificationProcessor); + mUpdateListener = checkNotNull(updateListener); mBroadcasterChannel = checkNotNull(broadcasterChannel); mNotificationManagerKeeper = checkNotNull(managerKeeper); - mTelemetryRuntimeProducer = checkNotNull(telemetryRuntimeProducer); + mTelemetry = checkNotNull(telemetry); } public boolean isConnectionConfirmed(Map values) { @@ -88,7 +86,7 @@ public void handleIncomingMessage(Map values) { case MEMBERSHIPS_MS_UPDATE: case MEMBERSHIPS_LS_UPDATE: if (mNotificationManagerKeeper.isStreamingActive()) { - mNotificationProcessor.process(incomingNotification); + mUpdateListener.onUpdateNotification(incomingNotification); } break; default: @@ -100,13 +98,7 @@ public void handleIncomingMessage(Map values) { public void handleError(boolean retryable) { PushStatusEvent event = new PushStatusEvent(retryable ? EventType.PUSH_RETRYABLE_ERROR : EventType.PUSH_NON_RETRYABLE_ERROR); mBroadcasterChannel.pushMessage(event); - - mTelemetryRuntimeProducer.recordStreamingEvents( - new SseConnectionErrorStreamingEvent( - (retryable) ? SseConnectionErrorStreamingEvent.Status.REQUESTED : SseConnectionErrorStreamingEvent.Status.NON_REQUESTED, - System.currentTimeMillis() - ) - ); + mTelemetry.recordConnectionError(retryable, System.currentTimeMillis()); } public boolean isRetryableError(Map values) { @@ -162,7 +154,7 @@ private void handleError(String jsonData) { return; } - mTelemetryRuntimeProducer.recordStreamingEvents(new AblyErrorStreamingEvent(errorNotification.getCode(), System.currentTimeMillis())); + mTelemetry.recordAblyError(errorNotification.getCode(), System.currentTimeMillis()); PushStatusEvent message = new PushStatusEvent( errorNotification.isRetryable() ? EventType.PUSH_RETRYABLE_ERROR : EventType.PUSH_NON_RETRYABLE_ERROR); diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java similarity index 53% rename from main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java index 88980ccfe..5d5a0e935 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java @@ -4,43 +4,42 @@ import androidx.annotation.NonNull; -import io.split.android.client.service.executor.SplitTask; -import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskExecutionListener; -import io.split.android.client.service.executor.SplitTaskExecutor; -import io.split.android.client.service.executor.SplitTaskType; import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent; import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent.EventType; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; import io.split.android.client.utils.logger.Logger; -public class SseRefreshTokenTimer implements SplitTaskExecutionListener { +public class SseRefreshTokenTimer { private final static int RECONNECT_TIME_BEFORE_TOKEN_EXP_IN_SECONDS = 600; - SplitTaskExecutor mTaskExecutor; - PushManagerEventBroadcaster mBroadcasterChannel; - String mTaskId; + private final StreamingScheduler mScheduler; + private final PushManagerEventBroadcaster mBroadcasterChannel; + private String mTaskId; - public SseRefreshTokenTimer(@NonNull SplitTaskExecutor taskExecutor, @NonNull PushManagerEventBroadcaster broadcasterChannel) { - mTaskExecutor = checkNotNull(taskExecutor); + public SseRefreshTokenTimer(@NonNull StreamingScheduler scheduler, @NonNull PushManagerEventBroadcaster broadcasterChannel) { + mScheduler = checkNotNull(scheduler); mBroadcasterChannel = checkNotNull(broadcasterChannel); } public void cancel() { - mTaskExecutor.stopTask(mTaskId); + mScheduler.cancel(mTaskId); } public void schedule(long issueAtTime, long expirationTime) { cancel(); long reconnectTime = reconnectTime(issueAtTime, expirationTime); - mTaskId = mTaskExecutor.schedule(new SplitTask() { - @NonNull + mTaskId = mScheduler.schedule(new Runnable() { @Override - public SplitTaskExecutionInfo execute() { + public void run() { Logger.d("Informing sse token expired through pushing retryable error."); mBroadcasterChannel.pushMessage(new PushStatusEvent(EventType.PUSH_RETRYABLE_ERROR)); - return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); } - }, reconnectTime, null); + }, reconnectTime, new StreamingScheduler.TaskExecutionListener() { + @Override + public void onTaskExecuted() { + mTaskId = null; + } + }); } private long reconnectTime(long issuedAtTime, long expirationTime) { @@ -48,8 +47,4 @@ private long reconnectTime(long issuedAtTime, long expirationTime) { , 0L); } - @Override - public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { - mTaskId = null; - } } diff --git a/streaming/src/main/java/io/split/android/client/utils/Base64Util.java b/streaming/src/main/java/io/split/android/client/utils/Base64Util.java new file mode 100644 index 000000000..6bcbe2cee --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/Base64Util.java @@ -0,0 +1,47 @@ +package io.split.android.client.utils; + +import android.util.Base64; + +import androidx.annotation.Nullable; + +import io.split.android.client.utils.logger.Logger; + +public class Base64Util { + @Nullable + public static String decode(String string) { + byte[] bytes = bytesDecode(string); + if (bytes != null) { + return StringHelper.stringFromBytes(bytes); + } + return null; + } + + @Nullable + public static byte[] bytesDecode(String string) { + try { + return Base64.decode(string, Base64.DEFAULT); + } catch (IllegalArgumentException e) { + Logger.e("Received bytes didn't correspond to a valid Base64 encoded string." + e.getLocalizedMessage()); + } catch (Exception e) { + Logger.e("An unknown error has occurred " + e.getLocalizedMessage()); + } + return null; + } + + @Nullable + public static String encode(String string) { + try { + byte[] bytes = Base64.encode(string.getBytes(StringHelper.defaultCharset()), Base64.DEFAULT); + return StringHelper.stringFromBytes(bytes); + } catch (IllegalArgumentException e) { + Logger.e("Received bytes didn't correspond to a valid Base64 encoded string." + e.getLocalizedMessage()); + } catch (Exception e) { + Logger.e("An unknown error has occurred " + e.getLocalizedMessage()); + } + return null; + } + + public static String encode(byte[] bytes) { + return Base64.encodeToString(bytes, Base64.DEFAULT); + } +} diff --git a/streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java b/streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java new file mode 100644 index 000000000..e5e67de9b --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java @@ -0,0 +1,5 @@ +package io.split.android.client.utils; + +public interface CompressionUtil { + byte[] decompress(byte[] compressed); +} diff --git a/streaming/src/main/java/io/split/android/client/utils/Gzip.java b/streaming/src/main/java/io/split/android/client/utils/Gzip.java new file mode 100644 index 000000000..85f29237a --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/Gzip.java @@ -0,0 +1,49 @@ +package io.split.android.client.utils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.util.zip.GZIPInputStream; + +import io.split.android.client.service.sseclient.StreamingConstants; +import io.split.android.client.utils.logger.Logger; + +public class Gzip implements CompressionUtil { + + @Override + public byte[] decompress(byte[] input) { + if (input == null || input.length == 0) { + return null; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayInputStream in = new ByteArrayInputStream(input); + GZIPInputStream gzipIn = null; + try { + gzipIn = new GZIPInputStream(in); + byte[] buffer = new byte[StreamingConstants.SEGMENT_DATA_BUFFER_SIZE]; + int byteCount; + while ((byteCount = gzipIn.read(buffer)) >= 0) { + out.write(buffer, 0, byteCount); + } + return out.toByteArray(); + } catch (IOException e) { + Logger.e("Gzip format error: " + e.getLocalizedMessage()); + } catch (Exception e) { + Logger.e("Error decompressing gzip: " + e.getLocalizedMessage()); + } finally { + close(out); + close(gzipIn); + close(in); + } + return null; + } + + void close(Closeable component) { + try { + component.close(); + } catch (Exception e) { + Logger.e("Gzip error closing component: " + e.getLocalizedMessage()); + } + } +} diff --git a/streaming/src/main/java/io/split/android/client/utils/Json.java b/streaming/src/main/java/io/split/android/client/utils/Json.java new file mode 100644 index 000000000..b9275d5c8 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/Json.java @@ -0,0 +1,29 @@ +package io.split.android.client.utils; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; + +import java.lang.reflect.Type; + +/** + * JSON utility class for the streaming module. + */ +public class Json { + + private static final Gson mJson = new GsonBuilder() + .serializeNulls() + .create(); + + public static String toJson(Object obj) { + return mJson.toJson(obj); + } + + public static T fromJson(String json, Type typeOfT) throws JsonSyntaxException { + return mJson.fromJson(json, typeOfT); + } + + public static T fromJson(String json, Class clz) throws JsonSyntaxException { + return mJson.fromJson(json, clz); + } +} diff --git a/streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java b/streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java new file mode 100644 index 000000000..c966a31e1 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java @@ -0,0 +1,312 @@ +package io.split.android.client.utils; + +import java.math.BigInteger; + +/** + * The MurmurHash3 algorithm was created by Austin Appleby and placed in the public domain. + * This java port was authored by Yonik Seeley and also placed into the public domain. + * The author hereby disclaims copyright to this source code. + *

+ * This produces exactly the same hash values as the final C++ + * version of MurmurHash3 and is thus suitable for producing the same hash values across + * platforms. + *

+ * The 32 bit x86 version of this hash should be the fastest variant for relatively short keys like ids. + * murmurhash3_x64_128 is a good choice for longer strings or if you need more than 32 bits of hash. + *

+ * Note - The x86 and x64 versions do _not_ produce the same results, as the + * algorithms are optimized for their respective platforms. + *

+ * See http://github.com/yonik/java_util for future updates to this file. + */ +@SuppressWarnings("ALL") +public final class MurmurHash3 { + + /** + * 128 bits of state + */ + public static final class LongPair { + public long val1; + public long val2; + } + + public static int fmix32(int h) { + h ^= h >>> 16; + h *= 0x85ebca6b; + h ^= h >>> 13; + h *= 0xc2b2ae35; + h ^= h >>> 16; + return h; + } + + public static long fmix64(long k) { + k ^= k >>> 33; + k *= 0xff51afd7ed558ccdL; + k ^= k >>> 33; + k *= 0xc4ceb9fe1a85ec53L; + k ^= k >>> 33; + return k; + } + + /** + * Gets a long from a byte buffer in little endian byte order. + */ + public static long getLongLittleEndian(byte[] buf, int offset) { + return ((long) buf[offset + 7] << 56) // no mask needed + | ((buf[offset + 6] & 0xffL) << 48) + | ((buf[offset + 5] & 0xffL) << 40) + | ((buf[offset + 4] & 0xffL) << 32) + | ((buf[offset + 3] & 0xffL) << 24) + | ((buf[offset + 2] & 0xffL) << 16) + | ((buf[offset + 1] & 0xffL) << 8) + | ((buf[offset] & 0xffL)); // no shift needed + } + + + /** + * Returns the MurmurHash3_x86_32 hash of the UTF-8 bytes of the String without actually encoding + * the string to a temporary buffer. This is more than 2x faster than hashing the result + * of String.getBytes(). + */ + public static long murmurhash3_x86_32(CharSequence data, int offset, int len, int seed) { + + final int c1 = 0xcc9e2d51; + final int c2 = 0x1b873593; + + int h1 = seed; + + int pos = offset; + int end = offset + len; + int k1 = 0; + int k2 = 0; + int shift = 0; + int bits = 0; + int nBytes = 0; // length in UTF8 bytes + + + while (pos < end) { + int code = data.charAt(pos++); + if (code < 0x80) { + k2 = code; + bits = 8; + + } else if (code < 0x800) { + k2 = (0xC0 | (code >> 6)) + | ((0x80 | (code & 0x3F)) << 8); + bits = 16; + } else if (code < 0xD800 || code > 0xDFFF || pos >= end) { + // we check for pos>=end to encode an unpaired surrogate as 3 bytes. + k2 = (0xE0 | (code >> 12)) + | ((0x80 | ((code >> 6) & 0x3F)) << 8) + | ((0x80 | (code & 0x3F)) << 16); + bits = 24; + } else { + // surrogate pair + // int utf32 = pos < end ? (int) data.charAt(pos++) : 0; + int utf32 = (int) data.charAt(pos++); + utf32 = ((code - 0xD7C0) << 10) + (utf32 & 0x3FF); + k2 = (0xff & (0xF0 | (utf32 >> 18))) + | ((0x80 | ((utf32 >> 12) & 0x3F))) << 8 + | ((0x80 | ((utf32 >> 6) & 0x3F))) << 16 + | (0x80 | (utf32 & 0x3F)) << 24; + bits = 32; + } + + + k1 |= k2 << shift; + + // int used_bits = 32 - shift; // how many bits of k2 were used in k1. + // int unused_bits = bits - used_bits; // (bits-(32-shift)) == bits+shift-32 == bits-newshift + + shift += bits; + if (shift >= 32) { + // mix after we have a complete word + + k1 *= c1; + k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); + k1 *= c2; + + h1 ^= k1; + h1 = (h1 << 13) | (h1 >>> 19); // ROTL32(h1,13); + h1 = h1 * 5 + 0xe6546b64; + + shift -= 32; + // unfortunately, java won't let you shift 32 bits off, so we need to check for 0 + if (shift != 0) { + k1 = k2 >>> (bits - shift); // bits used == bits - newshift + } else { + k1 = 0; + } + nBytes += 4; + } + + } // inner + + // handle tail + if (shift > 0) { + nBytes += shift >> 3; + k1 *= c1; + k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); + k1 *= c2; + h1 ^= k1; + } + + // finalization + h1 ^= nBytes; + + // fmix(h1); + h1 ^= h1 >>> 16; + h1 *= 0x85ebca6b; + h1 ^= h1 >>> 13; + h1 *= 0xc2b2ae35; + h1 ^= h1 >>> 16; + + return h1 & 0xFFFFFFFFL; + } + + // The following set of methods and constants are borrowed from: + // `This method is borrowed from `org.apache.commons.codec.digest.MurmurHash3` + + // Constants for 128-bit variant + private static final long C1 = 0x87c37b91114253d5L; + private static final long C2 = 0x4cf5ad432745937fL; + private static final int R1 = 31; + private static final int R2 = 27; + private static final int R3 = 33; + private static final int M = 5; + private static final int N1 = 0x52dce729; + private static final int N2 = 0x38495ab5; + + /** + * Gets the little-endian long from 8 bytes starting at the specified index. + * + * @param data The data + * @param index The index + * @return The little-endian long + */ + private static long getLittleEndianLong(final byte[] data, final int index) { + return (((long) data[index ] & 0xff) ) | + (((long) data[index + 1] & 0xff) << 8) | + (((long) data[index + 2] & 0xff) << 16) | + (((long) data[index + 3] & 0xff) << 24) | + (((long) data[index + 4] & 0xff) << 32) | + (((long) data[index + 5] & 0xff) << 40) | + (((long) data[index + 6] & 0xff) << 48) | + (((long) data[index + 7] & 0xff) << 56); + } + + public static BigInteger[] unsignedHash128x64(final byte[] data) { + long[] hash = MurmurHash3.hash128x64(data); + BigInteger b0 = new BigInteger(Long.toBinaryString(hash[0]), 2); + BigInteger b1 = new BigInteger(Long.toBinaryString(hash[1]), 2); + return new BigInteger[] { b0, b1 }; + } + + public static long[] hash128x64(final byte[] data) { + return hash128x64(data, 0, data.length, 0); + } + + /** + * Generates 128-bit hash from the byte array with the given offset, length and seed. + * + *

This is an implementation of the 128-bit hash function {@code MurmurHash3_x64_128} + * from from Austin Applyby's original MurmurHash3 {@code c++} code in SMHasher.

+ * + * @param data The input byte array + * @param offset The first element of array + * @param length The length of array + * @param seed The initial seed value + * @return The 128-bit hash (2 longs) + */ + public static long[] hash128x64(final byte[] data, final int offset, final int length, final long seed) { + long h1 = seed; + long h2 = seed; + final int nblocks = length >> 4; + + // body + for (int i = 0; i < nblocks; i++) { + final int index = offset + (i << 4); + long k1 = getLittleEndianLong(data, index); + long k2 = getLittleEndianLong(data, index + 8); + + // mix functions for k1 + k1 *= C1; + k1 = Long.rotateLeft(k1, R1); + k1 *= C2; + h1 ^= k1; + h1 = Long.rotateLeft(h1, R2); + h1 += h2; + h1 = h1 * M + N1; + + // mix functions for k2 + k2 *= C2; + k2 = Long.rotateLeft(k2, R3); + k2 *= C1; + h2 ^= k2; + h2 = Long.rotateLeft(h2, R1); + h2 += h1; + h2 = h2 * M + N2; + } + + // tail + long k1 = 0; + long k2 = 0; + final int index = offset + (nblocks << 4); + switch (offset + length - index) { + case 15: + k2 ^= ((long) data[index + 14] & 0xff) << 48; + case 14: + k2 ^= ((long) data[index + 13] & 0xff) << 40; + case 13: + k2 ^= ((long) data[index + 12] & 0xff) << 32; + case 12: + k2 ^= ((long) data[index + 11] & 0xff) << 24; + case 11: + k2 ^= ((long) data[index + 10] & 0xff) << 16; + case 10: + k2 ^= ((long) data[index + 9] & 0xff) << 8; + case 9: + k2 ^= data[index + 8] & 0xff; + k2 *= C2; + k2 = Long.rotateLeft(k2, R3); + k2 *= C1; + h2 ^= k2; + + case 8: + k1 ^= ((long) data[index + 7] & 0xff) << 56; + case 7: + k1 ^= ((long) data[index + 6] & 0xff) << 48; + case 6: + k1 ^= ((long) data[index + 5] & 0xff) << 40; + case 5: + k1 ^= ((long) data[index + 4] & 0xff) << 32; + case 4: + k1 ^= ((long) data[index + 3] & 0xff) << 24; + case 3: + k1 ^= ((long) data[index + 2] & 0xff) << 16; + case 2: + k1 ^= ((long) data[index + 1] & 0xff) << 8; + case 1: + k1 ^= data[index] & 0xff; + k1 *= C1; + k1 = Long.rotateLeft(k1, R1); + k1 *= C2; + h1 ^= k1; + } + + // finalization + h1 ^= length; + h2 ^= length; + + h1 += h2; + h2 += h1; + + h1 = fmix64(h1); + h2 = fmix64(h2); + + h1 += h2; + h2 += h1; + + return new long[] { h1, h2 }; + } +} \ No newline at end of file diff --git a/streaming/src/main/java/io/split/android/client/utils/StringHelper.java b/streaming/src/main/java/io/split/android/client/utils/StringHelper.java new file mode 100644 index 000000000..5fbfc39f7 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/StringHelper.java @@ -0,0 +1,54 @@ +package io.split.android.client.utils; + +import java.nio.charset.Charset; +import java.util.Iterator; +import java.util.List; + +import io.split.android.client.utils.logger.Logger; + + +public class StringHelper { + public static Charset defaultCharset() { + Charset charset = Charset.defaultCharset(); + try { + charset = Charset.forName("UTF-8"); + } catch (Exception e) { + Logger.e("UTF-8 charset not available"); + } + return charset; + } + + public static String stringFromBytes(byte[] bytes) { + return new String(bytes, 0, bytes.length, StringHelper.defaultCharset()); + } + + public String join(String connector, List list) { + if(list == null || list.size() == 0 || connector == null) { + return ""; + } + StringBuilder string = new StringBuilder(list.get(0)); + for(int i=1; i values) { + if(values == null || connector == null) { + return ""; + } + + Iterator iterator = values.iterator(); + if(!iterator.hasNext()) { + return ""; + } + + StringBuilder string = new StringBuilder(iterator.next().toString()); + while (iterator.hasNext()) { + string.append(connector).append(iterator.next().toString()); + } + + return string.toString(); + } + +} diff --git a/streaming/src/main/java/io/split/android/client/utils/Utils.java b/streaming/src/main/java/io/split/android/client/utils/Utils.java new file mode 100644 index 000000000..9190317c4 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/Utils.java @@ -0,0 +1,36 @@ +package io.split.android.client.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +/** + * Utility methods for the streaming module. + */ +public class Utils { + + private Utils() { + // Utility class + } + + public static T checkNotNull(T obj) { + return Objects.requireNonNull(obj); + } + + public static T checkNotNull(@Nullable T reference, @Nullable Object errorMessage) { + if (reference == null) { + throw new NullPointerException(String.valueOf(errorMessage)); + } + return reference; + } + + public static boolean isNullOrEmpty(@Nullable String string) { + return string == null || string.isEmpty(); + } + + @NonNull + public static T getOrDefault(@Nullable T value, @NonNull T defaultValue) { + return value != null ? value : defaultValue; + } +} diff --git a/streaming/src/main/java/io/split/android/client/utils/Zlib.java b/streaming/src/main/java/io/split/android/client/utils/Zlib.java new file mode 100644 index 000000000..28461b025 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/utils/Zlib.java @@ -0,0 +1,30 @@ +package io.split.android.client.utils; + +import java.util.Arrays; +import java.util.zip.Inflater; + +import io.split.android.client.service.sseclient.StreamingConstants; +import io.split.android.client.utils.logger.Logger; + +public class Zlib implements CompressionUtil { + + @Override + public byte[] decompress(byte[] input) { + if (input == null || input.length == 0) { + return null; + } + try { + Inflater inflater = new Inflater(); + inflater.setInput(input); + byte[] result = new byte[StreamingConstants.SEGMENT_DATA_BUFFER_SIZE]; + int resultLength = inflater.inflate(result); + inflater.end(); + return Arrays.copyOfRange(result, 0, resultLength); + } catch (java.util.zip.DataFormatException e) { + Logger.e("DataFormatException error: " + e.getLocalizedMessage()); + } catch (Exception e) { + Logger.e("Error decompressing: " + e.getLocalizedMessage()); + } + return null; + } +} From 10fc97205436cedd90116f8718fb7e0aea1bf925 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 8 Feb 2026 22:31:25 -0300 Subject: [PATCH 11/23] Streaming without auth --- main/build.gradle | 2 +- .../android/client/SplitFactoryHelper.java | 10 +- .../sseclient/InvalidJwtTokenException.java | 0 .../service/sseclient/SseAuthToken.java | 0 .../sseclient/SseAuthenticationResponse.java | 0 .../service/sseclient/SseJwtParser.java | 0 .../client/service/sseclient/SseJwtToken.java | 0 .../service/sseclient/StreamingConstants.java | 0 .../BroadcastedEventListener.java | 0 .../feedbackchannel/DelayStatusEvent.java | 0 .../PushManagerEventBroadcaster.java | 0 .../feedbackchannel/PushStatusEvent.java | 0 .../notifications/ControlNotification.java | 0 .../notifications/HashingAlgorithm.java | 0 .../notifications/IncomingNotification.java | 0 .../IncomingNotificationType.java | 0 .../InstantUpdateChangeNotification.java | 0 .../sseclient/notifications/KeyList.java | 0 .../notifications/MembershipNotification.java | 0 .../MySegmentUpdateStrategy.java | 0 .../notifications/NotificationParser.java | 0 .../notifications/NotificationType.java | 0 .../notifications/OccupancyNotification.java | 0 .../notifications/RawNotification.java | 0 .../RuleBasedSegmentChangeNotification.java | 0 .../notifications/SplitKillNotification.java | 0 .../SplitsChangeNotification.java | 0 .../notifications/StreamingError.java | 0 .../sseclient/spi/StreamingAuthException.java | 0 .../sseclient/spi/StreamingAuthFetcher.java | 0 .../sseclient/spi/StreamingScheduler.java | 0 .../sseclient/spi/StreamingTelemetry.java | 0 .../spi/UpdateNotificationListener.java | 0 .../sseclient/sseclient/DefaultSseClient.java | 113 ++++++ .../sseclient/NotificationManagerKeeper.java | 0 .../sseclient/PushNotificationManager.java | 2 +- .../SplitTaskExecutorStreamingScheduler.java | 3 +- .../sseclient/sseclient/SseAuthenticator.java | 0 .../sseclient/sseclient/SseClient.java | 0 .../sseclient/SseDisconnectionTimer.java | 0 .../sseclient/sseclient/SseHandler.java | 0 .../sseclient/SseRefreshTokenTimer.java | 0 .../BackgroundDisconnectionTaskTest.java | 14 +- .../NotificationManagerKeeperTest.java | 52 +-- .../PushNotificationManagerTest.java | 16 +- .../sseclient/SseAuthenticatorTest.java | 57 +-- .../service/sseclient/SseClientTest.java | 360 ++++++------------ .../service/sseclient/SseHandlerTest.java | 71 +--- .../sseclient/SseDisconnectionTimerTest.java | 43 +-- streaming/build.gradle | 3 +- .../client/common/CompressionType.java | 9 - .../common/CompressionUtilProvider.java | 43 --- .../android/client/network/URIBuilder.java | 85 ----- .../sseclient/EventSourceClient.java | 73 ++++ ...ntImpl.java => EventSourceClientImpl.java} | 132 +++---- .../android/client/utils/Base64Util.java | 47 --- .../android/client/utils/CompressionUtil.java | 5 - .../io/split/android/client/utils/Gzip.java | 49 --- .../io/split/android/client/utils/Json.java | 29 -- .../android/client/utils/MurmurHash3.java | 312 --------------- .../android/client/utils/StringHelper.java | 54 --- .../io/split/android/client/utils/Zlib.java | 30 -- 62 files changed, 455 insertions(+), 1159 deletions(-) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java (100%) create mode 100644 main/src/main/java/io/split/android/client/service/sseclient/sseclient/DefaultSseClient.java rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java (99%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java (100%) delete mode 100644 streaming/src/main/java/io/split/android/client/common/CompressionType.java delete mode 100644 streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java delete mode 100644 streaming/src/main/java/io/split/android/client/network/URIBuilder.java create mode 100644 streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClient.java rename streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/{SseClientImpl.java => EventSourceClientImpl.java} (50%) delete mode 100644 streaming/src/main/java/io/split/android/client/utils/Base64Util.java delete mode 100644 streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java delete mode 100644 streaming/src/main/java/io/split/android/client/utils/Gzip.java delete mode 100644 streaming/src/main/java/io/split/android/client/utils/Json.java delete mode 100644 streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java delete mode 100644 streaming/src/main/java/io/split/android/client/utils/StringHelper.java delete mode 100644 streaming/src/main/java/io/split/android/client/utils/Zlib.java diff --git a/main/build.gradle b/main/build.gradle index 2344a00c4..322088bac 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -56,7 +56,7 @@ dependencies { // Internal module dependencies implementation project(':http') implementation project(':events-domain') - api project(':streaming') + implementation project(':streaming') // External dependencies implementation libs.roomRuntime diff --git a/main/src/main/java/io/split/android/client/SplitFactoryHelper.java b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java index b8af0fe32..9c4720386 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryHelper.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java @@ -60,7 +60,8 @@ import io.split.android.client.service.sseclient.sseclient.SseAuthenticator; import io.split.android.client.service.sseclient.sseclient.SseClient; import io.split.android.client.service.sseclient.sseclient.HttpClientStreamingTransport; -import io.split.android.client.service.sseclient.sseclient.SseClientImpl; +import io.split.android.client.service.sseclient.sseclient.DefaultSseClient; +import io.split.android.client.service.sseclient.sseclient.EventSourceClientImpl; import io.split.android.client.service.sseclient.sseclient.SseHandler; import io.split.android.client.service.sseclient.sseclient.SseRefreshTokenTimer; import io.split.android.client.service.sseclient.sseclient.SplitTaskExecutorStreamingScheduler; @@ -325,9 +326,12 @@ public SseClient getSseClient(String streamingServiceUrlString, telemetry, pushManagerEventBroadcaster); - return new SseClientImpl(URI.create(streamingServiceUrlString), + EventSourceClientImpl eventSourceClient = new EventSourceClientImpl( new HttpClientStreamingTransport(httpClient), - new EventStreamParser(), + new EventStreamParser()); + + return new DefaultSseClient(URI.create(streamingServiceUrlString), + eventSourceClient, sseHandler); } diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java b/main/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java rename to main/src/main/java/io/split/android/client/service/sseclient/InvalidJwtTokenException.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java b/main/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java rename to main/src/main/java/io/split/android/client/service/sseclient/SseAuthToken.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java b/main/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java rename to main/src/main/java/io/split/android/client/service/sseclient/SseAuthenticationResponse.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java b/main/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java rename to main/src/main/java/io/split/android/client/service/sseclient/SseJwtParser.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java b/main/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java rename to main/src/main/java/io/split/android/client/service/sseclient/SseJwtToken.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java b/main/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java rename to main/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java b/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java rename to main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/BroadcastedEventListener.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java b/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java rename to main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/DelayStatusEvent.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java b/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java rename to main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushManagerEventBroadcaster.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java b/main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java rename to main/src/main/java/io/split/android/client/service/sseclient/feedbackchannel/PushStatusEvent.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/ControlNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/HashingAlgorithm.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/IncomingNotificationType.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/KeyList.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentUpdateStrategy.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationParser.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/NotificationType.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/OccupancyNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/RawNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/RuleBasedSegmentChangeNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitKillNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/SplitsChangeNotification.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java rename to main/src/main/java/io/split/android/client/service/sseclient/notifications/StreamingError.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java b/main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java rename to main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthException.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java b/main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java rename to main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingAuthFetcher.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java b/main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java rename to main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingScheduler.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java b/main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java rename to main/src/main/java/io/split/android/client/service/sseclient/spi/StreamingTelemetry.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java b/main/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java rename to main/src/main/java/io/split/android/client/service/sseclient/spi/UpdateNotificationListener.java diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/DefaultSseClient.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/DefaultSseClient.java new file mode 100644 index 000000000..105326282 --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/DefaultSseClient.java @@ -0,0 +1,113 @@ +package io.split.android.client.service.sseclient.sseclient; + +import static io.split.android.client.utils.Utils.checkNotNull; + +import androidx.annotation.NonNull; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +import io.split.android.client.network.URIBuilder; +import io.split.android.client.service.sseclient.EventStreamParser; +import io.split.android.client.service.sseclient.SseJwtToken; +import io.split.android.client.utils.StringHelper; +import io.split.android.client.utils.logger.Logger; + +/** + * Split-specific SSE client adapter. + *

+ * Builds the Split streaming URL from an {@link SseJwtToken} + * (channels, access token, version) and delegates the actual + * SSE connection to a generic {@link EventSourceClient}. + *

+ * Incoming SSE events are routed through {@link SseHandler} + * for Split notification processing. + */ +public class DefaultSseClient implements SseClient { + + private static final String PUSH_NOTIFICATION_CHANNELS_PARAM = "channel"; + private static final String PUSH_NOTIFICATION_TOKEN_PARAM = "accessToken"; + private static final String PUSH_NOTIFICATION_VERSION_PARAM = "v"; + private static final String PUSH_NOTIFICATION_VERSION_VALUE = "1.1"; + + private final URI mTargetUrl; + private final EventSourceClient mEventSourceClient; + private final SseHandler mSseHandler; + private final StringHelper mStringHelper; + + public DefaultSseClient(@NonNull URI uri, + @NonNull EventSourceClient eventSourceClient, + @NonNull SseHandler sseHandler) { + mTargetUrl = checkNotNull(uri); + mEventSourceClient = checkNotNull(eventSourceClient); + mSseHandler = checkNotNull(sseHandler); + mStringHelper = new StringHelper(); + } + + @Override + public int status() { + return mEventSourceClient.status(); + } + + @Override + public void disconnect() { + mEventSourceClient.disconnect(); + } + + @Override + public void connect(SseJwtToken token, ConnectionListener connectionListener) { + String channels = mStringHelper.join(",", token.getChannels()); + String rawToken = token.getRawJwt(); + + try { + URI url = new URIBuilder(mTargetUrl) + .addParameter(PUSH_NOTIFICATION_VERSION_PARAM, PUSH_NOTIFICATION_VERSION_VALUE) + .addParameter(PUSH_NOTIFICATION_CHANNELS_PARAM, channels) + .addParameter(PUSH_NOTIFICATION_TOKEN_PARAM, rawToken) + .build(); + + mEventSourceClient.connect(url, new EventSourceClient.EventHandler() { + private boolean isConnectionConfirmed = false; + + @Override + public void onOpen() { + Logger.d("Streaming connection opened"); + } + + @Override + public void onMessage(@NonNull Map event) { + if (!isConnectionConfirmed) { + boolean isKeepAlive = EventStreamParser.KEEP_ALIVE_EVENT.equals( + event.get(EventStreamParser.EVENT_FIELD)); + if (isKeepAlive || mSseHandler.isConnectionConfirmed(event)) { + Logger.d("Streaming connection success"); + isConnectionConfirmed = true; + connectionListener.onConnectionSuccess(); + } else { + Logger.d("Streaming error after connection"); + boolean retryable = mSseHandler.isRetryableError(event); + mSseHandler.handleError(retryable); + mEventSourceClient.disconnect(); + return; + } + } + + boolean isKeepAlive = EventStreamParser.KEEP_ALIVE_EVENT.equals( + event.get(EventStreamParser.EVENT_FIELD)); + if (!isKeepAlive) { + mSseHandler.handleIncomingMessage(event); + } + } + + @Override + public void onError(boolean retryable) { + mSseHandler.handleError(retryable); + } + }); + } catch (URISyntaxException e) { + Logger.e("An error has occurred while creating stream URL: " + e.getLocalizedMessage()); + mSseHandler.handleError(false); + } + } +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/NotificationManagerKeeper.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java similarity index 99% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java index f4b374f4e..9cb4a546e 100644 --- a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java @@ -211,7 +211,7 @@ public void run() { return; } - mSseClient.connect(token, new SseClientImpl.ConnectionListener() { + mSseClient.connect(token, new SseClient.ConnectionListener() { @Override public void onConnectionSuccess() { mBroadcasterChannel.pushMessage(new PushStatusEvent(EventType.PUSH_SUBSYSTEM_UP)); diff --git a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java index 404883669..55180f313 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java @@ -6,7 +6,6 @@ import io.split.android.client.service.executor.SplitTask; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutionListener; -import io.split.android.client.service.executor.SplitTaskExecutionStatus; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskType; import io.split.android.client.service.sseclient.spi.StreamingScheduler; @@ -33,7 +32,7 @@ public SplitTaskExecutionInfo execute() { task.run(); return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); } catch (Exception e) { - return SplitTaskExecutionInfo.error(SplitTaskType.GENERIC_TASK, SplitTaskExecutionStatus.ERROR, e.getMessage()); + return SplitTaskExecutionInfo.error(SplitTaskType.GENERIC_TASK); } } }, delaySeconds, new SplitTaskExecutionListener() { diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseAuthenticator.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClient.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimer.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseHandler.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java rename to main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimer.java diff --git a/main/src/test/java/io/split/android/client/service/sseclient/BackgroundDisconnectionTaskTest.java b/main/src/test/java/io/split/android/client/service/sseclient/BackgroundDisconnectionTaskTest.java index 8b5c7cf51..21fa8872e 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/BackgroundDisconnectionTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/BackgroundDisconnectionTaskTest.java @@ -1,15 +1,11 @@ package io.split.android.client.service.sseclient; -import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import org.junit.Before; import org.junit.Test; -import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskExecutionStatus; -import io.split.android.client.service.executor.SplitTaskType; import io.split.android.client.service.sseclient.sseclient.PushNotificationManager; import io.split.android.client.service.sseclient.sseclient.SseClient; import io.split.android.client.service.sseclient.sseclient.SseRefreshTokenTimer; @@ -29,17 +25,9 @@ public void setUp() { @Test public void executionDisconnectsClientAndCancelsTimer() { - mTask.execute(); + mTask.run(); verify(mSseClient).disconnect(); verify(mTimer).cancel(); } - - @Test - public void executionReturnsCorrectResult() { - SplitTaskExecutionInfo result = mTask.execute(); - - assertEquals(SplitTaskType.GENERIC_TASK, result.getTaskType()); - assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); - } } diff --git a/main/src/test/java/io/split/android/client/service/sseclient/NotificationManagerKeeperTest.java b/main/src/test/java/io/split/android/client/service/sseclient/NotificationManagerKeeperTest.java index 384afdda6..9adf74149 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/NotificationManagerKeeperTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/NotificationManagerKeeperTest.java @@ -4,6 +4,7 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; @@ -14,12 +15,7 @@ import io.split.android.client.service.sseclient.notifications.ControlNotification; import io.split.android.client.service.sseclient.notifications.OccupancyNotification; import io.split.android.client.service.sseclient.sseclient.NotificationManagerKeeper; -import io.split.android.client.telemetry.model.EventTypeEnum; -import io.split.android.client.telemetry.model.streaming.OccupancyPriStreamingEvent; -import io.split.android.client.telemetry.model.streaming.OccupancySecStreamingEvent; -import io.split.android.client.telemetry.model.streaming.StreamingEvent; -import io.split.android.client.telemetry.model.streaming.StreamingStatusStreamingEvent; -import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -47,7 +43,7 @@ public class NotificationManagerKeeperTest { OccupancyNotification.Metrics mMetrics; @Mock - TelemetryRuntimeProducer mTelemetryRuntimeProducer; + StreamingTelemetry mTelemetryRuntimeProducer; @Before @@ -215,47 +211,38 @@ public void incomingControlStreamingEnabledNoPublishers() { @Test public void pausedStreamingIsRecordedInTelemetry() { - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingStatusStreamingEvent.class); - when(mControlNotification.getControlType()).thenReturn(ControlNotification.ControlType.STREAMING_PAUSED); when(mControlNotification.getTimestamp()).thenReturn(20L); mManagerKeeper.handleControlNotification(mControlNotification); - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertEquals(StreamingStatusStreamingEvent.Status.PAUSED.getNumericValue(), argumentCaptor.getValue().getEventData().longValue()); - Assert.assertEquals(EventTypeEnum.STREAMING_STATUS.getNumericValue(), argumentCaptor.getValue().getEventType()); - Assert.assertTrue(argumentCaptor.getValue().getTimestamp() > 0); + verify(mTelemetryRuntimeProducer).recordStreamingStatus( + ArgumentMatchers.eq(StreamingTelemetry.StreamingStatus.PAUSED), + ArgumentMatchers.longThat(ts -> ts > 0)); } @Test public void enabledStreamingIsRecordedInTelemetry() { - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingStatusStreamingEvent.class); - when(mControlNotification.getControlType()).thenReturn(ControlNotification.ControlType.STREAMING_RESUMED); when(mControlNotification.getTimestamp()).thenReturn(20L); mManagerKeeper.handleControlNotification(mControlNotification); - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertEquals(StreamingStatusStreamingEvent.Status.ENABLED.getNumericValue(), argumentCaptor.getValue().getEventData().longValue()); - Assert.assertEquals(EventTypeEnum.STREAMING_STATUS.getNumericValue(), argumentCaptor.getValue().getEventType()); - Assert.assertTrue(argumentCaptor.getValue().getTimestamp() > 0); + verify(mTelemetryRuntimeProducer).recordStreamingStatus( + ArgumentMatchers.eq(StreamingTelemetry.StreamingStatus.ENABLED), + ArgumentMatchers.longThat(ts -> ts > 0)); } @Test public void disabledStreamingIsRecordedInTelemetry() { - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingStatusStreamingEvent.class); - when(mControlNotification.getControlType()).thenReturn(ControlNotification.ControlType.STREAMING_DISABLED); when(mControlNotification.getTimestamp()).thenReturn(20L); mManagerKeeper.handleControlNotification(mControlNotification); - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertEquals(StreamingStatusStreamingEvent.Status.DISABLED.getNumericValue(), argumentCaptor.getValue().getEventData().longValue()); - Assert.assertEquals(EventTypeEnum.STREAMING_STATUS.getNumericValue(), argumentCaptor.getValue().getEventType()); - Assert.assertTrue(argumentCaptor.getValue().getTimestamp() > 0); + verify(mTelemetryRuntimeProducer).recordStreamingStatus( + ArgumentMatchers.eq(StreamingTelemetry.StreamingStatus.DISABLED), + ArgumentMatchers.longThat(ts -> ts > 0)); } @Test @@ -267,10 +254,9 @@ public void occupancyPriIsRecordedInTelemetry() { when(mOccupancyNotification.isControlSecChannel()).thenReturn(false); mManagerKeeper.handleOccupancyNotification(mOccupancyNotification); - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingEvent.class); - - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertTrue(argumentCaptor.getValue() instanceof OccupancyPriStreamingEvent); + verify(mTelemetryRuntimeProducer).recordOccupancyPri( + ArgumentMatchers.eq(0), + ArgumentMatchers.longThat(ts -> ts > 0)); } @Test @@ -282,9 +268,9 @@ public void occupancySecIsRecordedInTelemetry() { when(mOccupancyNotification.isControlSecChannel()).thenReturn(true); mManagerKeeper.handleOccupancyNotification(mOccupancyNotification); - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingEvent.class); - - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertTrue(argumentCaptor.getValue() instanceof OccupancySecStreamingEvent); + // publishersCount() is the total across both channels: PRI(1) + SEC(0) = 1 + verify(mTelemetryRuntimeProducer).recordOccupancySec( + ArgumentMatchers.eq(1), + ArgumentMatchers.longThat(ts -> ts > 0)); } } diff --git a/main/src/test/java/io/split/android/client/service/sseclient/PushNotificationManagerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/PushNotificationManagerTest.java index 25d22fc2b..8c29a199f 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/PushNotificationManagerTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/PushNotificationManagerTest.java @@ -40,9 +40,7 @@ import io.split.android.client.service.sseclient.sseclient.SseClient; import io.split.android.client.service.sseclient.sseclient.SseDisconnectionTimer; import io.split.android.client.service.sseclient.sseclient.SseRefreshTokenTimer; -import io.split.android.client.telemetry.model.OperationType; -import io.split.android.client.telemetry.model.streaming.TokenRefreshStreamingEvent; -import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; import io.split.android.fake.SseClientMock; public class PushNotificationManagerTest { @@ -69,7 +67,7 @@ public class PushNotificationManagerTest { private SseAuthenticationResult mResult; @Mock - private TelemetryRuntimeProducer mTelemetryRuntimeProducer; + private StreamingTelemetry mTelemetryRuntimeProducer; PushNotificationManager mPushManager; @@ -218,8 +216,8 @@ public void successfulConnectionTracksTokenRefreshInTelemetry() throws Interrupt performSuccessfulConnection(); verify(mTelemetryRuntimeProducer).recordTokenRefreshes(); - verify(mTelemetryRuntimeProducer).recordSuccessfulSync(eq(OperationType.TOKEN), longThat(argument -> argument > 0)); - verify(mTelemetryRuntimeProducer).recordStreamingEvents(any(TokenRefreshStreamingEvent.class)); + verify(mTelemetryRuntimeProducer).recordTokenSuccessfulSync(longThat(argument -> argument > 0)); + verify(mTelemetryRuntimeProducer).recordTokenRefreshEvent(anyLong(), longThat(argument -> argument > 0)); } @Test @@ -236,7 +234,7 @@ public void connectErrorTracksAuthRejectionInTelemetry() throws InterruptedExcep mPushManager.start(); sseClient.mConnectLatch.await(2, TimeUnit.SECONDS); - verify(mTelemetryRuntimeProducer).recordAuthRejections(); + verify(mTelemetryRuntimeProducer, times(1)).recordAuthRejections(); } @Test @@ -254,7 +252,7 @@ public void connectErrorTracksSyncErrorInTelemetryWhenThereIsHttpStatus() throws mPushManager.start(); sseClient.mConnectLatch.await(2, TimeUnit.SECONDS); - verify(mTelemetryRuntimeProducer).recordSyncError(OperationType.TOKEN, 500); + verify(mTelemetryRuntimeProducer).recordTokenSyncError(500); } @Test @@ -262,7 +260,7 @@ public void authenticationLatencyIsTracked() throws InterruptedException { performSuccessfulConnection(); Thread.sleep(1000); - verify(mTelemetryRuntimeProducer).recordSyncLatency(eq(OperationType.TOKEN), anyLong()); + verify(mTelemetryRuntimeProducer).recordTokenSyncLatency(anyLong()); } @Test diff --git a/main/src/test/java/io/split/android/client/service/sseclient/SseAuthenticatorTest.java b/main/src/test/java/io/split/android/client/service/sseclient/SseAuthenticatorTest.java index c5eb0d8de..16ad7cc3c 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/SseAuthenticatorTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/SseAuthenticatorTest.java @@ -21,11 +21,12 @@ import java.util.Map; import java.util.Set; -import io.split.android.client.service.http.HttpFetcherException; -import io.split.android.client.service.http.HttpSseAuthTokenFetcher; +import io.split.android.client.service.sseclient.spi.StreamingAuthException; +import io.split.android.client.service.sseclient.spi.StreamingAuthFetcher; import io.split.android.client.service.sseclient.sseclient.SseAuthenticationResult; import io.split.android.client.service.sseclient.sseclient.SseAuthenticator; +@SuppressWarnings("unchecked") public class SseAuthenticatorTest { @Mock @@ -35,7 +36,7 @@ public class SseAuthenticatorTest { SseAuthenticationResponse mResponse; @Mock - HttpSseAuthTokenFetcher mFetcher; + StreamingAuthFetcher mFetcher; List mDummyChannels; @@ -46,14 +47,14 @@ public void setup() { } @Test - public void successfulRequest() throws InvalidJwtTokenException, HttpFetcherException { + public void successfulRequest() throws InvalidJwtTokenException, StreamingAuthException { SseJwtToken token = new SseJwtToken(100, 200, mDummyChannels, "the raw token"); when(mResponse.isStreamingEnabled()).thenReturn(true); when(mResponse.getToken()).thenReturn(""); when(mJwtParser.parse(anyString())).thenReturn(token); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); SseAuthenticationResult result = authenticator.authenticate(60L); @@ -67,13 +68,13 @@ public void successfulRequest() throws InvalidJwtTokenException, HttpFetcherExce } @Test - public void tokenParseError() throws InvalidJwtTokenException, HttpFetcherException { + public void tokenParseError() throws InvalidJwtTokenException, StreamingAuthException { when(mResponse.isStreamingEnabled()).thenReturn(true); when(mResponse.getToken()).thenReturn(""); when(mJwtParser.parse(anyString())).thenThrow(InvalidJwtTokenException.class); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); SseAuthenticationResult result = authenticator.authenticate(60L); @@ -84,12 +85,12 @@ public void tokenParseError() throws InvalidJwtTokenException, HttpFetcherExcept } @Test - public void recoverableError() throws HttpFetcherException { + public void recoverableError() throws StreamingAuthException { when(mResponse.isStreamingEnabled()).thenReturn(false); when(mResponse.getToken()).thenReturn(null); when(mResponse.isClientError()).thenReturn(false); - when(mFetcher.execute(any(), any())).thenThrow(HttpFetcherException.class); + when(mFetcher.execute(any())).thenThrow(StreamingAuthException.class); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); SseAuthenticationResult result = authenticator.authenticate(60L); @@ -101,12 +102,12 @@ public void recoverableError() throws HttpFetcherException { } @Test - public void nonRecoverableError() throws HttpFetcherException { + public void nonRecoverableError() throws StreamingAuthException { when(mResponse.isStreamingEnabled()).thenReturn(false); when(mResponse.getToken()).thenReturn(null); when(mResponse.isClientError()).thenReturn(true); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); SseAuthenticationResult result = authenticator.authenticate(60L); @@ -118,9 +119,9 @@ public void nonRecoverableError() throws HttpFetcherException { } @Test - public void registeredKeysAreUsedInFetcher() throws HttpFetcherException { + public void registeredKeysAreUsedInFetcher() throws StreamingAuthException { when(mResponse.isClientError()).thenReturn(false); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); authenticator.registerKey("user1"); @@ -133,13 +134,13 @@ public void registeredKeysAreUsedInFetcher() throws HttpFetcherException { authenticator.authenticate(60L); - verify(mFetcher).execute(map, null); + verify(mFetcher).execute(map); } @Test - public void unregisteredKeysAreNotUsedInFetcher() throws HttpFetcherException { + public void unregisteredKeysAreNotUsedInFetcher() throws StreamingAuthException { when(mResponse.isClientError()).thenReturn(false); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); authenticator.registerKey("user1"); @@ -154,13 +155,13 @@ public void unregisteredKeysAreNotUsedInFetcher() throws HttpFetcherException { authenticator.authenticate(60L); - verify(mFetcher).execute(map, null); + verify(mFetcher).execute(map); } @Test - public void flagsSpecIsUsedInFetcher() throws HttpFetcherException { + public void flagsSpecIsUsedInFetcher() throws StreamingAuthException { when(mResponse.isClientError()).thenReturn(false); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, "1.1"); @@ -170,13 +171,13 @@ public void flagsSpecIsUsedInFetcher() throws HttpFetcherException { List keys = new ArrayList<>(argument.keySet()); return keys.get(0).equals("s") && keys.get(1).equals("users"); - }), eq(null)); + })); } @Test - public void flagsSpecIsNotUsedInFetcherWhenNull() throws HttpFetcherException { + public void flagsSpecIsNotUsedInFetcherWhenNull() throws StreamingAuthException { when(mResponse.isClientError()).thenReturn(false); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); authenticator.authenticate(60L); @@ -184,13 +185,13 @@ public void flagsSpecIsNotUsedInFetcherWhenNull() throws HttpFetcherException { verify(mFetcher).execute(argThat(argument -> { List keys = new ArrayList<>(argument.keySet()); return keys.get(0).equals("users"); - }), eq(null)); + })); } @Test - public void flagsSpecIsNotUsedInFetcherWhenEmpty() throws HttpFetcherException { + public void flagsSpecIsNotUsedInFetcherWhenEmpty() throws StreamingAuthException { when(mResponse.isClientError()).thenReturn(false); - when(mFetcher.execute(any(), any())).thenReturn(mResponse); + when(mFetcher.execute(any())).thenReturn(mResponse); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, ""); authenticator.authenticate(60L); @@ -198,13 +199,13 @@ public void flagsSpecIsNotUsedInFetcherWhenEmpty() throws HttpFetcherException { verify(mFetcher).execute(argThat(argument -> { List keys = new ArrayList<>(argument.keySet()); return keys.get(0).equals("users"); - }), eq(null)); + })); } @Test - public void returnUnrecoverableErrorWhenHttpStatusIsInternalNonRetryable() throws HttpFetcherException { + public void returnUnrecoverableErrorWhenHttpStatusIsInternalNonRetryable() throws StreamingAuthException { - when(mFetcher.execute(any(), any())).thenThrow(new HttpFetcherException("path", "error", 9009)); + when(mFetcher.execute(any())).thenThrow(new StreamingAuthException("error", null, 9009)); SseAuthenticator authenticator = new SseAuthenticator(mFetcher, mJwtParser, null); SseAuthenticationResult result = authenticator.authenticate(60L); diff --git a/main/src/test/java/io/split/android/client/service/sseclient/SseClientTest.java b/main/src/test/java/io/split/android/client/service/sseclient/SseClientTest.java index eeb53f2e1..6646563a5 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/SseClientTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/SseClientTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -11,39 +12,24 @@ import org.junit.Before; import org.junit.Test; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.net.URI; import java.net.URISyntaxException; -import java.nio.charset.Charset; -import java.util.List; -import java.util.concurrent.BlockingQueue; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; -import io.split.android.client.network.HttpClient; -import io.split.android.client.network.HttpException; -import io.split.android.client.network.HttpStreamRequest; -import io.split.android.client.network.HttpStreamResponse; +import io.split.android.client.service.sseclient.sseclient.DefaultSseClient; +import io.split.android.client.service.sseclient.sseclient.EventSourceClient; import io.split.android.client.service.sseclient.sseclient.SseClient; -import io.split.android.client.service.sseclient.sseclient.SseClientImpl; import io.split.android.client.service.sseclient.sseclient.SseHandler; -import io.split.sharedtest.fake.HttpStreamResponseMock; public class SseClientTest { @Mock - HttpClient mHttpClient; - - @Mock - EventStreamParser mParser; + EventSourceClient mEventSourceClient; @Mock SseHandler mSseHandler; @@ -51,35 +37,34 @@ public class SseClientTest { @Mock SseJwtToken mJwt; - BlockingQueue mData; - SseClient mClient; URI mUri; @Before public void setup() throws URISyntaxException { - MockitoAnnotations.initMocks(this); + MockitoAnnotations.openMocks(this); mUri = new URI("http://api/sse"); - mClient = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - mData = new LinkedBlockingDeque(); + mClient = new DefaultSseClient(mUri, mEventSourceClient, mSseHandler); } @Test - public void onConnect() throws InterruptedException, HttpException, IOException { + public void onConnect() throws InterruptedException { CountDownLatch onOpenLatch = new CountDownLatch(1); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); + SseClient.ConnectionListener connListener = spy(new TestConnListener(onOpenLatch)); when(mSseHandler.isConnectionConfirmed(any())).thenReturn(true); - when(response.isSuccess()).thenReturn(true); - when(mParser.parseLineAndAppendValue(any(), any())).thenReturn(true); - when(response.getBufferedReader()).thenReturn(dummyData()); - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onOpen(); + Map event = new HashMap<>(); + event.put("data", "somedata"); + handler.onMessage(event); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); + + mClient.connect(mJwt, connListener); onOpenLatch.await(1000, TimeUnit.MILLISECONDS); @@ -87,77 +72,79 @@ public void onConnect() throws InterruptedException, HttpException, IOException } @Test - public void onConnectNotConfirmed() throws InterruptedException, HttpException, IOException { + public void onConnectNotConfirmed() throws InterruptedException { CountDownLatch onOpenLatch = new CountDownLatch(1); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); + SseClient.ConnectionListener connListener = spy(new TestConnListener(onOpenLatch)); when(mSseHandler.isConnectionConfirmed(any())).thenReturn(false); - when(response.isSuccess()).thenReturn(true); - when(mParser.parseLineAndAppendValue(any(), any())).thenReturn(true); - when(response.getBufferedReader()).thenReturn(dummyData()); - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + when(mSseHandler.isRetryableError(any())).thenReturn(true); + + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onOpen(); + Map event = new HashMap<>(); + event.put("data", "error"); + handler.onMessage(event); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); + + mClient.connect(mJwt, connListener); onOpenLatch.await(1000, TimeUnit.MILLISECONDS); verify(connListener, never()).onConnectionSuccess(); + verify(mSseHandler, times(1)).handleError(true); + verify(mEventSourceClient, times(1)).disconnect(); } @Test - public void onMessage() throws InterruptedException, HttpException, IOException { + public void onMessage() throws InterruptedException { CountDownLatch onOpenLatch = new CountDownLatch(1); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); - + SseClient.ConnectionListener connListener = spy(new TestConnListener(onOpenLatch)); when(mSseHandler.isConnectionConfirmed(any())).thenReturn(true); - when(response.isSuccess()).thenReturn(true); - when(response.getBufferedReader()).thenReturn(dummyData()); - when(request.execute()).thenReturn(response); - - // Simulate message arrived - when(mParser.parseLineAndAppendValue(any(), any())).thenReturn(true).thenReturn(false); - when(mParser.isKeepAlive(any())).thenReturn(false); - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onOpen(); + // First message confirms connection + Map event1 = new HashMap<>(); + event1.put("data", "first"); + handler.onMessage(event1); + // Second message is a real notification + Map event2 = new HashMap<>(); + event2.put("data", "second"); + handler.onMessage(event2); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); + + mClient.connect(mJwt, connListener); onOpenLatch.await(1000, TimeUnit.MILLISECONDS); verify(connListener, times(1)).onConnectionSuccess(); - verify(mSseHandler, times(1)).handleIncomingMessage(any()); + // Both messages are routed to handleIncomingMessage + verify(mSseHandler, times(2)).handleIncomingMessage(any()); } @Test - public void onKeepAlive() throws InterruptedException, HttpException, IOException { + public void onKeepAlive() throws InterruptedException { CountDownLatch onOpenLatch = new CountDownLatch(1); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); - + SseClient.ConnectionListener connListener = spy(new TestConnListener(onOpenLatch)); when(mSseHandler.isConnectionConfirmed(any())).thenReturn(true); - when(response.isSuccess()).thenReturn(true); - when(response.getBufferedReader()).thenReturn(dummyData()); - when(request.execute()).thenReturn(response); - // Simulate message arrived - when(mParser.parseLineAndAppendValue(any(), any())).thenReturn(true).thenReturn(false); - when(mParser.isKeepAlive(any())).thenReturn(true); + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onOpen(); + // Keepalive event confirms connection but is not routed to handler + Map keepalive = new HashMap<>(); + keepalive.put("event", "keepalive"); + handler.onMessage(keepalive); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + mClient.connect(mJwt, connListener); onOpenLatch.await(1000, TimeUnit.MILLISECONDS); @@ -166,205 +153,94 @@ public void onKeepAlive() throws InterruptedException, HttpException, IOExceptio } @Test - public void clientError() throws InterruptedException, HttpException, IOException { - CountDownLatch onOpenLatch = new CountDownLatch(1); + public void clientError() { + SseClient.ConnectionListener connListener = spy(new TestConnListener(new CountDownLatch(1))); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); + // EventSourceClient reports non-retryable error + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onError(false); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); - - when(response.isSuccess()).thenReturn(false); - when(response.isClientRelatedError()).thenReturn(true); - when(response.getBufferedReader()).thenReturn(dummyData()); - when(request.execute()).thenReturn(response); - - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + mClient.connect(mJwt, connListener); verify(mSseHandler, times(1)).handleError(false); verify(mSseHandler, never()).handleIncomingMessage(any()); } @Test - public void ioException() throws InterruptedException, HttpException, IOException { - CountDownLatch onOpenLatch = new CountDownLatch(1); - - BufferedReader reader = Mockito.mock(BufferedReader.class); - when(reader.readLine()).thenThrow(IOException.class); - - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); - - when(response.isSuccess()).thenReturn(true); - when(response.getBufferedReader()).thenReturn(reader); - when(request.execute()).thenReturn(response); - - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); - - verify(mSseHandler, times(1)).handleError(true); - verify(mSseHandler, never()).handleIncomingMessage(any()); - } - - @Test - public void noClientError() throws InterruptedException, HttpException, IOException { - CountDownLatch onOpenLatch = new CountDownLatch(1); - - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - - HttpStreamResponse response = Mockito.mock(HttpStreamResponse.class); + public void ioException() { + SseClient.ConnectionListener connListener = spy(new TestConnListener(new CountDownLatch(1))); - when(response.isSuccess()).thenReturn(false); - when(response.isClientRelatedError()).thenReturn(false); - when(response.getBufferedReader()).thenReturn(dummyData()); - when(request.execute()).thenReturn(response); + // EventSourceClient reports retryable error (like IOException) + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onError(true); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + mClient.connect(mJwt, connListener); verify(mSseHandler, times(1)).handleError(true); verify(mSseHandler, never()).handleIncomingMessage(any()); } @Test - public void disconnect() throws InterruptedException, HttpException, IOException { + public void disconnect() throws InterruptedException { CountDownLatch onOpenLatch = new CountDownLatch(1); + SseClient.ConnectionListener connListener = spy(new TestConnListener(onOpenLatch)); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - - HttpStreamResponse response = new HttpStreamResponseMock(200, mData); - - when(request.execute()).thenReturn(response); + // EventSourceClient simulates long-lived connection + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onOpen(); + // Simulate blocking connection + Thread.sleep(2000); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); - when(request.execute()).thenReturn(response); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - new Thread(new Runnable() { - @Override - public void run() { - client.connect(mJwt, new TestConnListener(onOpenLatch) { - @Override - public void onConnectionSuccess() { - super.onConnectionSuccess(); - } - }); - } - }).start(); + new Thread(() -> mClient.connect(mJwt, connListener)).start(); Thread.sleep(500); - client.disconnect(); - verify(mSseHandler, never()).handleError(anyBoolean()); + mClient.disconnect(); + + verify(mEventSourceClient, times(1)).disconnect(); } @Test - public void nonRetryableErrorWhenRequestFailsWithHttpExceptionWith9009Code() throws HttpException, IOException { - CountDownLatch onOpenLatch = new CountDownLatch(1); - - BufferedReader reader = Mockito.mock(BufferedReader.class); + public void nonRetryableErrorOnConnection() { + SseClient.ConnectionListener connListener = spy(new TestConnListener(new CountDownLatch(1))); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - when(request.execute()).thenThrow(new HttpException("error", 9009)); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onError(false); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + mClient.connect(mJwt, connListener); verify(mSseHandler, times(1)).handleError(false); verify(mSseHandler, never()).handleIncomingMessage(any()); } @Test - public void retryableErrorWhenRequestFailsWithHttpExceptionWithNullCode() throws HttpException, IOException { - CountDownLatch onOpenLatch = new CountDownLatch(1); - - BufferedReader reader = Mockito.mock(BufferedReader.class); + public void retryableErrorOnConnection() { + SseClient.ConnectionListener connListener = spy(new TestConnListener(new CountDownLatch(1))); - TestConnListener connListener = spy(new TestConnListener(onOpenLatch)); - HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); - when(request.execute()).thenThrow(new HttpException("error")); - when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); + doAnswer(invocation -> { + EventSourceClient.EventHandler handler = invocation.getArgument(1); + handler.onError(true); + return null; + }).when(mEventSourceClient).connect(any(URI.class), any(EventSourceClient.EventHandler.class)); - SseClient client = new SseClientImpl(mUri, mHttpClient, mParser, mSseHandler); - client.connect(mJwt, connListener); + mClient.connect(mJwt, connListener); verify(mSseHandler, times(1)).handleError(true); verify(mSseHandler, never()).handleIncomingMessage(any()); } - private void setupJwt(List channels, long issuedAt, long expirationTime, String rawToken) { - when(mJwt.getChannels()).thenReturn(channels); - when(mJwt.getIssuedAtTime()).thenReturn(issuedAt); - when(mJwt.getExpirationTime()).thenReturn(expirationTime); - when(mJwt.getRawJwt()).thenReturn(rawToken); - } - -// @Test -// public void cancelScheduledDisconnectTimer() throws InterruptedException { -// mClient = new SseClient(mUri, mHttpClient, mParser, new ScheduledThreadPoolExecutor(POOL_SIZE)); -// mClient.scheduleDisconnection(50); -// sleep(1000); -// boolean result = mClient.cancelDisconnectionTimer(); -// Assert.assertTrue(result); -// } -// -// @Test -// public void failedCancelScheduledDisconnectTimer() throws InterruptedException { -// SseClient client = new SseClient(mUri, mHttpClient, mParser, new ScheduledThreadPoolExecutor(POOL_SIZE)); -// client.scheduleDisconnection(DUMMY_DELAY); -// sleep(DUMMY_DELAY + 2000); -// boolean result = client.cancelDisconnectionTimer(); -// Assert.assertFalse(result); -// } -// -// @Test -// public void disconnectTriggered() throws InterruptedException, HttpException, IOException { -// Listener listener = new Listener(); -// -// CountDownLatch onDisconnectLatch = new CountDownLatch(1); -// listener.mOnDisconnectLatch = onDisconnectLatch; -// listener = spy(listener); -// -// List dummyChannels = new ArrayList(); -// dummyChannels.add("dummychanel"); -// HttpStreamRequest request = Mockito.mock(HttpStreamRequest.class); -// HttpStreamResponse response = new HttpStreamResponseMock(200, mData); -// -// when(request.execute()).thenReturn(response); -// when(mHttpClient.streamRequest(any(URI.class))).thenReturn(request); -// SseClient client = new SseClient(mUri, mHttpClient, mParser, new ScheduledThreadPoolExecutor(POOL_SIZE)); -// client.setListener(listener); -// client.connect("pepetoken", dummyChannels); -// -// client = spy(client); -// client.scheduleDisconnection(DUMMY_DELAY); -// onDisconnectLatch.await(10, TimeUnit.SECONDS); -// long readyState = client.readyState(); -// -// verify(client, times(1)).disconnect(); -// verify(listener, never()).onError(anyBoolean()); -// verify(listener, times(1)).onDisconnect(); -// Assert.assertEquals(SseClient.CLOSED, readyState); -// } - - - private BufferedReader dummyData() { - InputStream inputStream = new ByteArrayInputStream("dummydata\n".getBytes(Charset.forName("UTF-8"))); - return new BufferedReader(new InputStreamReader(inputStream)); - } - - private static class TestConnListener implements SseClientImpl.ConnectionListener { + private static class TestConnListener implements SseClient.ConnectionListener { CountDownLatch mConnLatch; public TestConnListener(CountDownLatch connLatch) { @@ -376,6 +252,4 @@ public void onConnectionSuccess() { mConnLatch.countDown(); } } - - } diff --git a/main/src/test/java/io/split/android/client/service/sseclient/SseHandlerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/SseHandlerTest.java index 74ea21706..3aa0b9037 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/SseHandlerTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/SseHandlerTest.java @@ -1,8 +1,10 @@ package io.split.android.client.service.sseclient; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -25,19 +27,16 @@ import io.split.android.client.service.sseclient.notifications.IncomingNotification; import io.split.android.client.service.sseclient.notifications.MembershipNotification; import io.split.android.client.service.sseclient.notifications.NotificationParser; -import io.split.android.client.service.sseclient.notifications.NotificationProcessor; import io.split.android.client.service.sseclient.notifications.NotificationType; import io.split.android.client.service.sseclient.notifications.OccupancyNotification; import io.split.android.client.service.sseclient.notifications.RuleBasedSegmentChangeNotification; import io.split.android.client.service.sseclient.notifications.SplitKillNotification; import io.split.android.client.service.sseclient.notifications.SplitsChangeNotification; import io.split.android.client.service.sseclient.notifications.StreamingError; +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; +import io.split.android.client.service.sseclient.spi.UpdateNotificationListener; import io.split.android.client.service.sseclient.sseclient.NotificationManagerKeeper; import io.split.android.client.service.sseclient.sseclient.SseHandler; -import io.split.android.client.telemetry.model.streaming.AblyErrorStreamingEvent; -import io.split.android.client.telemetry.model.streaming.SseConnectionErrorStreamingEvent; -import io.split.android.client.telemetry.model.streaming.StreamingEvent; -import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; public class SseHandlerTest { @@ -54,97 +53,81 @@ public class SseHandlerTest { PushManagerEventBroadcaster mBroadcasterChannel; @Mock - NotificationProcessor mNotificationProcessor; + UpdateNotificationListener mUpdateListener; @Mock - TelemetryRuntimeProducer mTelemetryRuntimeProducer; + StreamingTelemetry mTelemetryRuntimeProducer; @Before public void setup() { MockitoAnnotations.openMocks(this); - mSseHandler = new SseHandler(mNotificationParser, mNotificationProcessor, mManagerKeeper, mBroadcasterChannel, mTelemetryRuntimeProducer); + mSseHandler = new SseHandler(mNotificationParser, mUpdateListener, mManagerKeeper, mBroadcasterChannel, mTelemetryRuntimeProducer); when(mNotificationParser.isError(any())).thenReturn(false); } @Test public void incomingSplitUpdate() { - - IncomingNotification incomingNotification = new IncomingNotification(NotificationType.SPLIT_UPDATE, "", "", 100); - SplitsChangeNotification notification = new SplitsChangeNotification(-1); when(mNotificationParser.parseIncoming(anyString())).thenReturn(incomingNotification); - when(mNotificationParser.parseSplitUpdate(anyString())).thenReturn(notification); when(mManagerKeeper.isStreamingActive()).thenReturn(true); mSseHandler.handleIncomingMessage(buildMessage("{}")); - verify(mNotificationProcessor).process(incomingNotification); + verify(mUpdateListener).onUpdateNotification(incomingNotification); } @Test public void incomingSplitKill() { - IncomingNotification incomingNotification = new IncomingNotification(NotificationType.SPLIT_KILL, "", "", 100); - SplitKillNotification notification = new SplitKillNotification(); when(mNotificationParser.parseIncoming(anyString())).thenReturn(incomingNotification); - when(mNotificationParser.parseSplitKill(anyString())).thenReturn(notification); when(mManagerKeeper.isStreamingActive()).thenReturn(true); mSseHandler.handleIncomingMessage(buildMessage("{}")); - verify(mNotificationProcessor).process(incomingNotification); + verify(mUpdateListener).onUpdateNotification(incomingNotification); } @Test public void incomingMembershipUpdate() { - IncomingNotification incomingNotification = new IncomingNotification(NotificationType.MEMBERSHIPS_MS_UPDATE, "", "", 100); - MembershipNotification notification = new MembershipNotification(); when(mNotificationParser.parseIncoming(anyString())).thenReturn(incomingNotification); - when(mNotificationParser.parseMembershipNotification(anyString())).thenReturn(notification); when(mManagerKeeper.isStreamingActive()).thenReturn(true); mSseHandler.handleIncomingMessage(buildMessage("{}")); - verify(mNotificationProcessor).process(incomingNotification); + verify(mUpdateListener).onUpdateNotification(incomingNotification); } @Test public void incomingLargeMembershipUpdate() { - IncomingNotification incomingNotification = new IncomingNotification(NotificationType.MEMBERSHIPS_LS_UPDATE, "", "", 100); - MembershipNotification notification = new MembershipNotification(); when(mNotificationParser.parseIncoming(anyString())).thenReturn(incomingNotification); - when(mNotificationParser.parseMembershipNotification(anyString())).thenReturn(notification); when(mManagerKeeper.isStreamingActive()).thenReturn(true); mSseHandler.handleIncomingMessage(buildMessage("{}")); - verify(mNotificationProcessor).process(incomingNotification); + verify(mUpdateListener).onUpdateNotification(incomingNotification); } @Test public void streamingPaused() { - IncomingNotification incomingNotification = new IncomingNotification(NotificationType.MEMBERSHIPS_LS_UPDATE, "", "", 100); - MembershipNotification notification = new MembershipNotification(); when(mNotificationParser.parseIncoming(anyString())).thenReturn(incomingNotification); - when(mNotificationParser.parseMembershipNotification(anyString())).thenReturn(notification); when(mManagerKeeper.isStreamingActive()).thenReturn(false); mSseHandler.handleIncomingMessage(buildMessage("{}")); - verify(mNotificationProcessor, never()).process(incomingNotification); + verify(mUpdateListener, never()).onUpdateNotification(incomingNotification); } @Test @@ -186,7 +169,6 @@ public void incomingHighRetryableSseError() { } public void incomingRetryableSseErrorTest(int code) { - StreamingError notification = new StreamingError("msg", code, code); when(mNotificationParser.isError(any())).thenReturn(true); @@ -240,55 +222,34 @@ public void ablyErrorIsRecordedInTelemetry() { mSseHandler.handleIncomingMessage(buildMessage("{}")); - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingEvent.class); - - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertTrue(argumentCaptor.getValue() instanceof AblyErrorStreamingEvent); - Assert.assertEquals(40000, argumentCaptor.getValue().getEventData().longValue()); - Assert.assertTrue(argumentCaptor.getValue().getTimestamp() > 0); + verify(mTelemetryRuntimeProducer).recordAblyError(eq(40000), anyLong()); } @Test public void sseRecoverableConnectionErrorIsRecordedInTelemetry() { - setupNotification(); - mSseHandler.handleError(false); - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingEvent.class); - - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertTrue(argumentCaptor.getValue() instanceof SseConnectionErrorStreamingEvent); - Assert.assertEquals(SseConnectionErrorStreamingEvent.Status.NON_REQUESTED.getNumericValue(), argumentCaptor.getValue().getEventData().longValue()); - Assert.assertTrue(argumentCaptor.getValue().getTimestamp() > 0); + verify(mTelemetryRuntimeProducer).recordConnectionError(eq(false), anyLong()); } @Test public void sseNonRecoverableConnectionErrorIsRecordedInTelemetry() { - setupNotification(); - mSseHandler.handleError(true); - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(StreamingEvent.class); - - verify(mTelemetryRuntimeProducer).recordStreamingEvents(argumentCaptor.capture()); - Assert.assertTrue(argumentCaptor.getValue() instanceof SseConnectionErrorStreamingEvent); - Assert.assertEquals(SseConnectionErrorStreamingEvent.Status.REQUESTED.getNumericValue(), argumentCaptor.getValue().getEventData().longValue()); - Assert.assertTrue(argumentCaptor.getValue().getTimestamp() > 0); + verify(mTelemetryRuntimeProducer).recordConnectionError(eq(true), anyLong()); } @Test public void incomingRuleBasedSegmentChange() { IncomingNotification incomingNotification = new IncomingNotification(NotificationType.RULE_BASED_SEGMENT_UPDATE, "", "", 100); - RuleBasedSegmentChangeNotification notification = new RuleBasedSegmentChangeNotification(-1); when(mNotificationParser.parseIncoming(anyString())).thenReturn(incomingNotification); - when(mNotificationParser.parseRuleBasedSegmentUpdate(anyString())).thenReturn(notification); when(mManagerKeeper.isStreamingActive()).thenReturn(true); mSseHandler.handleIncomingMessage(buildMessage("{}")); - verify(mNotificationProcessor).process(incomingNotification); + verify(mUpdateListener).onUpdateNotification(incomingNotification); } private void setupNotification() { diff --git a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimerTest.java index 8254dd202..8bed56eeb 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimerTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseDisconnectionTimerTest.java @@ -1,8 +1,9 @@ package io.split.android.client.service.sseclient.sseclient; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.eq; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -11,55 +12,53 @@ import org.junit.Before; import org.junit.Test; -import io.split.android.client.SplitClientConfig; -import io.split.android.client.service.executor.SplitTask; -import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskExecutor; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; public class SseDisconnectionTimerTest { - private SplitTaskExecutor mTaskExecutor; - private SplitTask mTask; + private StreamingScheduler mScheduler; + private Runnable mTask; private SseDisconnectionTimer mSseDisconnectionTimer; @Before public void setUp() { - mTaskExecutor = mock(SplitTaskExecutor.class); - mTask = mock(SplitTask.class); - when(mTask.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK)); - mSseDisconnectionTimer = new SseDisconnectionTimer(mTaskExecutor, 0); + mScheduler = mock(StreamingScheduler.class); + mTask = mock(Runnable.class); + mSseDisconnectionTimer = new SseDisconnectionTimer(mScheduler, 0); } @Test - public void cancelDoesNothingWhenTaskHasNotBeenScheduled() { + public void cancelCallsSchedulerCancelWithNull() { + // When no task has been scheduled, mTaskId is null mSseDisconnectionTimer.cancel(); - verify(mTaskExecutor, times(0)).stopTask(any()); + verify(mScheduler).cancel(isNull()); } @Test - public void scheduleSchedulesTaskInTaskExecutor() { + public void scheduleSchedulesTaskInScheduler() { mSseDisconnectionTimer.schedule(mTask); - verify(mTaskExecutor).schedule(eq(mTask), eq(0L), eq(mSseDisconnectionTimer)); + // schedule() internally calls cancel() first, then schedules the task + verify(mScheduler).schedule(eq(mTask), eq(0L), any()); } @Test public void cancelCancelsTaskWithCorrectTaskId() { - when(mTaskExecutor.schedule(eq(mTask), anyLong(), any())).thenReturn("id"); + when(mScheduler.schedule(eq(mTask), anyLong(), any())).thenReturn("task-id"); mSseDisconnectionTimer.schedule(mTask); mSseDisconnectionTimer.cancel(); - verify(mTaskExecutor).stopTask("id"); + // Second cancel call should use the task ID returned by schedule + verify(mScheduler).cancel("task-id"); } @Test - public void scheduleInitialDelayInSecondsDefaultValueIs60() { - mSseDisconnectionTimer = new SseDisconnectionTimer(mTaskExecutor, 60); + public void scheduleInitialDelayInSecondsUsesProvidedValue() { + mSseDisconnectionTimer = new SseDisconnectionTimer(mScheduler, 60); mSseDisconnectionTimer.schedule(mTask); - verify(mTaskExecutor).schedule(mTask, 60L, mSseDisconnectionTimer); + verify(mScheduler).schedule(eq(mTask), eq(60L), any()); } } diff --git a/streaming/build.gradle b/streaming/build.gradle index 670903666..990fdb1ae 100644 --- a/streaming/build.gradle +++ b/streaming/build.gradle @@ -16,8 +16,7 @@ android { dependencies { compileOnly libs.jetbrainsAnnotations implementation libs.annotation - implementation libs.gson - + // Logger module for logging api project(':logger') diff --git a/streaming/src/main/java/io/split/android/client/common/CompressionType.java b/streaming/src/main/java/io/split/android/client/common/CompressionType.java deleted file mode 100644 index 6d8bcf7f3..000000000 --- a/streaming/src/main/java/io/split/android/client/common/CompressionType.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.split.android.client.common; - -import com.google.gson.annotations.SerializedName; - -public enum CompressionType { - @SerializedName("0") NONE, - @SerializedName("1") GZIP, - @SerializedName("2") ZLIB -} diff --git a/streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java b/streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java deleted file mode 100644 index d6b8721c6..000000000 --- a/streaming/src/main/java/io/split/android/client/common/CompressionUtilProvider.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.split.android.client.common; - -import androidx.annotation.Nullable; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import io.split.android.client.utils.CompressionUtil; -import io.split.android.client.utils.Gzip; -import io.split.android.client.utils.logger.Logger; -import io.split.android.client.utils.Zlib; - -public class CompressionUtilProvider { - Map mCompressionUtils = new ConcurrentHashMap<>(); - - @Nullable - public CompressionUtil get(CompressionType type) { - CompressionUtil util = mCompressionUtils.get(type); - return (util != null ? util : create(type)); - } - - // Using a method instead of a factory to avoid - // a complex architecture. - @Nullable - private CompressionUtil create(CompressionType type) { - switch (type) { - case NONE: - return new CompressionUtil() { - @Override - public byte[] decompress(byte[] compressed) { - return compressed; - } - }; - case GZIP: - return new Gzip(); - case ZLIB: - return new Zlib(); - default: - Logger.d("Unavailable compression algorithm: " + type); - } - return null; - } -} diff --git a/streaming/src/main/java/io/split/android/client/network/URIBuilder.java b/streaming/src/main/java/io/split/android/client/network/URIBuilder.java deleted file mode 100644 index 9837ef614..000000000 --- a/streaming/src/main/java/io/split/android/client/network/URIBuilder.java +++ /dev/null @@ -1,85 +0,0 @@ -package io.split.android.client.network; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import androidx.annotation.NonNull; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.AbstractMap; -import java.util.LinkedHashSet; -import java.util.Set; - -import io.split.android.client.utils.Utils; - -public class URIBuilder { - private final URI mRootURI; - private final Set> mParams; - private String mPath; - private String mQueryString; - - public URIBuilder(@NonNull URI rootURI, String path) { - mRootURI = checkNotNull(rootURI); - String rootPath = mRootURI.getRawPath(); - if (path != null && rootPath != null) { - mPath = String.format("%s/%s", rootPath, path); - mPath = mPath.replace("///", "/"); - mPath = mPath.replace("//", "/"); - } else if (rootPath != null) { - mPath = rootPath; - mQueryString = rootURI.getQuery(); - } else { - mPath = path; - } - mParams = new LinkedHashSet<>(); - } - - public URIBuilder(@NonNull URI rootURI) { - this(rootURI, null); - } - - public URIBuilder addParameter(@NonNull String param, @NonNull String value) { - if (param != null && value != null) { - mParams.add(new AbstractMap.SimpleEntry<>(param, value)); - } - return this; - } - - public URIBuilder defaultQueryString(@NonNull String queryString) { - if (!Utils.isNullOrEmpty(queryString)) { - mQueryString = queryString; - } - return this; - } - - public URI build() throws URISyntaxException { - - String params = null; - if (mParams.size() > 0) { - StringBuilder query = new StringBuilder(); - for (AbstractMap.SimpleEntry param : mParams) { - query.append(param.getKey()).append("=").append(param.getValue()).append("&"); - } - params = query.substring(0, query.length() - 1); - } - - if (!Utils.isNullOrEmpty(mQueryString)) { - if (!Utils.isNullOrEmpty(params)) { - if (!"&".equals(mQueryString.substring(0, 1))) { - params = params + "&"; - } - params = params + mQueryString; - } else { - params = mQueryString; - } - } - - return new URI(mRootURI.getScheme(), - null, - mRootURI.getHost(), - mRootURI.getPort(), - mPath, - params, - null); - } -} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClient.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClient.java new file mode 100644 index 000000000..194c3ce67 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClient.java @@ -0,0 +1,73 @@ +package io.split.android.client.service.sseclient.sseclient; + +import androidx.annotation.NonNull; + +import java.net.URI; +import java.util.Map; + +/** + * Generic Server-Sent Events (SSE) client interface. + * Connects to an SSE endpoint and delivers raw events via an {@link EventHandler}. + *

+ * This client is protocol-aware only — it understands SSE framing + * (event, data, id fields) but has no knowledge of application-level + * message semantics. + */ +public interface EventSourceClient { + + int CONNECTING = 0; + int CONNECTED = 1; + int DISCONNECTED = 2; + + /** + * @return the current connection status. + */ + int status(); + + /** + * Disconnects the SSE stream. Safe to call from any thread. + * If called while {@link #connect} is blocking, the read loop + * will be interrupted and {@link EventHandler#onError} will NOT fire. + */ + void disconnect(); + + /** + * Opens an SSE connection to the given URI and blocks while reading events. + * Events are delivered to the supplied {@link EventHandler}. + *

+ * This method returns only when the connection is closed (either by + * calling {@link #disconnect()}, by a transport error, or when the + * server closes the stream). + * + * @param url fully-built URI to connect to + * @param handler callback for SSE lifecycle events + */ + void connect(@NonNull URI url, @NonNull EventHandler handler); + + /** + * Callback interface for SSE lifecycle events. + */ + interface EventHandler { + + /** + * Called when the HTTP connection succeeds and the event stream is open. + */ + void onOpen(); + + /** + * Called for each complete SSE event parsed from the stream. + * Keepalive events are included — the handler decides what to do with them. + * + * @param event the parsed SSE field→value map + * (typically contains "event", "data", and/or "id" keys) + */ + void onMessage(@NonNull Map event); + + /** + * Called when the connection ends unexpectedly (NOT via {@link #disconnect()}). + * + * @param retryable {@code true} if the error suggests a retry is reasonable + */ + void onError(boolean retryable); + } +} diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java similarity index 50% rename from streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java rename to streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java index 72f87f5f8..156191540 100644 --- a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java @@ -3,57 +3,48 @@ import static io.split.android.client.utils.Utils.checkNotNull; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.io.BufferedReader; import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import io.split.android.client.network.URIBuilder; import io.split.android.client.service.sseclient.EventStreamParser; -import io.split.android.client.service.sseclient.SseJwtToken; import io.split.android.client.service.sseclient.spi.StreamingTransport; import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingConnection; import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingResponse; import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingTransportException; -import io.split.android.client.utils.StringHelper; import io.split.android.client.utils.logger.Logger; -public class SseClientImpl implements SseClient { +/** + * Generic SSE client implementation. + *

+ * Connects to an SSE endpoint using a {@link StreamingTransport}, + * parses the event stream with {@link EventStreamParser}, and + * delivers raw events through an {@link EventHandler}. + */ +public class EventSourceClientImpl implements EventSourceClient { - private final URI mTargetUrl; private final AtomicInteger mStatus; private final StreamingTransport mStreamingTransport; private final EventStreamParser mEventStreamParser; private final AtomicBoolean mIsDisconnectCalled; - private final SseHandler mSseHandler; - private final StringHelper mStringHelper; + @Nullable + private StreamingConnection mStreamingConnection; + @Nullable + private StreamingResponse mStreamingResponse; - private StreamingConnection mStreamingConnection = null; - private StreamingResponse mStreamingResponse = null; - - private static final String PUSH_NOTIFICATION_CHANNELS_PARAM = "channel"; - private static final String PUSH_NOTIFICATION_TOKEN_PARAM = "accessToken"; - private static final String PUSH_NOTIFICATION_VERSION_PARAM = "v"; - private static final String PUSH_NOTIFICATION_VERSION_VALUE = "1.1"; - - public SseClientImpl(@NonNull URI uri, - @NonNull StreamingTransport streamingTransport, - @NonNull EventStreamParser eventStreamParser, - @NonNull SseHandler sseHandler) { - mTargetUrl = checkNotNull(uri); + public EventSourceClientImpl(@NonNull StreamingTransport streamingTransport, + @NonNull EventStreamParser eventStreamParser) { mStreamingTransport = checkNotNull(streamingTransport); mEventStreamParser = checkNotNull(eventStreamParser); - mSseHandler = checkNotNull(sseHandler); mStatus = new AtomicInteger(DISCONNECTED); mIsDisconnectCalled = new AtomicBoolean(false); - mStringHelper = new StringHelper(); - mStatus.set(DISCONNECTED); } @Override @@ -68,105 +59,78 @@ public void disconnect() { } } - private void close() { - Logger.d("Disconnecting SSE client"); - if (mStatus.getAndSet(DISCONNECTED) != DISCONNECTED) { - // Close the StreamingResponse first to clean up sockets - if (mStreamingResponse != null) { - try { - mStreamingResponse.close(); - Logger.v("StreamingResponse closed successfully"); - } catch (IOException e) { - Logger.w("Failed to close StreamingResponse: " + e.getMessage()); - } - mStreamingResponse = null; - } - - // Close the StreamingConnection - if (mStreamingConnection != null) { - mStreamingConnection.close(); - mStreamingConnection = null; - } - Logger.d("SSE client disconnected"); - } - } - @Override - public void connect(SseJwtToken token, ConnectionListener connectionListener) { + public void connect(@NonNull URI url, @NonNull EventHandler handler) { mIsDisconnectCalled.set(false); mStatus.set(CONNECTING); - boolean isConnectionConfirmed = false; - String channels = mStringHelper.join(",", token.getChannels()); - String rawToken = token.getRawJwt(); boolean isErrorRetryable = true; BufferedReader bufferedReader = null; try { - URI url = new URIBuilder(mTargetUrl) - .addParameter(PUSH_NOTIFICATION_VERSION_PARAM, PUSH_NOTIFICATION_VERSION_VALUE) - .addParameter(PUSH_NOTIFICATION_CHANNELS_PARAM, channels) - .addParameter(PUSH_NOTIFICATION_TOKEN_PARAM, rawToken) - .build(); mStreamingConnection = mStreamingTransport.connect(url); mStreamingResponse = mStreamingConnection.execute(); if (mStreamingResponse.isSuccess()) { bufferedReader = mStreamingResponse.getBufferedReader(); if (bufferedReader != null) { - Logger.d("Streaming connection opened"); + Logger.d("SSE connection opened"); mStatus.set(CONNECTED); + handler.onOpen(); String inputLine; Map values = new HashMap<>(); while ((inputLine = bufferedReader.readLine()) != null) { if (mEventStreamParser.parseLineAndAppendValue(inputLine, values)) { - if (!isConnectionConfirmed) { - if (mEventStreamParser.isKeepAlive(values) || mSseHandler.isConnectionConfirmed(values)) { - Logger.d("Streaming connection success"); - isConnectionConfirmed = true; - connectionListener.onConnectionSuccess(); - } else { - Logger.d("Streaming error after connection"); - isErrorRetryable = mSseHandler.isRetryableError(values); - break; - } - } - // Keep alive has to be handled by connection timeout - if (!mEventStreamParser.isKeepAlive(values)) { - mSseHandler.handleIncomingMessage(values); - } + handler.onMessage(values); values = new HashMap<>(); } } } else { - throw (new IOException("Buffer is null")); + throw new IOException("Buffer is null"); } } else { - Logger.e("Streaming connection error. Http return code " + mStreamingResponse.getHttpStatus()); + Logger.e("SSE connection error. Http return code " + mStreamingResponse.getHttpStatus()); isErrorRetryable = !mStreamingResponse.isClientRelatedError(); } - } catch (URISyntaxException e) { - logError("An error has occurred while creating stream Url ", e); - isErrorRetryable = false; } catch (StreamingTransportException e) { - logError("An error has occurred while creating stream Url ", e); + logError("An error has occurred during SSE transport", e); isErrorRetryable = !isNotRetryableStatusCode(e.getStatusCode()); } catch (IOException e) { - Logger.d("An error has occurred while parsing stream: " + e.getLocalizedMessage()); + Logger.d("SSE stream read error: " + e.getLocalizedMessage()); isErrorRetryable = true; } catch (Exception e) { - logError("An unexpected error has occurred while receiving stream events from: ", e); + logError("An unexpected error has occurred during SSE connection", e); isErrorRetryable = true; } finally { if (!mIsDisconnectCalled.getAndSet(false)) { - mSseHandler.handleError(isErrorRetryable); - close(); + handler.onError(isErrorRetryable); + } + close(); + } + } + + private void close() { + Logger.d("Closing SSE connection"); + if (mStatus.getAndSet(DISCONNECTED) != DISCONNECTED) { + if (mStreamingResponse != null) { + try { + mStreamingResponse.close(); + Logger.v("StreamingResponse closed successfully"); + } catch (IOException e) { + Logger.w("Failed to close StreamingResponse: " + e.getMessage()); + } + mStreamingResponse = null; + } + + if (mStreamingConnection != null) { + mStreamingConnection.close(); + mStreamingConnection = null; } + Logger.d("SSE connection closed"); } } - private boolean isNotRetryableStatusCode(Integer statusCode) { + private boolean isNotRetryableStatusCode(@Nullable Integer statusCode) { if (statusCode == null) { return false; } - // Not retryable: 4xx errors except 408 (Request Timeout) return statusCode >= 400 && statusCode < 500 && statusCode != 408; } diff --git a/streaming/src/main/java/io/split/android/client/utils/Base64Util.java b/streaming/src/main/java/io/split/android/client/utils/Base64Util.java deleted file mode 100644 index 6bcbe2cee..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/Base64Util.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.split.android.client.utils; - -import android.util.Base64; - -import androidx.annotation.Nullable; - -import io.split.android.client.utils.logger.Logger; - -public class Base64Util { - @Nullable - public static String decode(String string) { - byte[] bytes = bytesDecode(string); - if (bytes != null) { - return StringHelper.stringFromBytes(bytes); - } - return null; - } - - @Nullable - public static byte[] bytesDecode(String string) { - try { - return Base64.decode(string, Base64.DEFAULT); - } catch (IllegalArgumentException e) { - Logger.e("Received bytes didn't correspond to a valid Base64 encoded string." + e.getLocalizedMessage()); - } catch (Exception e) { - Logger.e("An unknown error has occurred " + e.getLocalizedMessage()); - } - return null; - } - - @Nullable - public static String encode(String string) { - try { - byte[] bytes = Base64.encode(string.getBytes(StringHelper.defaultCharset()), Base64.DEFAULT); - return StringHelper.stringFromBytes(bytes); - } catch (IllegalArgumentException e) { - Logger.e("Received bytes didn't correspond to a valid Base64 encoded string." + e.getLocalizedMessage()); - } catch (Exception e) { - Logger.e("An unknown error has occurred " + e.getLocalizedMessage()); - } - return null; - } - - public static String encode(byte[] bytes) { - return Base64.encodeToString(bytes, Base64.DEFAULT); - } -} diff --git a/streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java b/streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java deleted file mode 100644 index e5e67de9b..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/CompressionUtil.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.split.android.client.utils; - -public interface CompressionUtil { - byte[] decompress(byte[] compressed); -} diff --git a/streaming/src/main/java/io/split/android/client/utils/Gzip.java b/streaming/src/main/java/io/split/android/client/utils/Gzip.java deleted file mode 100644 index 85f29237a..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/Gzip.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.split.android.client.utils; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.IOException; -import java.util.zip.GZIPInputStream; - -import io.split.android.client.service.sseclient.StreamingConstants; -import io.split.android.client.utils.logger.Logger; - -public class Gzip implements CompressionUtil { - - @Override - public byte[] decompress(byte[] input) { - if (input == null || input.length == 0) { - return null; - } - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayInputStream in = new ByteArrayInputStream(input); - GZIPInputStream gzipIn = null; - try { - gzipIn = new GZIPInputStream(in); - byte[] buffer = new byte[StreamingConstants.SEGMENT_DATA_BUFFER_SIZE]; - int byteCount; - while ((byteCount = gzipIn.read(buffer)) >= 0) { - out.write(buffer, 0, byteCount); - } - return out.toByteArray(); - } catch (IOException e) { - Logger.e("Gzip format error: " + e.getLocalizedMessage()); - } catch (Exception e) { - Logger.e("Error decompressing gzip: " + e.getLocalizedMessage()); - } finally { - close(out); - close(gzipIn); - close(in); - } - return null; - } - - void close(Closeable component) { - try { - component.close(); - } catch (Exception e) { - Logger.e("Gzip error closing component: " + e.getLocalizedMessage()); - } - } -} diff --git a/streaming/src/main/java/io/split/android/client/utils/Json.java b/streaming/src/main/java/io/split/android/client/utils/Json.java deleted file mode 100644 index b9275d5c8..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/Json.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.split.android.client.utils; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonSyntaxException; - -import java.lang.reflect.Type; - -/** - * JSON utility class for the streaming module. - */ -public class Json { - - private static final Gson mJson = new GsonBuilder() - .serializeNulls() - .create(); - - public static String toJson(Object obj) { - return mJson.toJson(obj); - } - - public static T fromJson(String json, Type typeOfT) throws JsonSyntaxException { - return mJson.fromJson(json, typeOfT); - } - - public static T fromJson(String json, Class clz) throws JsonSyntaxException { - return mJson.fromJson(json, clz); - } -} diff --git a/streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java b/streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java deleted file mode 100644 index c966a31e1..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/MurmurHash3.java +++ /dev/null @@ -1,312 +0,0 @@ -package io.split.android.client.utils; - -import java.math.BigInteger; - -/** - * The MurmurHash3 algorithm was created by Austin Appleby and placed in the public domain. - * This java port was authored by Yonik Seeley and also placed into the public domain. - * The author hereby disclaims copyright to this source code. - *

- * This produces exactly the same hash values as the final C++ - * version of MurmurHash3 and is thus suitable for producing the same hash values across - * platforms. - *

- * The 32 bit x86 version of this hash should be the fastest variant for relatively short keys like ids. - * murmurhash3_x64_128 is a good choice for longer strings or if you need more than 32 bits of hash. - *

- * Note - The x86 and x64 versions do _not_ produce the same results, as the - * algorithms are optimized for their respective platforms. - *

- * See http://github.com/yonik/java_util for future updates to this file. - */ -@SuppressWarnings("ALL") -public final class MurmurHash3 { - - /** - * 128 bits of state - */ - public static final class LongPair { - public long val1; - public long val2; - } - - public static int fmix32(int h) { - h ^= h >>> 16; - h *= 0x85ebca6b; - h ^= h >>> 13; - h *= 0xc2b2ae35; - h ^= h >>> 16; - return h; - } - - public static long fmix64(long k) { - k ^= k >>> 33; - k *= 0xff51afd7ed558ccdL; - k ^= k >>> 33; - k *= 0xc4ceb9fe1a85ec53L; - k ^= k >>> 33; - return k; - } - - /** - * Gets a long from a byte buffer in little endian byte order. - */ - public static long getLongLittleEndian(byte[] buf, int offset) { - return ((long) buf[offset + 7] << 56) // no mask needed - | ((buf[offset + 6] & 0xffL) << 48) - | ((buf[offset + 5] & 0xffL) << 40) - | ((buf[offset + 4] & 0xffL) << 32) - | ((buf[offset + 3] & 0xffL) << 24) - | ((buf[offset + 2] & 0xffL) << 16) - | ((buf[offset + 1] & 0xffL) << 8) - | ((buf[offset] & 0xffL)); // no shift needed - } - - - /** - * Returns the MurmurHash3_x86_32 hash of the UTF-8 bytes of the String without actually encoding - * the string to a temporary buffer. This is more than 2x faster than hashing the result - * of String.getBytes(). - */ - public static long murmurhash3_x86_32(CharSequence data, int offset, int len, int seed) { - - final int c1 = 0xcc9e2d51; - final int c2 = 0x1b873593; - - int h1 = seed; - - int pos = offset; - int end = offset + len; - int k1 = 0; - int k2 = 0; - int shift = 0; - int bits = 0; - int nBytes = 0; // length in UTF8 bytes - - - while (pos < end) { - int code = data.charAt(pos++); - if (code < 0x80) { - k2 = code; - bits = 8; - - } else if (code < 0x800) { - k2 = (0xC0 | (code >> 6)) - | ((0x80 | (code & 0x3F)) << 8); - bits = 16; - } else if (code < 0xD800 || code > 0xDFFF || pos >= end) { - // we check for pos>=end to encode an unpaired surrogate as 3 bytes. - k2 = (0xE0 | (code >> 12)) - | ((0x80 | ((code >> 6) & 0x3F)) << 8) - | ((0x80 | (code & 0x3F)) << 16); - bits = 24; - } else { - // surrogate pair - // int utf32 = pos < end ? (int) data.charAt(pos++) : 0; - int utf32 = (int) data.charAt(pos++); - utf32 = ((code - 0xD7C0) << 10) + (utf32 & 0x3FF); - k2 = (0xff & (0xF0 | (utf32 >> 18))) - | ((0x80 | ((utf32 >> 12) & 0x3F))) << 8 - | ((0x80 | ((utf32 >> 6) & 0x3F))) << 16 - | (0x80 | (utf32 & 0x3F)) << 24; - bits = 32; - } - - - k1 |= k2 << shift; - - // int used_bits = 32 - shift; // how many bits of k2 were used in k1. - // int unused_bits = bits - used_bits; // (bits-(32-shift)) == bits+shift-32 == bits-newshift - - shift += bits; - if (shift >= 32) { - // mix after we have a complete word - - k1 *= c1; - k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); - k1 *= c2; - - h1 ^= k1; - h1 = (h1 << 13) | (h1 >>> 19); // ROTL32(h1,13); - h1 = h1 * 5 + 0xe6546b64; - - shift -= 32; - // unfortunately, java won't let you shift 32 bits off, so we need to check for 0 - if (shift != 0) { - k1 = k2 >>> (bits - shift); // bits used == bits - newshift - } else { - k1 = 0; - } - nBytes += 4; - } - - } // inner - - // handle tail - if (shift > 0) { - nBytes += shift >> 3; - k1 *= c1; - k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); - k1 *= c2; - h1 ^= k1; - } - - // finalization - h1 ^= nBytes; - - // fmix(h1); - h1 ^= h1 >>> 16; - h1 *= 0x85ebca6b; - h1 ^= h1 >>> 13; - h1 *= 0xc2b2ae35; - h1 ^= h1 >>> 16; - - return h1 & 0xFFFFFFFFL; - } - - // The following set of methods and constants are borrowed from: - // `This method is borrowed from `org.apache.commons.codec.digest.MurmurHash3` - - // Constants for 128-bit variant - private static final long C1 = 0x87c37b91114253d5L; - private static final long C2 = 0x4cf5ad432745937fL; - private static final int R1 = 31; - private static final int R2 = 27; - private static final int R3 = 33; - private static final int M = 5; - private static final int N1 = 0x52dce729; - private static final int N2 = 0x38495ab5; - - /** - * Gets the little-endian long from 8 bytes starting at the specified index. - * - * @param data The data - * @param index The index - * @return The little-endian long - */ - private static long getLittleEndianLong(final byte[] data, final int index) { - return (((long) data[index ] & 0xff) ) | - (((long) data[index + 1] & 0xff) << 8) | - (((long) data[index + 2] & 0xff) << 16) | - (((long) data[index + 3] & 0xff) << 24) | - (((long) data[index + 4] & 0xff) << 32) | - (((long) data[index + 5] & 0xff) << 40) | - (((long) data[index + 6] & 0xff) << 48) | - (((long) data[index + 7] & 0xff) << 56); - } - - public static BigInteger[] unsignedHash128x64(final byte[] data) { - long[] hash = MurmurHash3.hash128x64(data); - BigInteger b0 = new BigInteger(Long.toBinaryString(hash[0]), 2); - BigInteger b1 = new BigInteger(Long.toBinaryString(hash[1]), 2); - return new BigInteger[] { b0, b1 }; - } - - public static long[] hash128x64(final byte[] data) { - return hash128x64(data, 0, data.length, 0); - } - - /** - * Generates 128-bit hash from the byte array with the given offset, length and seed. - * - *

This is an implementation of the 128-bit hash function {@code MurmurHash3_x64_128} - * from from Austin Applyby's original MurmurHash3 {@code c++} code in SMHasher.

- * - * @param data The input byte array - * @param offset The first element of array - * @param length The length of array - * @param seed The initial seed value - * @return The 128-bit hash (2 longs) - */ - public static long[] hash128x64(final byte[] data, final int offset, final int length, final long seed) { - long h1 = seed; - long h2 = seed; - final int nblocks = length >> 4; - - // body - for (int i = 0; i < nblocks; i++) { - final int index = offset + (i << 4); - long k1 = getLittleEndianLong(data, index); - long k2 = getLittleEndianLong(data, index + 8); - - // mix functions for k1 - k1 *= C1; - k1 = Long.rotateLeft(k1, R1); - k1 *= C2; - h1 ^= k1; - h1 = Long.rotateLeft(h1, R2); - h1 += h2; - h1 = h1 * M + N1; - - // mix functions for k2 - k2 *= C2; - k2 = Long.rotateLeft(k2, R3); - k2 *= C1; - h2 ^= k2; - h2 = Long.rotateLeft(h2, R1); - h2 += h1; - h2 = h2 * M + N2; - } - - // tail - long k1 = 0; - long k2 = 0; - final int index = offset + (nblocks << 4); - switch (offset + length - index) { - case 15: - k2 ^= ((long) data[index + 14] & 0xff) << 48; - case 14: - k2 ^= ((long) data[index + 13] & 0xff) << 40; - case 13: - k2 ^= ((long) data[index + 12] & 0xff) << 32; - case 12: - k2 ^= ((long) data[index + 11] & 0xff) << 24; - case 11: - k2 ^= ((long) data[index + 10] & 0xff) << 16; - case 10: - k2 ^= ((long) data[index + 9] & 0xff) << 8; - case 9: - k2 ^= data[index + 8] & 0xff; - k2 *= C2; - k2 = Long.rotateLeft(k2, R3); - k2 *= C1; - h2 ^= k2; - - case 8: - k1 ^= ((long) data[index + 7] & 0xff) << 56; - case 7: - k1 ^= ((long) data[index + 6] & 0xff) << 48; - case 6: - k1 ^= ((long) data[index + 5] & 0xff) << 40; - case 5: - k1 ^= ((long) data[index + 4] & 0xff) << 32; - case 4: - k1 ^= ((long) data[index + 3] & 0xff) << 24; - case 3: - k1 ^= ((long) data[index + 2] & 0xff) << 16; - case 2: - k1 ^= ((long) data[index + 1] & 0xff) << 8; - case 1: - k1 ^= data[index] & 0xff; - k1 *= C1; - k1 = Long.rotateLeft(k1, R1); - k1 *= C2; - h1 ^= k1; - } - - // finalization - h1 ^= length; - h2 ^= length; - - h1 += h2; - h2 += h1; - - h1 = fmix64(h1); - h2 = fmix64(h2); - - h1 += h2; - h2 += h1; - - return new long[] { h1, h2 }; - } -} \ No newline at end of file diff --git a/streaming/src/main/java/io/split/android/client/utils/StringHelper.java b/streaming/src/main/java/io/split/android/client/utils/StringHelper.java deleted file mode 100644 index 5fbfc39f7..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/StringHelper.java +++ /dev/null @@ -1,54 +0,0 @@ -package io.split.android.client.utils; - -import java.nio.charset.Charset; -import java.util.Iterator; -import java.util.List; - -import io.split.android.client.utils.logger.Logger; - - -public class StringHelper { - public static Charset defaultCharset() { - Charset charset = Charset.defaultCharset(); - try { - charset = Charset.forName("UTF-8"); - } catch (Exception e) { - Logger.e("UTF-8 charset not available"); - } - return charset; - } - - public static String stringFromBytes(byte[] bytes) { - return new String(bytes, 0, bytes.length, StringHelper.defaultCharset()); - } - - public String join(String connector, List list) { - if(list == null || list.size() == 0 || connector == null) { - return ""; - } - StringBuilder string = new StringBuilder(list.get(0)); - for(int i=1; i values) { - if(values == null || connector == null) { - return ""; - } - - Iterator iterator = values.iterator(); - if(!iterator.hasNext()) { - return ""; - } - - StringBuilder string = new StringBuilder(iterator.next().toString()); - while (iterator.hasNext()) { - string.append(connector).append(iterator.next().toString()); - } - - return string.toString(); - } - -} diff --git a/streaming/src/main/java/io/split/android/client/utils/Zlib.java b/streaming/src/main/java/io/split/android/client/utils/Zlib.java deleted file mode 100644 index 28461b025..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/Zlib.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.split.android.client.utils; - -import java.util.Arrays; -import java.util.zip.Inflater; - -import io.split.android.client.service.sseclient.StreamingConstants; -import io.split.android.client.utils.logger.Logger; - -public class Zlib implements CompressionUtil { - - @Override - public byte[] decompress(byte[] input) { - if (input == null || input.length == 0) { - return null; - } - try { - Inflater inflater = new Inflater(); - inflater.setInput(input); - byte[] result = new byte[StreamingConstants.SEGMENT_DATA_BUFFER_SIZE]; - int resultLength = inflater.inflate(result); - inflater.end(); - return Arrays.copyOfRange(result, 0, resultLength); - } catch (java.util.zip.DataFormatException e) { - Logger.e("DataFormatException error: " + e.getLocalizedMessage()); - } catch (Exception e) { - Logger.e("Error decompressing: " + e.getLocalizedMessage()); - } - return null; - } -} From 70d61fa860f60421de8214587e81ffc6eb92cc74 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 8 Feb 2026 22:41:32 -0300 Subject: [PATCH 12/23] README and .gitignore --- streaming/.gitignore | 1 + streaming/README.md | 95 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 streaming/.gitignore create mode 100644 streaming/README.md diff --git a/streaming/.gitignore b/streaming/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/streaming/.gitignore @@ -0,0 +1 @@ +/build diff --git a/streaming/README.md b/streaming/README.md new file mode 100644 index 000000000..8b4b9059a --- /dev/null +++ b/streaming/README.md @@ -0,0 +1,95 @@ +# Streaming Module + +Generic Server-Sent Events (SSE) client library for Android. This module is responsible for connecting to an SSE endpoint, managing the connection lifecycle, and delivering raw parsed events to the consumer. It has **no knowledge of application-level message semantics** (e.g. Split notifications, authentication, or JWT tokens). + +## Architecture + +``` +┌────────────────────────────────────────────────────────┐ +│ Host application │ +│ │ +│ Implements StreamingTransport to provide HTTP layer │ +│ Calls EventSourceClient.connect(url, handler) │ +└────────────────────┬───────────────────────────────────┘ + │ + ┌─────────────▼─────────────┐ + │ EventSourceClient │ ← public interface + │ EventSourceClientImpl │ ← implementation + │ │ + │ • Connects via Transport │ + │ • Parses SSE frames │ + │ • Delivers events via │ + │ EventHandler callback │ + └─────────────┬─────────────┘ + │ + ┌─────────────▼─────────────┐ + │ StreamingTransport │ ← SPI (implemented by host) + │ (spi interface) │ + └───────────────────────────┘ +``` + +## Key Components + +### Public API + +| Class / Interface | Description | +|---|---| +| `EventSourceClient` | Interface for a generic SSE client. Defines `connect(URI, EventHandler)` and `disconnect()`. | +| `EventSourceClient.EventHandler` | Callback interface with `onOpen()`, `onMessage(Map)`, and `onError(boolean)`. | +| `EventSourceClientImpl` | Default implementation that reads an SSE stream line-by-line and dispatches parsed events. | +| `EventStreamParser` | Parses raw SSE stream lines into field→value maps following the [W3C EventSource spec](https://www.w3.org/TR/2009/WD-eventsource-20090421/#references). | + +### SPI (Service Provider Interfaces) + +| Interface | Description | +|---|---| +| `StreamingTransport` | Provides the HTTP streaming connection. The host application implements this to bridge its HTTP stack. | +| `StreamingTransport.StreamingConnection` | Represents an open connection that can be executed and closed. | +| `StreamingTransport.StreamingResponse` | Wraps the HTTP response, exposing success status, HTTP code, and a `BufferedReader` for the stream. | + +### Utilities + +| Class | Description | +|---|---| +| `BackoffCounter` | Interface for reconnection backoff strategies. | +| `ReconnectBackoffCounter` | Exponential backoff with jitter for reconnection delays. | +| `FixedIntervalBackoffCounter` | Fixed-interval backoff counter. | + +## Usage + +The host application is responsible for: + +1. **Implementing `StreamingTransport`** — wrapping its HTTP client to provide streaming connections. +2. **Building the URL** — including any authentication tokens, query parameters, and channels. +3. **Calling `EventSourceClient.connect(url, handler)`** — which blocks while the stream is open. +4. **Handling events** — via the `EventHandler` callbacks (`onOpen`, `onMessage`, `onError`). + +```java +StreamingTransport transport = new MyHttpTransport(httpClient); +EventStreamParser parser = new EventStreamParser(); +EventSourceClient client = new EventSourceClientImpl(transport, parser); + +URI url = buildStreamingUrl(token); + +client.connect(url, new EventSourceClient.EventHandler() { + @Override + public void onOpen() { + // Connection established + } + + @Override + public void onMessage(@NonNull Map event) { + // Handle SSE event (data, event type, id fields) + } + + @Override + public void onError(boolean retryable) { + // Handle connection error + } +}); +``` + +## Dependencies + +- `androidx.annotation` — for nullability annotations +- `:logger` — internal logging module From b84195320ef8ec81605ecd10f284cdc9d470fb0f Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 8 Feb 2026 22:57:21 -0300 Subject: [PATCH 13/23] Fix README --- streaming/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/streaming/README.md b/streaming/README.md index 8b4b9059a..beb739305 100644 --- a/streaming/README.md +++ b/streaming/README.md @@ -1,6 +1,6 @@ # Streaming Module -Generic Server-Sent Events (SSE) client library for Android. This module is responsible for connecting to an SSE endpoint, managing the connection lifecycle, and delivering raw parsed events to the consumer. It has **no knowledge of application-level message semantics** (e.g. Split notifications, authentication, or JWT tokens). +Generic Server-Sent Events (SSE) client library. This module is responsible for connecting to an SSE endpoint, managing the connection lifecycle, and delivering raw parsed events. It has **no knowledge of application-level message semantics** (e.g. Split notifications, authentication, or JWT tokens). ## Architecture @@ -37,7 +37,7 @@ Generic Server-Sent Events (SSE) client library for Android. This module is resp | `EventSourceClient` | Interface for a generic SSE client. Defines `connect(URI, EventHandler)` and `disconnect()`. | | `EventSourceClient.EventHandler` | Callback interface with `onOpen()`, `onMessage(Map)`, and `onError(boolean)`. | | `EventSourceClientImpl` | Default implementation that reads an SSE stream line-by-line and dispatches parsed events. | -| `EventStreamParser` | Parses raw SSE stream lines into field→value maps following the [W3C EventSource spec](https://www.w3.org/TR/2009/WD-eventsource-20090421/#references). | +| `EventStreamParser` | Parses raw SSE stream lines into field→value maps. | ### SPI (Service Provider Interfaces) From c206c2d84949c2fb3922df588823df6b9cd35ea4 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 8 Feb 2026 23:23:11 -0300 Subject: [PATCH 14/23] Move backoff back to main --- .../io/split/android/client/service/sseclient/BackoffCounter.java | 0 .../client/service/sseclient/FixedIntervalBackoffCounter.java | 0 .../android/client/service/sseclient/ReconnectBackoffCounter.java | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java (100%) rename {streaming => main}/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java (100%) diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java b/main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java rename to main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java b/main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java rename to main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java b/main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java similarity index 100% rename from streaming/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java rename to main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java From b7a56ac3ccbc98e3312376f682e410fbbb860e87 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 8 Feb 2026 23:42:13 -0300 Subject: [PATCH 15/23] Update readme --- streaming/README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/streaming/README.md b/streaming/README.md index beb739305..960c7cea7 100644 --- a/streaming/README.md +++ b/streaming/README.md @@ -47,14 +47,6 @@ Generic Server-Sent Events (SSE) client library. This module is responsible for | `StreamingTransport.StreamingConnection` | Represents an open connection that can be executed and closed. | | `StreamingTransport.StreamingResponse` | Wraps the HTTP response, exposing success status, HTTP code, and a `BufferedReader` for the stream. | -### Utilities - -| Class | Description | -|---|---| -| `BackoffCounter` | Interface for reconnection backoff strategies. | -| `ReconnectBackoffCounter` | Exponential backoff with jitter for reconnection delays. | -| `FixedIntervalBackoffCounter` | Fixed-interval backoff counter. | - ## Usage The host application is responsible for: From 4564dbf8d5ead62547568c312a848617d8bd70b3 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 15 Feb 2026 21:28:17 -0300 Subject: [PATCH 16/23] Update README --- streaming/README.md | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/streaming/README.md b/streaming/README.md index 960c7cea7..f6c11bd07 100644 --- a/streaming/README.md +++ b/streaming/README.md @@ -2,33 +2,7 @@ Generic Server-Sent Events (SSE) client library. This module is responsible for connecting to an SSE endpoint, managing the connection lifecycle, and delivering raw parsed events. It has **no knowledge of application-level message semantics** (e.g. Split notifications, authentication, or JWT tokens). -## Architecture - -``` -┌────────────────────────────────────────────────────────┐ -│ Host application │ -│ │ -│ Implements StreamingTransport to provide HTTP layer │ -│ Calls EventSourceClient.connect(url, handler) │ -└────────────────────┬───────────────────────────────────┘ - │ - ┌─────────────▼─────────────┐ - │ EventSourceClient │ ← public interface - │ EventSourceClientImpl │ ← implementation - │ │ - │ • Connects via Transport │ - │ • Parses SSE frames │ - │ • Delivers events via │ - │ EventHandler callback │ - └─────────────┬─────────────┘ - │ - ┌─────────────▼─────────────┐ - │ StreamingTransport │ ← SPI (implemented by host) - │ (spi interface) │ - └───────────────────────────┘ -``` - -## Key Components +## Components ### Public API @@ -49,7 +23,7 @@ Generic Server-Sent Events (SSE) client library. This module is responsible for ## Usage -The host application is responsible for: +The consumer is responsible for: 1. **Implementing `StreamingTransport`** — wrapping its HTTP client to provide streaming connections. 2. **Building the URL** — including any authentication tokens, query parameters, and channels. From ea770a3ad025e5f97c143e59a3afab266f27a841 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Sun, 15 Feb 2026 22:16:43 -0300 Subject: [PATCH 17/23] Remove unnecessary utils class --- build.gradle | 1 + sonar-project.properties | 15 +++++--- .../sseclient/EventSourceClientImpl.java | 7 ++-- .../io/split/android/client/utils/Utils.java | 36 ------------------- 4 files changed, 14 insertions(+), 45 deletions(-) delete mode 100644 streaming/src/main/java/io/split/android/client/utils/Utils.java diff --git a/build.gradle b/build.gradle index a947d08b6..7b84b2cf7 100644 --- a/build.gradle +++ b/build.gradle @@ -142,6 +142,7 @@ dependencies { include project(':api') include project(':http-api') include project(':http') + include project(':streaming') } def javadocSourceProjects = providers.provider { diff --git a/sonar-project.properties b/sonar-project.properties index 85a95779d..10cf60b66 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,11 +3,11 @@ sonar.projectKey=splitio_android-client sonar.projectName=android-client # Path to source directories (multi-module) -# Root project contains modules: api, events-domain, main, events, logger, http-api, http -sonar.sources=api/src/main/java,events-domain/src/main/java,main/src/main/java,events/src/main/java,logger/src/main/java,http-api/src/main/java,http/src/main/java +# Root project contains modules: api, events-domain, main, events, logger, http-api, http, streaming +sonar.sources=api/src/main/java,events-domain/src/main/java,main/src/main/java,events/src/main/java,logger/src/main/java,http-api/src/main/java,http/src/main/java,streaming/src/main/java # Path to compiled classes (multi-module) -# Include binary paths for all modules: api, events-domain, main, events, logger, http-api, http +# Include binary paths for all modules: api, events-domain, main, events, logger, http-api, http, streaming sonar.java.binaries=\ api/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ events-domain/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ @@ -15,7 +15,8 @@ sonar.java.binaries=\ events/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ logger/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ http-api/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ - http/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes + http/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + streaming/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes # Path to dependency/libraries jars (multi-module) sonar.java.libraries=\ @@ -46,7 +47,11 @@ sonar.java.libraries=\ http/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ http/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ http/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ - http/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar + http/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + streaming/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + streaming/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + streaming/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + streaming/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar # Path to test directories (multi-module) # Only include test source folders that are guaranteed to exist in all environments diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java index 156191540..839c5aa5d 100644 --- a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java @@ -1,7 +1,5 @@ package io.split.android.client.service.sseclient.sseclient; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -10,6 +8,7 @@ import java.net.URI; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -41,8 +40,8 @@ public class EventSourceClientImpl implements EventSourceClient { public EventSourceClientImpl(@NonNull StreamingTransport streamingTransport, @NonNull EventStreamParser eventStreamParser) { - mStreamingTransport = checkNotNull(streamingTransport); - mEventStreamParser = checkNotNull(eventStreamParser); + mStreamingTransport = Objects.requireNonNull(streamingTransport); + mEventStreamParser = Objects.requireNonNull(eventStreamParser); mStatus = new AtomicInteger(DISCONNECTED); mIsDisconnectCalled = new AtomicBoolean(false); } diff --git a/streaming/src/main/java/io/split/android/client/utils/Utils.java b/streaming/src/main/java/io/split/android/client/utils/Utils.java deleted file mode 100644 index 9190317c4..000000000 --- a/streaming/src/main/java/io/split/android/client/utils/Utils.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.split.android.client.utils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Objects; - -/** - * Utility methods for the streaming module. - */ -public class Utils { - - private Utils() { - // Utility class - } - - public static T checkNotNull(T obj) { - return Objects.requireNonNull(obj); - } - - public static T checkNotNull(@Nullable T reference, @Nullable Object errorMessage) { - if (reference == null) { - throw new NullPointerException(String.valueOf(errorMessage)); - } - return reference; - } - - public static boolean isNullOrEmpty(@Nullable String string) { - return string == null || string.isEmpty(); - } - - @NonNull - public static T getOrDefault(@Nullable T value, @NonNull T defaultValue) { - return value != null ? value : defaultValue; - } -} From 706b61b171f1658e30dc48fd7a078a548834eaf6 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 17 Feb 2026 01:55:28 -0300 Subject: [PATCH 18/23] Increase test coverage --- .../sseclient/EventSourceClientImpl.java | 4 +- .../sseclient/EventSourceClientImplTest.java | 383 ++++++++++++++++++ 2 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 streaming/src/test/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImplTest.java diff --git a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java index 839c5aa5d..905e4a842 100644 --- a/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java @@ -34,9 +34,9 @@ public class EventSourceClientImpl implements EventSourceClient { private final AtomicBoolean mIsDisconnectCalled; @Nullable - private StreamingConnection mStreamingConnection; + private volatile StreamingConnection mStreamingConnection; @Nullable - private StreamingResponse mStreamingResponse; + private volatile StreamingResponse mStreamingResponse; public EventSourceClientImpl(@NonNull StreamingTransport streamingTransport, @NonNull EventStreamParser eventStreamParser) { diff --git a/streaming/src/test/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImplTest.java b/streaming/src/test/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImplTest.java new file mode 100644 index 000000000..3a977238f --- /dev/null +++ b/streaming/src/test/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImplTest.java @@ -0,0 +1,383 @@ +package io.split.android.client.service.sseclient.sseclient; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.split.android.client.service.sseclient.EventStreamParser; +import io.split.android.client.service.sseclient.spi.StreamingTransport; +import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingConnection; +import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingResponse; +import io.split.android.client.service.sseclient.spi.StreamingTransport.StreamingTransportException; + +public class EventSourceClientImplTest { + + @Mock + private StreamingTransport mTransport; + + @Mock + private StreamingConnection mConnection; + + @Mock + private StreamingResponse mResponse; + + @Mock + private EventSourceClient.EventHandler mHandler; + + private EventStreamParser mParser; + private EventSourceClientImpl mClient; + private URI mUri; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + mParser = new EventStreamParser(); + mClient = new EventSourceClientImpl(mTransport, mParser); + mUri = new URI("http://test.example.com/sse"); + } + + @Test + public void initialStatusIsDisconnected() { + assertEquals(EventSourceClient.DISCONNECTED, mClient.status()); + } + + @Test + public void statusIsConnectedAfterSuccessfulConnection() throws Exception { + String sseData = "event: message\ndata: test\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + // Status should be DISCONNECTED after connect() returns (connection closed) + assertEquals(EventSourceClient.DISCONNECTED, mClient.status()); + } + + @Test + public void onOpenCalledOnSuccessfulConnection() throws Exception { + String sseData = "data: test\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onOpen(); + } + + @Test + public void messagesDeliveredToHandler() throws Exception { + String sseData = "event: update\ndata: {\"type\":\"split\"}\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mHandler, times(1)).onMessage(captor.capture()); + + Map event = captor.getValue(); + assertEquals("update", event.get("event")); + assertEquals("{\"type\":\"split\"}", event.get("data")); + } + + @Test + public void multipleMessagesDelivered() throws Exception { + String sseData = "data: first\n\nevent: second\ndata: message2\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(2)).onMessage(anyMap()); + } + + @Test + public void keepaliveEventDelivered() throws Exception { + String sseData = ":keepalive\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mHandler, times(1)).onMessage(captor.capture()); + + Map event = captor.getValue(); + assertEquals("keepalive", event.get("event")); + } + + @Test + public void onErrorCalledWithRetryableTrueOnIOException() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(true); + + BufferedReader mockReader = mock(BufferedReader.class); + when(mockReader.readLine()).thenThrow(new IOException("Connection reset")); + when(mResponse.getBufferedReader()).thenReturn(mockReader); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(true); + } + + @Test + public void onErrorCalledWithRetryableFalseOnClientError() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(false); + when(mResponse.isClientRelatedError()).thenReturn(true); + when(mResponse.getHttpStatus()).thenReturn(401); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(false); + verify(mHandler, never()).onOpen(); + } + + @Test + public void onErrorCalledWithRetryableTrueOnServerError() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(false); + when(mResponse.isClientRelatedError()).thenReturn(false); + when(mResponse.getHttpStatus()).thenReturn(503); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(true); + } + + @Test + public void onErrorCalledWithRetryableFalseOnTransportException4xx() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenThrow(new StreamingTransportException("Forbidden", 403)); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(false); + } + + @Test + public void onErrorCalledWithRetryableTrueOnTransportException408() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenThrow(new StreamingTransportException("Timeout", 408)); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(true); + } + + @Test + public void onErrorCalledWithRetryableTrueOnTransportException5xx() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenThrow(new StreamingTransportException("Server error", 500)); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(true); + } + + @Test + public void onErrorCalledWithRetryableTrueOnTransportExceptionWithNoStatusCode() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenThrow(new StreamingTransportException("Network error")); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(true); + } + + @Test + public void onErrorCalledOnNullBufferedReader() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(true); + when(mResponse.getBufferedReader()).thenReturn(null); + + mClient.connect(mUri, mHandler); + + verify(mHandler, times(1)).onError(true); + verify(mHandler, never()).onOpen(); + } + + @Test + public void disconnectClosesConnection() throws Exception { + CountDownLatch readingLatch = new CountDownLatch(1); + CountDownLatch disconnectLatch = new CountDownLatch(1); + + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(true); + + BufferedReader blockingReader = mock(BufferedReader.class); + when(blockingReader.readLine()).thenAnswer(invocation -> { + readingLatch.countDown(); + disconnectLatch.await(5, TimeUnit.SECONDS); + return null; // End of stream + }); + when(mResponse.getBufferedReader()).thenReturn(blockingReader); + + Thread connectThread = new Thread(() -> mClient.connect(mUri, mHandler)); + connectThread.start(); + + // Wait for connect to start reading + readingLatch.await(2, TimeUnit.SECONDS); + + // Disconnect from another thread + mClient.disconnect(); + disconnectLatch.countDown(); + + connectThread.join(2000); + + verify(mConnection, times(1)).close(); + verify(mResponse, times(1)).close(); + } + + @Test + public void onErrorNotCalledWhenDisconnectIsCalled() throws Exception { + CountDownLatch readingLatch = new CountDownLatch(1); + AtomicBoolean disconnected = new AtomicBoolean(false); + + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(true); + + BufferedReader blockingReader = mock(BufferedReader.class); + when(blockingReader.readLine()).thenAnswer(invocation -> { + readingLatch.countDown(); + while (!disconnected.get()) { + Thread.sleep(10); + } + return null; + }); + when(mResponse.getBufferedReader()).thenReturn(blockingReader); + + Thread connectThread = new Thread(() -> mClient.connect(mUri, mHandler)); + connectThread.start(); + + readingLatch.await(2, TimeUnit.SECONDS); + + mClient.disconnect(); + disconnected.set(true); + + connectThread.join(2000); + + // onError should NOT be called when disconnect() was explicitly called + verify(mHandler, never()).onError(any(Boolean.class)); + } + + @Test + public void disconnectIsIdempotent() throws Exception { + String sseData = "data: test\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + // Multiple disconnects should not cause issues + mClient.disconnect(); + mClient.disconnect(); + mClient.disconnect(); + + // Should only close once + verify(mResponse, times(1)).close(); + verify(mConnection, times(1)).close(); + } + + @Test + public void resourcesClosedOnSuccess() throws Exception { + String sseData = "data: test\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + verify(mResponse, times(1)).close(); + verify(mConnection, times(1)).close(); + } + + @Test + public void resourcesClosedOnError() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(false); + when(mResponse.isClientRelatedError()).thenReturn(true); + + mClient.connect(mUri, mHandler); + + verify(mConnection, times(1)).close(); + } + + @Test + public void resourcesClosedOnException() throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenThrow(new StreamingTransportException("error")); + + mClient.connect(mUri, mHandler); + + verify(mConnection, times(1)).close(); + } + + @Test + public void emptyLinesDoNotTriggerMessage() throws Exception { + String sseData = "\n\n\ndata: test\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + // Only one message should be delivered (the data: test one) + verify(mHandler, times(1)).onMessage(anyMap()); + } + + @Test + public void commentLinesIgnored() throws Exception { + String sseData = ": this is a comment\ndata: test\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mHandler, times(1)).onMessage(captor.capture()); + + // Comment should not be in the event + assertEquals("test", captor.getValue().get("data")); + } + + @Test + public void multiLineDataConcatenated() throws Exception { + // Per SSE spec, multiple data fields should be present + String sseData = "data: line1\ndata: line2\n\n"; + setupSuccessfulConnection(sseData); + + mClient.connect(mUri, mHandler); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mHandler, times(1)).onMessage(captor.capture()); + assertEquals("line2", captor.getValue().get("data")); + } + + private void setupSuccessfulConnection(String sseData) throws Exception { + when(mTransport.connect(any(URI.class))).thenReturn(mConnection); + when(mConnection.execute()).thenReturn(mResponse); + when(mResponse.isSuccess()).thenReturn(true); + when(mResponse.getBufferedReader()).thenReturn(new BufferedReader(new StringReader(sseData))); + } +} From 5f5184a9d193733be16201989eb2f3c1d264bd06 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Wed, 25 Mar 2026 16:48:24 -0300 Subject: [PATCH 19/23] Streaming support module --- main/build.gradle | 1 + .../android/client/SplitFactoryHelper.java | 2 +- .../android/client/SplitFactoryImpl.java | 2 +- .../InstantUpdateChangeNotification.java | 2 +- .../notifications/MembershipNotification.java | 2 +- .../MySegmentsV2PayloadDecoder.java | 2 +- .../MembershipsNotificationProcessorImpl.java | 4 +- ...shipsNotificationProcessorFactoryImpl.java | 2 +- .../sseclient/reactor/SplitUpdatesWorker.java | 4 +- .../sseclient/NotificationParserTest.java | 2 +- .../sseclient/SplitUpdateWorkerTest.java | 6 +- ...SegmentsNotificationProcessorImplTest.java | 4 +- .../SplitsChangeNotificationTest.java | 2 +- settings.gradle | 1 + streaming-support/.gitignore | 6 ++ streaming-support/build.gradle | 22 +++++ streaming-support/consumer-rules.pro | 1 + streaming-support/proguard-rules.pro | 1 + .../streaming/support}/CompressionType.java | 2 +- .../streaming/support}/CompressionUtil.java | 2 +- .../support}/CompressionUtilProvider.java | 9 +- .../client/streaming/support}/Gzip.java | 7 +- .../client/streaming/support}/Zlib.java | 7 +- .../client/streaming/support/GzipTest.java | 92 +++++++++++++++++ .../client/streaming/support/ZlibTest.java | 99 +++++++++++++++++++ 25 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 streaming-support/.gitignore create mode 100644 streaming-support/build.gradle create mode 100644 streaming-support/consumer-rules.pro create mode 100644 streaming-support/proguard-rules.pro rename {main/src/main/java/io/split/android/client/common => streaming-support/src/main/java/io/split/android/client/streaming/support}/CompressionType.java (77%) rename {main/src/main/java/io/split/android/client/utils => streaming-support/src/main/java/io/split/android/client/streaming/support}/CompressionUtil.java (61%) rename {main/src/main/java/io/split/android/client/common => streaming-support/src/main/java/io/split/android/client/streaming/support}/CompressionUtilProvider.java (81%) rename {main/src/main/java/io/split/android/client/utils => streaming-support/src/main/java/io/split/android/client/streaming/support}/Gzip.java (88%) rename {main/src/main/java/io/split/android/client/utils => streaming-support/src/main/java/io/split/android/client/streaming/support}/Zlib.java (82%) create mode 100644 streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java create mode 100644 streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java diff --git a/main/build.gradle b/main/build.gradle index 107a2d9a3..8647b4e08 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(':http') implementation project(':events-domain') implementation project(':streaming') + implementation project(':streaming-support') // External dependencies implementation libs.roomRuntime diff --git a/main/src/main/java/io/split/android/client/SplitFactoryHelper.java b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java index bf5689dbf..8cfa6771c 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryHelper.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java @@ -22,7 +22,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.events.EventsManagerCoordinator; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.lifecycle.SplitLifecycleManager; diff --git a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java index e37792172..b010fa6ee 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -24,7 +24,7 @@ import io.split.android.client.main.BuildConfig; import io.split.android.client.api.Key; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.events.EventsManagerCoordinator; import io.split.android.client.factory.FactoryMonitor; import io.split.android.client.factory.FactoryMonitorImpl; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java index cff8533d7..8a71d0945 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/notifications/InstantUpdateChangeNotification.java @@ -4,7 +4,7 @@ import com.google.gson.annotations.SerializedName; -import io.split.android.client.common.CompressionType; +import io.split.android.client.streaming.support.CompressionType; public abstract class InstantUpdateChangeNotification extends IncomingNotification { diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java index 20cfea311..2ed067ad1 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/notifications/MembershipNotification.java @@ -6,7 +6,7 @@ import java.util.Set; -import io.split.android.client.common.CompressionType; +import io.split.android.client.streaming.support.CompressionType; public class MembershipNotification extends IncomingNotification { diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentsV2PayloadDecoder.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentsV2PayloadDecoder.java index c646c4c0b..44e07da81 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentsV2PayloadDecoder.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/notifications/MySegmentsV2PayloadDecoder.java @@ -5,7 +5,7 @@ import io.split.android.client.exceptions.MySegmentsParsingException; import io.split.android.client.utils.Base64Util; -import io.split.android.client.utils.CompressionUtil; +import io.split.android.client.streaming.support.CompressionUtil; import io.split.android.client.utils.MurmurHash3; import io.split.android.client.utils.StringHelper; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/memberships/MembershipsNotificationProcessorImpl.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/memberships/MembershipsNotificationProcessorImpl.java index a9eb549c3..5311815e3 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/notifications/memberships/MembershipsNotificationProcessorImpl.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/notifications/memberships/MembershipsNotificationProcessorImpl.java @@ -5,8 +5,8 @@ import java.util.Set; import java.util.concurrent.BlockingQueue; -import io.split.android.client.common.CompressionType; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionType; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.mysegments.MySegmentUpdateParams; import io.split.android.client.service.mysegments.MySegmentsUpdateTask; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/notifications/mysegments/MembershipsNotificationProcessorFactoryImpl.java b/main/src/main/java/io/split/android/client/service/sseclient/notifications/mysegments/MembershipsNotificationProcessorFactoryImpl.java index 898ea21d9..a90e4307c 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/notifications/mysegments/MembershipsNotificationProcessorFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/notifications/mysegments/MembershipsNotificationProcessorFactoryImpl.java @@ -4,7 +4,7 @@ import androidx.annotation.NonNull; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.sseclient.notifications.MySegmentsV2PayloadDecoder; import io.split.android.client.service.sseclient.notifications.NotificationParser; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/reactor/SplitUpdatesWorker.java b/main/src/main/java/io/split/android/client/service/sseclient/reactor/SplitUpdatesWorker.java index 44f4a1c1b..291175842 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/reactor/SplitUpdatesWorker.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/reactor/SplitUpdatesWorker.java @@ -8,7 +8,7 @@ import java.util.concurrent.BlockingQueue; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.dtos.Helper; import io.split.android.client.dtos.RuleBasedSegment; import io.split.android.client.dtos.Split; @@ -23,7 +23,7 @@ import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.utils.Base64Util; -import io.split.android.client.utils.CompressionUtil; +import io.split.android.client.streaming.support.CompressionUtil; import io.split.android.client.utils.Json; import io.split.android.client.utils.logger.Logger; diff --git a/main/src/test/java/io/split/android/client/service/sseclient/NotificationParserTest.java b/main/src/test/java/io/split/android/client/service/sseclient/NotificationParserTest.java index c0b734f85..a1f0bb89b 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/NotificationParserTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/NotificationParserTest.java @@ -11,7 +11,7 @@ import java.util.HashMap; import java.util.Map; -import io.split.android.client.common.CompressionType; +import io.split.android.client.streaming.support.CompressionType; import io.split.android.client.service.sseclient.notifications.ControlNotification; import io.split.android.client.service.sseclient.notifications.HashingAlgorithm; import io.split.android.client.service.sseclient.notifications.IncomingNotification; diff --git a/main/src/test/java/io/split/android/client/service/sseclient/SplitUpdateWorkerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/SplitUpdateWorkerTest.java index b946113db..68489820c 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/SplitUpdateWorkerTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/SplitUpdateWorkerTest.java @@ -22,8 +22,8 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; -import io.split.android.client.common.CompressionType; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionType; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskFactory; @@ -38,7 +38,7 @@ import io.split.android.client.service.synchronizer.Synchronizer; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.client.utils.CompressionUtil; +import io.split.android.client.streaming.support.CompressionUtil; import io.split.android.fake.SplitTaskExecutorStub; public class SplitUpdateWorkerTest { diff --git a/main/src/test/java/io/split/android/client/service/sseclient/notifications/mysegments/MySegmentsNotificationProcessorImplTest.java b/main/src/test/java/io/split/android/client/service/sseclient/notifications/mysegments/MySegmentsNotificationProcessorImplTest.java index f1df86439..339e5d598 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/notifications/mysegments/MySegmentsNotificationProcessorImplTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/notifications/mysegments/MySegmentsNotificationProcessorImplTest.java @@ -28,7 +28,7 @@ import java.util.Set; import java.util.concurrent.BlockingQueue; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.exceptions.MySegmentsParsingException; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.mysegments.MySegmentUpdateParams; @@ -43,7 +43,7 @@ import io.split.android.client.service.sseclient.notifications.NotificationParser; import io.split.android.client.service.sseclient.notifications.NotificationType; import io.split.android.client.service.sseclient.notifications.memberships.MembershipsNotificationProcessorImpl; -import io.split.android.client.utils.CompressionUtil; +import io.split.android.client.streaming.support.CompressionUtil; public class MySegmentsNotificationProcessorImplTest { diff --git a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/notifications/SplitsChangeNotificationTest.java b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/notifications/SplitsChangeNotificationTest.java index ae89e70d2..6da22719c 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/notifications/SplitsChangeNotificationTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/notifications/SplitsChangeNotificationTest.java @@ -5,7 +5,7 @@ import org.junit.Test; -import io.split.android.client.common.CompressionType; +import io.split.android.client.streaming.support.CompressionType; import io.split.android.client.service.sseclient.notifications.SplitsChangeNotification; import io.split.android.client.utils.Json; diff --git a/settings.gradle b/settings.gradle index 13bfa5c78..828e04c5e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,7 @@ include ':main' include ':events' include ':events-domain' include ':streaming' +include ':streaming-support' include ':backoff' include ':tracker' include ':submitter' diff --git a/streaming-support/.gitignore b/streaming-support/.gitignore new file mode 100644 index 000000000..6009265cd --- /dev/null +++ b/streaming-support/.gitignore @@ -0,0 +1,6 @@ +/build +.gradle +*.iml +.DS_Store +.classpath +.settings diff --git a/streaming-support/build.gradle b/streaming-support/build.gradle new file mode 100644 index 000000000..5f80fbe30 --- /dev/null +++ b/streaming-support/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.streaming.support' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + api project(':logger') + implementation libs.gson + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/streaming-support/consumer-rules.pro b/streaming-support/consumer-rules.pro new file mode 100644 index 000000000..fb164d666 --- /dev/null +++ b/streaming-support/consumer-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/streaming-support/proguard-rules.pro b/streaming-support/proguard-rules.pro new file mode 100644 index 000000000..fb164d666 --- /dev/null +++ b/streaming-support/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/main/src/main/java/io/split/android/client/common/CompressionType.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionType.java similarity index 77% rename from main/src/main/java/io/split/android/client/common/CompressionType.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionType.java index 6d8bcf7f3..45c0976ec 100644 --- a/main/src/main/java/io/split/android/client/common/CompressionType.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionType.java @@ -1,4 +1,4 @@ -package io.split.android.client.common; +package io.split.android.client.streaming.support; import com.google.gson.annotations.SerializedName; diff --git a/main/src/main/java/io/split/android/client/utils/CompressionUtil.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtil.java similarity index 61% rename from main/src/main/java/io/split/android/client/utils/CompressionUtil.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtil.java index e5e67de9b..5476a8a51 100644 --- a/main/src/main/java/io/split/android/client/utils/CompressionUtil.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtil.java @@ -1,4 +1,4 @@ -package io.split.android.client.utils; +package io.split.android.client.streaming.support; public interface CompressionUtil { byte[] decompress(byte[] compressed); diff --git a/main/src/main/java/io/split/android/client/common/CompressionUtilProvider.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtilProvider.java similarity index 81% rename from main/src/main/java/io/split/android/client/common/CompressionUtilProvider.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtilProvider.java index d6b8721c6..07d764595 100644 --- a/main/src/main/java/io/split/android/client/common/CompressionUtilProvider.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtilProvider.java @@ -1,19 +1,13 @@ -package io.split.android.client.common; - -import androidx.annotation.Nullable; +package io.split.android.client.streaming.support; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import io.split.android.client.utils.CompressionUtil; -import io.split.android.client.utils.Gzip; import io.split.android.client.utils.logger.Logger; -import io.split.android.client.utils.Zlib; public class CompressionUtilProvider { Map mCompressionUtils = new ConcurrentHashMap<>(); - @Nullable public CompressionUtil get(CompressionType type) { CompressionUtil util = mCompressionUtils.get(type); return (util != null ? util : create(type)); @@ -21,7 +15,6 @@ public CompressionUtil get(CompressionType type) { // Using a method instead of a factory to avoid // a complex architecture. - @Nullable private CompressionUtil create(CompressionType type) { switch (type) { case NONE: diff --git a/main/src/main/java/io/split/android/client/utils/Gzip.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/Gzip.java similarity index 88% rename from main/src/main/java/io/split/android/client/utils/Gzip.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/Gzip.java index 12881c672..2843d4a5f 100644 --- a/main/src/main/java/io/split/android/client/utils/Gzip.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/Gzip.java @@ -1,4 +1,4 @@ -package io.split.android.client.utils; +package io.split.android.client.streaming.support; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -6,11 +6,12 @@ import java.io.IOException; import java.util.zip.GZIPInputStream; -import io.split.android.client.service.ServiceConstants; import io.split.android.client.utils.logger.Logger; public class Gzip implements CompressionUtil { + private static final int BUFFER_SIZE = 256 * 1024; // 256KB buffer + @Override public byte[] decompress(byte[] input) { if (input == null || input.length == 0) { @@ -21,7 +22,7 @@ public byte[] decompress(byte[] input) { GZIPInputStream gzipIn = null; try { gzipIn = new GZIPInputStream(in); - byte[] buffer = new byte[ServiceConstants.MY_SEGMENT_V2_DATA_SIZE]; + byte[] buffer = new byte[BUFFER_SIZE]; int byteCount; while ((byteCount = gzipIn.read(buffer)) >= 0) { out.write(buffer, 0, byteCount); diff --git a/main/src/main/java/io/split/android/client/utils/Zlib.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/Zlib.java similarity index 82% rename from main/src/main/java/io/split/android/client/utils/Zlib.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/Zlib.java index efe50e914..03dfe123e 100644 --- a/main/src/main/java/io/split/android/client/utils/Zlib.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/Zlib.java @@ -1,13 +1,14 @@ -package io.split.android.client.utils; +package io.split.android.client.streaming.support; import java.util.Arrays; import java.util.zip.Inflater; -import io.split.android.client.service.ServiceConstants; import io.split.android.client.utils.logger.Logger; public class Zlib implements CompressionUtil { + private static final int BUFFER_SIZE = 256 * 1024; // 256KB buffer + @Override public byte[] decompress(byte[] input) { if (input == null || input.length == 0) { @@ -16,7 +17,7 @@ public byte[] decompress(byte[] input) { try { Inflater inflater = new Inflater(); inflater.setInput(input); - byte[] result = new byte[ServiceConstants.MY_SEGMENT_V2_DATA_SIZE]; + byte[] result = new byte[BUFFER_SIZE]; int resultLength = inflater.inflate(result); inflater.end(); return Arrays.copyOfRange(result, 0, resultLength); diff --git a/streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java b/streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java new file mode 100644 index 000000000..fa3ca33f1 --- /dev/null +++ b/streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java @@ -0,0 +1,92 @@ +package io.split.android.client.streaming.support; + +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.GZIPOutputStream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class GzipTest { + + private Gzip gzip; + + @Before + public void setUp() { + gzip = new Gzip(); + } + + @Test + public void decompress_validGzipData_returnsDecompressedBytes() throws IOException { + // Arrange + byte[] original = "Hello, World! This is a test message for gzip compression.".getBytes(); + byte[] compressed = compressWithGzip(original); + + // Act + byte[] decompressed = gzip.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + @Test + public void decompress_emptyArray_returnsNull() { + // Act + byte[] result = gzip.decompress(new byte[0]); + + // Assert + assertNull(result); + } + + @Test + public void decompress_nullInput_returnsNull() { + // Act + byte[] result = gzip.decompress(null); + + // Assert + assertNull(result); + } + + @Test + public void decompress_invalidGzipData_returnsNull() { + // Arrange + byte[] invalidData = "This is not gzip compressed data".getBytes(); + + // Act + byte[] result = gzip.decompress(invalidData); + + // Assert + assertNull(result); + } + + @Test + public void decompress_largeData_decompressesSuccessfully() throws IOException { + // Arrange + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10000; i++) { + sb.append("Line ").append(i).append(": Some test data\n"); + } + byte[] original = sb.toString().getBytes(); + byte[] compressed = compressWithGzip(original); + + // Act + byte[] decompressed = gzip.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + private byte[] compressWithGzip(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + GZIPOutputStream gzipOut = new GZIPOutputStream(out); + gzipOut.write(data); + gzipOut.close(); + return out.toByteArray(); + } +} diff --git a/streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java b/streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java new file mode 100644 index 000000000..e1f96d162 --- /dev/null +++ b/streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java @@ -0,0 +1,99 @@ +package io.split.android.client.streaming.support; + +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.util.zip.Deflater; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class ZlibTest { + + private Zlib zlib; + + @Before + public void setUp() { + zlib = new Zlib(); + } + + @Test + public void decompress_validZlibData_returnsDecompressedBytes() { + // Arrange + byte[] original = "Hello, World! This is a test message for zlib compression.".getBytes(); + byte[] compressed = compressWithZlib(original); + + // Act + byte[] decompressed = zlib.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + @Test + public void decompress_emptyArray_returnsNull() { + // Act + byte[] result = zlib.decompress(new byte[0]); + + // Assert + assertNull(result); + } + + @Test + public void decompress_nullInput_returnsNull() { + // Act + byte[] result = zlib.decompress(null); + + // Assert + assertNull(result); + } + + @Test + public void decompress_invalidZlibData_returnsNull() { + // Arrange + byte[] invalidData = "This is not zlib compressed data".getBytes(); + + // Act + byte[] result = zlib.decompress(invalidData); + + // Assert + assertNull(result); + } + + @Test + public void decompress_largeData_decompressesSuccessfully() { + // Arrange + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10000; i++) { + sb.append("Line ").append(i).append(": Some test data\n"); + } + byte[] original = sb.toString().getBytes(); + byte[] compressed = compressWithZlib(original); + + // Act + byte[] decompressed = zlib.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + private byte[] compressWithZlib(byte[] data) { + Deflater deflater = new Deflater(); + deflater.setInput(data); + deflater.finish(); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length); + byte[] buffer = new byte[1024]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + outputStream.write(buffer, 0, count); + } + deflater.end(); + + return outputStream.toByteArray(); + } +} From d9a59c9fed5e1604c47338e509e5aab7904a4aef Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Wed, 25 Mar 2026 17:24:38 -0300 Subject: [PATCH 20/23] fix main/build.gradle --- main/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main/build.gradle b/main/build.gradle index 8647b4e08..3557915ca 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -59,10 +59,10 @@ dependencies { api clientModuleProject('submitter') // Internal module dependencies - implementation project(':http') - implementation project(':events-domain') - implementation project(':streaming') - implementation project(':streaming-support') + implementation clientModuleProject(':http') + implementation clientModuleProject(':events-domain') + implementation clientModuleProject(':streaming') + implementation clientModuleProject(':streaming-support') // External dependencies implementation libs.roomRuntime From 21244e8cde303bbe27f31b9f7c141174d208be62 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Wed, 25 Mar 2026 17:25:36 -0300 Subject: [PATCH 21/23] Add streaming-support to fused library --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 602caaa94..b72f9ec03 100644 --- a/build.gradle +++ b/build.gradle @@ -145,7 +145,7 @@ dependencies { return candidates.find { findProject(it) != null } } - ['main', 'logger', 'events', 'events-domain', 'api', 'http-api', 'http', 'fallback', 'backoff', 'tracker', 'submitter', 'streaming'].each { moduleName -> + ['main', 'logger', 'events', 'events-domain', 'api', 'http-api', 'http', 'fallback', 'backoff', 'tracker', 'submitter', 'streaming', 'streaming-support'].each { moduleName -> def resolvedPath = resolveProjectPath(moduleName) if (resolvedPath != null) { include project(resolvedPath) From 08dc6ea0e3f3307a736a1db5aeb671c548fec40b Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Thu, 26 Mar 2026 16:40:07 -0300 Subject: [PATCH 22/23] Add tests --- ...litTaskExecutorStreamingSchedulerTest.java | 246 +++++++++++++++++ .../sseclient/SseRefreshTokenTimerTest.java | 159 +++++++++++ ...RuntimeProducerStreamingTelemetryTest.java | 250 ++++++++++++++++++ 3 files changed, 655 insertions(+) create mode 100644 main/src/test/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingSchedulerTest.java create mode 100644 main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimerTest.java create mode 100644 main/src/test/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetryTest.java diff --git a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingSchedulerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingSchedulerTest.java new file mode 100644 index 000000000..64418cc20 --- /dev/null +++ b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingSchedulerTest.java @@ -0,0 +1,246 @@ +package io.split.android.client.service.sseclient.sseclient; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import io.split.android.client.service.executor.SplitTask; +import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskExecutionListener; +import io.split.android.client.service.executor.SplitTaskExecutionStatus; +import io.split.android.client.service.executor.SplitTaskExecutor; +import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; + +public class SplitTaskExecutorStreamingSchedulerTest { + + private SplitTaskExecutor mTaskExecutor; + private SplitTaskExecutorStreamingScheduler mScheduler; + + @Before + public void setUp() { + mTaskExecutor = mock(SplitTaskExecutor.class); + mScheduler = new SplitTaskExecutorStreamingScheduler(mTaskExecutor); + } + + @Test + public void scheduleReturnsTaskIdFromExecutor() { + when(mTaskExecutor.schedule(any(SplitTask.class), eq(10L), any(SplitTaskExecutionListener.class))) + .thenReturn("task-123"); + + String taskId = mScheduler.schedule(() -> {}, 10L, null); + + assertEquals("task-123", taskId); + } + + @Test + public void scheduleUsesCorrectDelay() { + Runnable task = mock(Runnable.class); + + mScheduler.schedule(task, 42L, null); + + verify(mTaskExecutor).schedule(any(SplitTask.class), eq(42L), any(SplitTaskExecutionListener.class)); + } + + @Test + public void scheduledTaskExecutesRunnable() { + Runnable task = mock(Runnable.class); + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(SplitTask.class); + + when(mTaskExecutor.schedule(taskCaptor.capture(), eq(10L), any(SplitTaskExecutionListener.class))) + .thenReturn("task-id"); + + mScheduler.schedule(task, 10L, null); + + // Execute the captured SplitTask + SplitTask splitTask = taskCaptor.getValue(); + splitTask.execute(); + + verify(task).run(); + } + + @Test + public void scheduledTaskReturnsSuccessWhenRunnableCompletesNormally() { + Runnable task = () -> { /* normal execution */ }; + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(SplitTask.class); + + when(mTaskExecutor.schedule(taskCaptor.capture(), anyLong(), any())) + .thenReturn("task-id"); + + mScheduler.schedule(task, 10L, null); + + SplitTask splitTask = taskCaptor.getValue(); + SplitTaskExecutionInfo result = splitTask.execute(); + + assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); + assertEquals(SplitTaskType.GENERIC_TASK, result.getTaskType()); + } + + @Test + public void scheduledTaskReturnsErrorWhenRunnableThrowsException() { + Runnable task = () -> { + throw new RuntimeException("Task failed"); + }; + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(SplitTask.class); + + when(mTaskExecutor.schedule(taskCaptor.capture(), anyLong(), any())) + .thenReturn("task-id"); + + mScheduler.schedule(task, 10L, null); + + SplitTask splitTask = taskCaptor.getValue(); + SplitTaskExecutionInfo result = splitTask.execute(); + + assertEquals(SplitTaskExecutionStatus.ERROR, result.getStatus()); + assertEquals(SplitTaskType.GENERIC_TASK, result.getTaskType()); + } + + @Test + public void listenerIsCalledWhenTaskCompletes() { + StreamingScheduler.TaskExecutionListener listener = mock(StreamingScheduler.TaskExecutionListener.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(SplitTaskExecutionListener.class); + + when(mTaskExecutor.schedule(any(SplitTask.class), anyLong(), listenerCaptor.capture())) + .thenReturn("task-id"); + + mScheduler.schedule(() -> {}, 10L, listener); + + // Simulate task completion + SplitTaskExecutionListener splitListener = listenerCaptor.getValue(); + splitListener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK)); + + verify(listener).onTaskExecuted(); + } + + @Test + public void listenerIsNotCalledWhenNull() { + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(SplitTaskExecutionListener.class); + + when(mTaskExecutor.schedule(any(SplitTask.class), anyLong(), listenerCaptor.capture())) + .thenReturn("task-id"); + + // Schedule with null listener - should not throw + mScheduler.schedule(() -> {}, 10L, null); + + // Simulate task completion - should not throw + SplitTaskExecutionListener splitListener = listenerCaptor.getValue(); + splitListener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK)); + + // No exception means test passes + } + + @Test + public void cancelWithNullTaskIdDoesNotCallStopTask() { + mScheduler.cancel(null); + + // When taskId is null, stopTask should not be called + verify(mTaskExecutor, never()).stopTask(any()); + } + + @Test + public void cancelWithNonNullTaskIdCallsStopTask() { + mScheduler.cancel("task-456"); + + verify(mTaskExecutor).stopTask("task-456"); + } + + @Test + public void scheduledTaskHandlesDifferentExceptionTypes() { + // Test with different exception types to ensure all are caught + Runnable task1 = () -> { + throw new IllegalArgumentException("Invalid argument"); + }; + Runnable task2 = () -> { + throw new NullPointerException("Null pointer"); + }; + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(SplitTask.class); + when(mTaskExecutor.schedule(taskCaptor.capture(), anyLong(), any())) + .thenReturn("task-id"); + + // Test IllegalArgumentException + mScheduler.schedule(task1, 10L, null); + SplitTask splitTask1 = taskCaptor.getValue(); + SplitTaskExecutionInfo result1 = splitTask1.execute(); + assertEquals(SplitTaskExecutionStatus.ERROR, result1.getStatus()); + + // Test NullPointerException + mScheduler.schedule(task2, 10L, null); + SplitTask splitTask2 = taskCaptor.getAllValues().get(1); + SplitTaskExecutionInfo result2 = splitTask2.execute(); + assertEquals(SplitTaskExecutionStatus.ERROR, result2.getStatus()); + } + + @Test + public void multipleScheduleCallsWorkIndependently() { + when(mTaskExecutor.schedule(any(SplitTask.class), eq(10L), any())) + .thenReturn("task-1"); + when(mTaskExecutor.schedule(any(SplitTask.class), eq(20L), any())) + .thenReturn("task-2"); + + String taskId1 = mScheduler.schedule(() -> {}, 10L, null); + String taskId2 = mScheduler.schedule(() -> {}, 20L, null); + + assertEquals("task-1", taskId1); + assertEquals("task-2", taskId2); + verify(mTaskExecutor).schedule(any(SplitTask.class), eq(10L), any()); + verify(mTaskExecutor).schedule(any(SplitTask.class), eq(20L), any()); + } + + @Test + public void scheduleWithZeroDelay() { + when(mTaskExecutor.schedule(any(SplitTask.class), eq(0L), any())) + .thenReturn("immediate-task"); + + String taskId = mScheduler.schedule(() -> {}, 0L, null); + + assertEquals("immediate-task", taskId); + verify(mTaskExecutor).schedule(any(SplitTask.class), eq(0L), any()); + } + + @Test + public void scheduleWithLargeDelay() { + long largeDelay = 3600L; // 1 hour + when(mTaskExecutor.schedule(any(SplitTask.class), eq(largeDelay), any())) + .thenReturn("delayed-task"); + + String taskId = mScheduler.schedule(() -> {}, largeDelay, null); + + assertEquals("delayed-task", taskId); + verify(mTaskExecutor).schedule(any(SplitTask.class), eq(largeDelay), any()); + } + + @Test + public void listenerReceivesTaskInfoRegardlessOfStatus() { + StreamingScheduler.TaskExecutionListener listener = mock(StreamingScheduler.TaskExecutionListener.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(SplitTaskExecutionListener.class); + + when(mTaskExecutor.schedule(any(SplitTask.class), anyLong(), listenerCaptor.capture())) + .thenReturn("task-id"); + + mScheduler.schedule(() -> {}, 10L, listener); + SplitTaskExecutionListener splitListener = listenerCaptor.getValue(); + + // Test with success status + splitListener.taskExecuted(SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK)); + verify(listener).onTaskExecuted(); + + // Test with error status + splitListener.taskExecuted(SplitTaskExecutionInfo.error(SplitTaskType.GENERIC_TASK)); + verify(listener, times(2)).onTaskExecuted(); // Should be called twice now + } +} diff --git a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimerTest.java new file mode 100644 index 000000000..1cbc3c7f0 --- /dev/null +++ b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/SseRefreshTokenTimerTest.java @@ -0,0 +1,159 @@ +package io.split.android.client.service.sseclient.sseclient; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; +import io.split.android.client.service.sseclient.feedbackchannel.PushStatusEvent; +import io.split.android.client.service.sseclient.spi.StreamingScheduler; + +public class SseRefreshTokenTimerTest { + + private StreamingScheduler mScheduler; + private PushManagerEventBroadcaster mBroadcaster; + private SseRefreshTokenTimer mTimer; + + @Before + public void setUp() { + mScheduler = mock(StreamingScheduler.class); + mBroadcaster = mock(PushManagerEventBroadcaster.class); + mTimer = new SseRefreshTokenTimer(mScheduler, mBroadcaster); + } + + @Test + public void cancelCallsSchedulerCancelWithNull() { + // When no task has been scheduled, mTaskId is null + mTimer.cancel(); + + verify(mScheduler).cancel(isNull()); + } + + @Test + public void cancelCancelsTaskWithCorrectTaskId() { + when(mScheduler.schedule(any(Runnable.class), eq(400L), any())).thenReturn("task-id"); + + mTimer.schedule(1000L, 2000L); + mTimer.cancel(); + + // Second cancel call should use the task ID returned by schedule + verify(mScheduler).cancel("task-id"); + } + + @Test + public void scheduleCalculatesCorrectReconnectTime() { + long issueTime = 1000L; + long expirationTime = 2000L; + // Expected: (2000 - 1000) - 600 = 400 seconds + + mTimer.schedule(issueTime, expirationTime); + + verify(mScheduler).schedule(any(Runnable.class), eq(400L), any()); + } + + @Test + public void scheduleReturnsZeroWhenTokenLifetimeLessThan600Seconds() { + long issueTime = 1000L; + long expirationTime = 1500L; + // Expected: (1500 - 1000) - 600 = -100, should be max(0, -100) = 0 + + mTimer.schedule(issueTime, expirationTime); + + verify(mScheduler).schedule(any(Runnable.class), eq(0L), any()); + } + + @Test + public void scheduleReturnsZeroWhenTokenLifetimeEquals600Seconds() { + long issueTime = 0L; + long expirationTime = 600L; + // Expected: (600 - 0) - 600 = 0 + + mTimer.schedule(issueTime, expirationTime); + + verify(mScheduler).schedule(any(Runnable.class), eq(0L), any()); + } + + @Test + public void scheduleCancelsPreviousTaskBeforeSchedulingNew() { + when(mScheduler.schedule(any(Runnable.class), eq(400L), any())).thenReturn("first-task"); + + mTimer.schedule(1000L, 2000L); + mTimer.schedule(2000L, 3000L); + + // First cancel is with null, second cancel should use "first-task" + verify(mScheduler).cancel(isNull()); + verify(mScheduler).cancel("first-task"); + } + + @Test + public void taskExecutionBroadcastsRetryableError() { + ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + when(mScheduler.schedule(runnableCaptor.capture(), eq(400L), any())).thenReturn("task-id"); + + mTimer.schedule(1000L, 2000L); + + // Execute the scheduled task + Runnable scheduledTask = runnableCaptor.getValue(); + scheduledTask.run(); + + // Verify that the broadcaster receives a PUSH_RETRYABLE_ERROR event + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(PushStatusEvent.class); + verify(mBroadcaster).pushMessage(eventCaptor.capture()); + + PushStatusEvent event = eventCaptor.getValue(); + assert event.getMessage() == PushStatusEvent.EventType.PUSH_RETRYABLE_ERROR; + } + + @Test + public void taskExecutionListenerClearsTaskId() { + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(StreamingScheduler.TaskExecutionListener.class); + when(mScheduler.schedule(any(Runnable.class), eq(400L), listenerCaptor.capture())).thenReturn("task-id"); + + mTimer.schedule(1000L, 2000L); + + // Execute the task execution listener + StreamingScheduler.TaskExecutionListener listener = listenerCaptor.getValue(); + listener.onTaskExecuted(); + + // After listener is called, next cancel should use null (task ID cleared) + mTimer.cancel(); + verify(mScheduler, times(2)).cancel(isNull()); // Once during schedule, once in final cancel + } + + @Test + public void scheduleWithLargeTokenLifetime() { + long issueTime = 0L; + long expirationTime = 3600L; // 1 hour + // Expected: (3600 - 0) - 600 = 3000 seconds + + mTimer.schedule(issueTime, expirationTime); + + verify(mScheduler).schedule(any(Runnable.class), eq(3000L), any()); + } + + @Test + public void multipleScheduleCalls() { + when(mScheduler.schedule(any(Runnable.class), eq(400L), any())).thenReturn("task-1"); + when(mScheduler.schedule(any(Runnable.class), eq(500L), any())).thenReturn("task-2"); + when(mScheduler.schedule(any(Runnable.class), eq(600L), any())).thenReturn("task-3"); + + mTimer.schedule(1000L, 2000L); // 400s + mTimer.schedule(1000L, 2100L); // 500s + mTimer.schedule(1000L, 2200L); // 600s + + // Each schedule should cancel the previous task + verify(mScheduler).cancel(isNull()); // First schedule + verify(mScheduler).cancel("task-1"); // Second schedule + verify(mScheduler).cancel("task-2"); // Third schedule + } +} diff --git a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetryTest.java b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetryTest.java new file mode 100644 index 000000000..780ba5744 --- /dev/null +++ b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/TelemetryRuntimeProducerStreamingTelemetryTest.java @@ -0,0 +1,250 @@ +package io.split.android.client.service.sseclient.sseclient; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.verify; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.split.android.client.service.sseclient.spi.StreamingTelemetry; +import io.split.android.client.telemetry.model.EventTypeEnum; +import io.split.android.client.telemetry.model.OperationType; +import io.split.android.client.telemetry.model.streaming.StreamingEvent; +import io.split.android.client.telemetry.model.streaming.SseConnectionErrorStreamingEvent; +import io.split.android.client.telemetry.model.streaming.StreamingStatusStreamingEvent; +import io.split.android.client.telemetry.model.streaming.SyncModeUpdateStreamingEvent; +import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; + +public class TelemetryRuntimeProducerStreamingTelemetryTest { + + @Mock + private TelemetryRuntimeProducer mTelemetryRuntimeProducer; + + private TelemetryRuntimeProducerStreamingTelemetry mStreamingTelemetry; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + mStreamingTelemetry = new TelemetryRuntimeProducerStreamingTelemetry(mTelemetryRuntimeProducer); + } + + @Test + public void recordTokenSyncLatency() { + long latencyMillis = 123L; + + mStreamingTelemetry.recordTokenSyncLatency(latencyMillis); + + verify(mTelemetryRuntimeProducer).recordSyncLatency(OperationType.TOKEN, latencyMillis); + } + + @Test + public void recordTokenSuccessfulSync() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordTokenSuccessfulSync(timestamp); + + verify(mTelemetryRuntimeProducer).recordSuccessfulSync(OperationType.TOKEN, timestamp); + } + + @Test + public void recordTokenSyncError() { + Integer httpStatus = 500; + + mStreamingTelemetry.recordTokenSyncError(httpStatus); + + verify(mTelemetryRuntimeProducer).recordSyncError(OperationType.TOKEN, httpStatus); + } + + @Test + public void recordTokenSyncErrorWithNullStatus() { + mStreamingTelemetry.recordTokenSyncError(null); + + verify(mTelemetryRuntimeProducer).recordSyncError(OperationType.TOKEN, null); + } + + @Test + public void recordAuthRejections() { + mStreamingTelemetry.recordAuthRejections(); + + verify(mTelemetryRuntimeProducer).recordAuthRejections(); + } + + @Test + public void recordTokenRefreshes() { + mStreamingTelemetry.recordTokenRefreshes(); + + verify(mTelemetryRuntimeProducer).recordTokenRefreshes(); + } + + @Test + public void recordTokenRefreshEvent() { + long expirationTime = 9999999999L; + long timestamp = 1234567890L; + + mStreamingTelemetry.recordTokenRefreshEvent(expirationTime, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.TOKEN_REFRESH.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(expirationTime), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordSyncModeUpdateToStreaming() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordSyncModeUpdate(true, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.SYNC_MODE_UPDATE.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(SyncModeUpdateStreamingEvent.Mode.STREAMING.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordSyncModeUpdateToPolling() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordSyncModeUpdate(false, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.SYNC_MODE_UPDATE.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(SyncModeUpdateStreamingEvent.Mode.POLLING.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordConnectionErrorRetryable() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordConnectionError(true, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.SSE_CONNECTION_ERROR.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(SseConnectionErrorStreamingEvent.Status.REQUESTED.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordConnectionErrorNonRetryable() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordConnectionError(false, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.SSE_CONNECTION_ERROR.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(SseConnectionErrorStreamingEvent.Status.NON_REQUESTED.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordAblyError() { + int errorCode = 40142; + long timestamp = 1234567890L; + + mStreamingTelemetry.recordAblyError(errorCode, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.ABLY_ERROR.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(errorCode), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordOccupancyPri() { + int publisherCount = 5; + long timestamp = 1234567890L; + + mStreamingTelemetry.recordOccupancyPri(publisherCount, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.OCCUPANCY_PRI.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(publisherCount), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordOccupancySec() { + int publisherCount = 3; + long timestamp = 1234567890L; + + mStreamingTelemetry.recordOccupancySec(publisherCount, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.OCCUPANCY_SEC.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(publisherCount), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordStreamingStatusEnabled() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordStreamingStatus(StreamingTelemetry.StreamingStatus.ENABLED, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.STREAMING_STATUS.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(StreamingStatusStreamingEvent.Status.ENABLED.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordStreamingStatusPaused() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordStreamingStatus(StreamingTelemetry.StreamingStatus.PAUSED, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.STREAMING_STATUS.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(StreamingStatusStreamingEvent.Status.PAUSED.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } + + @Test + public void recordStreamingStatusDisabled() { + long timestamp = 1234567890L; + + mStreamingTelemetry.recordStreamingStatus(StreamingTelemetry.StreamingStatus.DISABLED, timestamp); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(StreamingEvent.class); + verify(mTelemetryRuntimeProducer).recordStreamingEvents(eventCaptor.capture()); + + StreamingEvent event = eventCaptor.getValue(); + assertEquals(EventTypeEnum.STREAMING_STATUS.getNumericValue(), event.getEventType()); + assertEquals(Long.valueOf(StreamingStatusStreamingEvent.Status.DISABLED.getNumericValue()), event.getEventData()); + assertEquals(timestamp, event.getTimestamp()); + } +} From a220fd11dc4004c66ae4b73613d5667dd17d6e02 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 31 Mar 2026 09:55:05 -0300 Subject: [PATCH 23/23] Fix gradle AI-Session-Id: f3348e67-9050-426f-b37c-51ba02c90e59 AI-Tool: claude-code AI-Model: unknown --- streaming-support/build.gradle | 2 +- streaming/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/streaming-support/build.gradle b/streaming-support/build.gradle index 5f80fbe30..6ecd47470 100644 --- a/streaming-support/build.gradle +++ b/streaming-support/build.gradle @@ -2,7 +2,7 @@ plugins { id 'com.android.library' } -apply from: "$rootDir/gradle/common-android-library.gradle" +apply from: "$projectDir/../gradle/common-android-library.gradle" android { namespace 'io.split.android.client.streaming.support' diff --git a/streaming/build.gradle b/streaming/build.gradle index 990fdb1ae..ec095cedf 100644 --- a/streaming/build.gradle +++ b/streaming/build.gradle @@ -2,7 +2,7 @@ plugins { id 'com.android.library' } -apply from: "$rootDir/gradle/common-android-library.gradle" +apply from: "$projectDir/../gradle/common-android-library.gradle" android { namespace 'io.split.android.streaming'