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/CHANGES.txt b/CHANGES.txt index 86aee3a0e..0fe5d807e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +5.5.1 (May 22, 2026) +- Fixed race condition in which killing the SDK process during first initialization could leave cache in an inconsistent state. +- Refactored internal SDK components into standalone modules. + 5.5.0 (Jan 28, 2026) - Added functionality to provide metadata alongside SDK update, ready and ready from cache events. Read more in our docs. - Fixed issue in which TLS 1.2 was being forced for new connections. diff --git a/android-commons-root.gradle b/android-commons-root.gradle new file mode 100644 index 000000000..8fbb66c0d --- /dev/null +++ b/android-commons-root.gradle @@ -0,0 +1 @@ +// Minimal root build file used when android-client is included as a subproject. diff --git a/api/.gitignore b/api/.gitignore index 796b96d1c..e4dbec6f2 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1 +1,3 @@ /build +.classpath +.settings diff --git a/api/build.gradle b/api/build.gradle index c32f26549..5574e9232 100644 --- a/api/build.gradle +++ b/api/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.api' diff --git a/backoff/.gitignore b/backoff/.gitignore new file mode 100644 index 000000000..6009265cd --- /dev/null +++ b/backoff/.gitignore @@ -0,0 +1,6 @@ +/build +.gradle +*.iml +.DS_Store +.classpath +.settings diff --git a/backoff/README.md b/backoff/README.md new file mode 100644 index 000000000..fe86db861 --- /dev/null +++ b/backoff/README.md @@ -0,0 +1,42 @@ +# Backoff module + +This module contains the backoff counter logic for the Split SDK. + +It provides the types used to calculate retry delays in HTTP infrastructure components such as `RetryableHttpClient`. + +Key types: +- `BackoffCounter` — interface with `getNextRetryTime()` and `resetCounter()` +- `ExponentialBackoffCounter` — exponential backoff implementation (base * 2^attempt, capped at a configurable max) +- `FixedIntervalBackoffCounter` — fixed-interval implementation (no-op reset) + +## Usage + +**Exponential backoff** (doubles each attempt, capped at 30 minutes by default): + +```java +BackoffCounter counter = new ExponentialBackoffCounter(1); // base of 1 second + +long delay = counter.getNextRetryTime(); // 1s +delay = counter.getNextRetryTime(); // 2s +delay = counter.getNextRetryTime(); // 4s +delay = counter.getNextRetryTime(); // 8s +// ... capped at 1800s (30 min) + +counter.resetCounter(); // start over +delay = counter.getNextRetryTime(); // 1s again +``` + +A custom cap can be specified via the two-argument constructor: + +```java +BackoffCounter counter = new ExponentialBackoffCounter(1, /* maxTimeLimit= */ 60); +``` + +**Fixed-interval backoff** (always returns the same delay, `resetCounter()` is a no-op): + +```java +BackoffCounter counter = new FixedIntervalBackoffCounter(5); // 5 seconds + +long delay = counter.getNextRetryTime(); // 5s +delay = counter.getNextRetryTime(); // 5s +``` diff --git a/backoff/build.gradle b/backoff/build.gradle new file mode 100644 index 000000000..fd35629c3 --- /dev/null +++ b/backoff/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.backoff' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + testImplementation libs.junit4 +} diff --git a/backoff/src/main/AndroidManifest.xml b/backoff/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9a40236b9 --- /dev/null +++ b/backoff/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java b/backoff/src/main/java/io/split/android/client/backoff/BackoffCounter.java similarity index 64% rename from main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java rename to backoff/src/main/java/io/split/android/client/backoff/BackoffCounter.java index 0906b5cb7..a8fad0cde 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/BackoffCounter.java +++ b/backoff/src/main/java/io/split/android/client/backoff/BackoffCounter.java @@ -1,4 +1,4 @@ -package io.split.android.client.service.sseclient; +package io.split.android.client.backoff; public interface BackoffCounter { long getNextRetryTime(); diff --git a/main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java b/backoff/src/main/java/io/split/android/client/backoff/ExponentialBackoffCounter.java similarity index 80% rename from main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java rename to backoff/src/main/java/io/split/android/client/backoff/ExponentialBackoffCounter.java index 95b2b65f3..5e9cfce94 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/ReconnectBackoffCounter.java +++ b/backoff/src/main/java/io/split/android/client/backoff/ExponentialBackoffCounter.java @@ -1,8 +1,8 @@ -package io.split.android.client.service.sseclient; +package io.split.android.client.backoff; import java.util.concurrent.atomic.AtomicLong; -public class ReconnectBackoffCounter implements BackoffCounter { +public class ExponentialBackoffCounter implements BackoffCounter { private final static int MAX_TIME_LIMIT_IN_SECS = 1800; // 30 minutes (30 * 60) private final static int RETRY_EXPONENTIAL_BASE = 2; private final int mBackoffBase; @@ -12,7 +12,7 @@ public class ReconnectBackoffCounter implements BackoffCounter { /** * @param backoffBase the base of the backoff in seconds */ - public ReconnectBackoffCounter(int backoffBase) { + public ExponentialBackoffCounter(int backoffBase) { this(backoffBase, MAX_TIME_LIMIT_IN_SECS); } @@ -20,7 +20,7 @@ public ReconnectBackoffCounter(int backoffBase) { * @param backoffBase the base of the backoff in seconds * @param maxTimeLimit the maximum time limit in seconds */ - public ReconnectBackoffCounter(int backoffBase, int maxTimeLimit) { + public ExponentialBackoffCounter(int backoffBase, int maxTimeLimit) { mBackoffBase = backoffBase; mAttemptCount = new AtomicLong(0); mMaxTimeLimit = maxTimeLimit; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java b/backoff/src/main/java/io/split/android/client/backoff/FixedIntervalBackoffCounter.java similarity index 89% rename from main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java rename to backoff/src/main/java/io/split/android/client/backoff/FixedIntervalBackoffCounter.java index b5c434add..edc830a6b 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/FixedIntervalBackoffCounter.java +++ b/backoff/src/main/java/io/split/android/client/backoff/FixedIntervalBackoffCounter.java @@ -1,4 +1,4 @@ -package io.split.android.client.service.sseclient; +package io.split.android.client.backoff; public class FixedIntervalBackoffCounter implements BackoffCounter { diff --git a/main/src/test/java/io/split/android/client/service/sseclient/ReconnectBackoffCounterTest.java b/backoff/src/test/java/io/split/android/client/backoff/ExponentialBackoffCounterTest.java similarity index 86% rename from main/src/test/java/io/split/android/client/service/sseclient/ReconnectBackoffCounterTest.java rename to backoff/src/test/java/io/split/android/client/backoff/ExponentialBackoffCounterTest.java index 759a8bb8e..02a4eea51 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/ReconnectBackoffCounterTest.java +++ b/backoff/src/test/java/io/split/android/client/backoff/ExponentialBackoffCounterTest.java @@ -1,9 +1,9 @@ -package io.split.android.client.service.sseclient; +package io.split.android.client.backoff; import org.junit.Assert; import org.junit.Test; -public class ReconnectBackoffCounterTest { +public class ExponentialBackoffCounterTest { @Test public void base1() { @@ -32,7 +32,7 @@ public void base8() { @Test public void maxWaitTimeIsTakenIntoAccount() { int maxTimeLimit = 100; - BackoffCounter counter = new ReconnectBackoffCounter(1, maxTimeLimit); + BackoffCounter counter = new ExponentialBackoffCounter(1, maxTimeLimit); long lastTime = 0; for (int i = 0; i < 8; i++) { @@ -45,7 +45,7 @@ public void maxWaitTimeIsTakenIntoAccount() { @Test public void defaultMaxWaitTimeIsTakenIntoAccount() { int maxTimeLimit = 1800; - BackoffCounter counter = new ReconnectBackoffCounter(1, maxTimeLimit); + BackoffCounter counter = new ExponentialBackoffCounter(1, maxTimeLimit); long lastTime = 0; for (int i = 0; i < 12; i++) { @@ -57,7 +57,7 @@ public void defaultMaxWaitTimeIsTakenIntoAccount() { private void testWithBase(int base, long[] results) { BackoffCounter counter - = new ReconnectBackoffCounter(base); + = new ExponentialBackoffCounter(base); long v1 = counter.getNextRetryTime(); long v2 = counter.getNextRetryTime(); long v3 = counter.getNextRetryTime(); diff --git a/build.gradle b/build.gradle index f521dda49..537a7ae0c 100644 --- a/build.gradle +++ b/build.gradle @@ -13,10 +13,14 @@ buildscript { apply plugin: 'com.android.fused-library' apply plugin: 'com.vanniktech.maven.publish' -apply from: "$rootDir/gradle/jacoco-root.gradle" +if (rootProject.name == 'android-client') { + apply from: "$projectDir/gradle/jacoco-root.gradle" +} else { + logger.lifecycle("Skipping android-client root JaCoCo config in nested build: ${rootProject.name}") +} ext { - splitVersion = '5.5.0' + splitVersion = '5.5.1' jacocoVersion = '0.8.8' } @@ -68,7 +72,7 @@ tasks.register('sonar') { if (sonarOrg) cmd.add("-Dsonar.organization=${sonarOrg}") cmd.add("-Dsonar.projectVersion=${splitVersion}") - def proc = new ProcessBuilder(cmd).directory(rootDir).inheritIO().start() + def proc = new ProcessBuilder(cmd).directory(projectDir).inheritIO().start() if (proc.waitFor() != 0) { throw new GradleException("sonar-scanner failed") } @@ -135,11 +139,20 @@ repositories { } dependencies { - include project(':main') - include project(':logger') - include project(':events') - include project(':events-domain') - include project(':api') + def resolveProjectPath = { String moduleName -> + def nestedPath = (project.path != ':') ? "${project.path}:${moduleName}" : null + def candidates = [":${moduleName}", nestedPath].findAll { it != null } + return candidates.find { findProject(it) != null } + } + + ['main', 'logger', 'events', 'events-domain', 'api', 'http-api', 'http', 'fallback', 'backoff', 'tracker', 'submitter', 'streaming', 'streaming-support', 'executor'].each { moduleName -> + def resolvedPath = resolveProjectPath(moduleName) + if (resolvedPath != null) { + include project(resolvedPath) + } else { + logger.lifecycle("Skipping fused include for '${moduleName}' because no matching project path was found.") + } + } } def javadocSourceProjects = providers.provider { diff --git a/events-domain/.gitignore b/events-domain/.gitignore index 796b96d1c..e4dbec6f2 100644 --- a/events-domain/.gitignore +++ b/events-domain/.gitignore @@ -1 +1,3 @@ /build +.classpath +.settings diff --git a/events-domain/build.gradle b/events-domain/build.gradle index 04cbce16f..cce729dd2 100644 --- a/events-domain/build.gradle +++ b/events-domain/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.events' @@ -16,9 +16,10 @@ android { dependencies { implementation libs.annotation - implementation project(':api') - implementation project(':events') - implementation project(':logger') + api clientModuleProject('executor') + implementation clientModuleProject('api') + implementation clientModuleProject('events') + implementation clientModuleProject('logger') testImplementation libs.junit4 testImplementation libs.mockitoCore diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskType.java b/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskType.java deleted file mode 100644 index 7842fea6d..000000000 --- a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskType.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.split.android.client.service.executor; - -public enum SplitTaskType { - SPLITS_SYNC, MY_SEGMENTS_SYNC, EVENTS_RECORDER, IMPRESSIONS_RECORDER, - LOAD_LOCAL_SPLITS, LOAD_LOCAL_MY_SEGMENTS, SSE_AUTHENTICATION_TASK, - SPLIT_KILL, FILTER_SPLITS_CACHE, GENERIC_TASK, - CLEAN_UP_DATABASE, IMPRESSIONS_COUNT_RECORDER, SAVE_IMPRESSIONS_COUNT, - MY_SEGMENTS_UPDATE, LOAD_LOCAL_ATTRIBUTES, - TELEMETRY_CONFIG_TASK, TELEMETRY_STATS_TASK, - SAVE_UNIQUE_KEYS_TASK, UNIQUE_KEYS_RECORDER_TASK, - MY_LARGE_SEGMENTS_UPDATE, LOAD_LOCAL_RULE_BASED_SEGMENTS, - RULE_BASED_SEGMENT_SYNC, -} diff --git a/events/.gitignore b/events/.gitignore index 42afabfd2..0b60b6351 100644 --- a/events/.gitignore +++ b/events/.gitignore @@ -1 +1,3 @@ -/build \ No newline at end of file +/build +.classpath +.settings \ No newline at end of file diff --git a/events/build.gradle b/events/build.gradle index b4a4d8ee9..a07072db1 100644 --- a/events/build.gradle +++ b/events/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.harness.events' diff --git a/events/pom.xml b/events/pom.xml new file mode 100644 index 000000000..5505bff4d --- /dev/null +++ b/events/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + io.harness + events + 1.0.0 + jar + Generic event-bus framework (pure Java) + + + 1.8 + 1.8 + UTF-8 + + + + + + org.jetbrains + annotations + 23.0.0 + + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-core + 4.8.0 + test + + + org.mockito + mockito-inline + 4.8.0 + test + + + + + src/main/java + src/test/java + + + org.apache.maven.plugins + maven-compiler-plugin + 3.10.1 + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar + + + + + + + diff --git a/executor/.gitignore b/executor/.gitignore new file mode 100644 index 000000000..22e7e7b81 --- /dev/null +++ b/executor/.gitignore @@ -0,0 +1,5 @@ +/build +.gradle +local.properties +.classpath +.settings \ No newline at end of file diff --git a/executor/README.md b/executor/README.md new file mode 100644 index 000000000..e07ec571a --- /dev/null +++ b/executor/README.md @@ -0,0 +1,60 @@ +# executor + +Generic task scheduling and execution infrastructure for the Split Android SDK. + +## Purpose + +Provides a pausable task executor with support for scheduled and immediate task execution, parallel task execution with timeout, serial and batch task wrappers, main thread task execution via Android Handler, and pause/resume/stop controls. + +## Usage + +### Basic Task Execution + +```java +SplitTaskExecutor executor = new SplitTaskExecutorImpl(); + +SplitTask task = () -> { + // Do work + return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); +}; + +executor.submit(task, null); +``` + +### Scheduled Execution + +```java +executor.schedule( + task, + 60, // delay in seconds + null // optional listener +); +``` + +### Parallel Execution + +```java +SplitParallelTaskExecutor parallelExecutor = + new SplitParallelTaskExecutorFactoryImpl().build(); + +List> tasks = Arrays.asList( + () -> fetchData1(), + () -> fetchData2() +); + +List results = parallelExecutor.executeParallelTasks(tasks, 60); +``` + +### Lifecycle Management + +```java +executor.pause(); // Pause scheduled tasks +executor.resume(); // Resume scheduled tasks +executor.stop(); // Stop and shutdown executor +``` + +## Dependencies + +- **logger**: Logging abstraction +- **Android framework**: Handler/Looper for main thread execution +- **AndroidX annotations**: @NonNull, @Nullable, etc. diff --git a/executor/build.gradle b/executor/build.gradle new file mode 100644 index 000000000..17b2228c4 --- /dev/null +++ b/executor/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.executor' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + implementation clientModuleProject('logger') + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitBaseTaskExecutor.java b/executor/src/main/java/io/split/android/client/service/executor/SplitBaseTaskExecutor.java similarity index 94% rename from main/src/main/java/io/split/android/client/service/executor/SplitBaseTaskExecutor.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitBaseTaskExecutor.java index 997cfbbab..e63dfb4ed 100644 --- a/main/src/main/java/io/split/android/client/service/executor/SplitBaseTaskExecutor.java +++ b/executor/src/main/java/io/split/android/client/service/executor/SplitBaseTaskExecutor.java @@ -1,8 +1,5 @@ package io.split.android.client.service.executor; -import static io.split.android.client.utils.Utils.checkArgument; -import static io.split.android.client.utils.Utils.checkNotNull; - import android.os.Handler; import android.os.Looper; @@ -12,6 +9,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; @@ -42,8 +40,10 @@ public String schedule(@NonNull SplitTask task, long periodInSecs, @Nullable SplitTaskExecutionListener executionListener ) { - checkNotNull(task); - checkArgument(periodInSecs > 0); + Objects.requireNonNull(task); + if (periodInSecs <= 0) { + throw new IllegalArgumentException("periodInSecs must be positive"); + } String taskId = null; if (!mScheduler.isShutdown()) { @@ -62,7 +62,7 @@ public String schedule(@NonNull SplitTask task, long initialDelayInSecs, @Nullable SplitTaskExecutionListener executionListener ) { - checkNotNull(task); + Objects.requireNonNull(task); String taskId = null; if (!mScheduler.isShutdown()) { ScheduledFuture taskFuture = mScheduler.schedule( @@ -77,7 +77,7 @@ public String schedule(@NonNull SplitTask task, @Override public void submit(@NonNull SplitTask task, @Nullable SplitTaskExecutionListener executionListener) { - checkNotNull(task); + Objects.requireNonNull(task); if (!mScheduler.isShutdown()) { mScheduler.submit(new TaskWrapper(task, executionListener)); } diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitClientEventTaskExecutor.java b/executor/src/main/java/io/split/android/client/service/executor/SplitClientEventTaskExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitClientEventTaskExecutor.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitClientEventTaskExecutor.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitSingleThreadTaskExecutor.java b/executor/src/main/java/io/split/android/client/service/executor/SplitSingleThreadTaskExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitSingleThreadTaskExecutor.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitSingleThreadTaskExecutor.java diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTask.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTask.java similarity index 100% rename from events-domain/src/main/java/io/split/android/client/service/executor/SplitTask.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTask.java diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java similarity index 100% rename from events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskBatchItem.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskBatchWrapper.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskBatchWrapper.java similarity index 88% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskBatchWrapper.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskBatchWrapper.java index 47b0dca6b..0e41e1165 100644 --- a/main/src/main/java/io/split/android/client/service/executor/SplitTaskBatchWrapper.java +++ b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskBatchWrapper.java @@ -1,8 +1,7 @@ package io.split.android.client.service.executor; -import static io.split.android.client.utils.Utils.checkNotNull; - import java.util.List; +import java.util.Objects; import io.split.android.client.utils.logger.Logger; @@ -10,7 +9,7 @@ class SplitTaskBatchWrapper implements Runnable { private final List mTaskQueue; SplitTaskBatchWrapper(List taskQueue) { - mTaskQueue = checkNotNull(taskQueue); + mTaskQueue = Objects.requireNonNull(taskQueue); } @Override diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java similarity index 100% rename from events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionInfo.java diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java similarity index 100% rename from events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionListener.java diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java similarity index 100% rename from events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutionStatus.java diff --git a/events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java similarity index 100% rename from events-domain/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutor.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutorImpl.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutorImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskExecutorImpl.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskExecutorImpl.java diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskSerialWrapper.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskSerialWrapper.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/SplitTaskSerialWrapper.java rename to executor/src/main/java/io/split/android/client/service/executor/SplitTaskSerialWrapper.java diff --git a/executor/src/main/java/io/split/android/client/service/executor/SplitTaskType.java b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskType.java new file mode 100644 index 000000000..8eaacb000 --- /dev/null +++ b/executor/src/main/java/io/split/android/client/service/executor/SplitTaskType.java @@ -0,0 +1,10 @@ +package io.split.android.client.service.executor; + +public interface SplitTaskType { + SplitTaskType GENERIC_TASK = new SplitTaskType() { + @Override + public String toString() { + return "GENERIC_TASK"; + } + }; +} diff --git a/main/src/main/java/io/split/android/client/service/executor/TaskWrapper.java b/executor/src/main/java/io/split/android/client/service/executor/TaskWrapper.java similarity index 89% rename from main/src/main/java/io/split/android/client/service/executor/TaskWrapper.java rename to executor/src/main/java/io/split/android/client/service/executor/TaskWrapper.java index 71018e3ef..f5009a33a 100644 --- a/main/src/main/java/io/split/android/client/service/executor/TaskWrapper.java +++ b/executor/src/main/java/io/split/android/client/service/executor/TaskWrapper.java @@ -1,8 +1,7 @@ package io.split.android.client.service.executor; -import static io.split.android.client.utils.Utils.checkNotNull; - import java.lang.ref.WeakReference; +import java.util.Objects; import io.split.android.client.utils.logger.Logger; @@ -12,7 +11,7 @@ class TaskWrapper implements Runnable { TaskWrapper(SplitTask task, SplitTaskExecutionListener executionListener) { - mTask = checkNotNull(task); + mTask = Objects.requireNonNull(task); mExecutionListener = new WeakReference<>(executionListener); } diff --git a/main/src/main/java/io/split/android/client/service/executor/ThreadFactoryBuilder.java b/executor/src/main/java/io/split/android/client/service/executor/ThreadFactoryBuilder.java similarity index 93% rename from main/src/main/java/io/split/android/client/service/executor/ThreadFactoryBuilder.java rename to executor/src/main/java/io/split/android/client/service/executor/ThreadFactoryBuilder.java index 4b3059b48..adb4000e7 100644 --- a/main/src/main/java/io/split/android/client/service/executor/ThreadFactoryBuilder.java +++ b/executor/src/main/java/io/split/android/client/service/executor/ThreadFactoryBuilder.java @@ -2,9 +2,6 @@ import static java.util.Objects.requireNonNull; -import static io.split.android.client.utils.Utils.checkArgument; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.Nullable; import java.util.Locale; @@ -71,8 +68,12 @@ public ThreadFactoryBuilder setDaemon(boolean daemon) { public ThreadFactoryBuilder setPriority(int priority) { // Thread#setPriority() already checks for validity. These error messages // are nicer though and will fail-fast. - checkArgument(priority >= Thread.MIN_PRIORITY); - checkArgument(priority <= Thread.MAX_PRIORITY); + if (priority < Thread.MIN_PRIORITY) { + throw new IllegalArgumentException("priority must be >= Thread.MIN_PRIORITY"); + } + if (priority > Thread.MAX_PRIORITY) { + throw new IllegalArgumentException("priority must be <= Thread.MAX_PRIORITY"); + } this.priority = priority; return this; } @@ -86,7 +87,7 @@ public ThreadFactoryBuilder setPriority(int priority) { */ public ThreadFactoryBuilder setUncaughtExceptionHandler( Thread.UncaughtExceptionHandler uncaughtExceptionHandler) { - this.uncaughtExceptionHandler = checkNotNull(uncaughtExceptionHandler); + this.uncaughtExceptionHandler = requireNonNull(uncaughtExceptionHandler); return this; } @@ -99,7 +100,7 @@ public ThreadFactoryBuilder setUncaughtExceptionHandler( * @return this for the builder pattern */ public ThreadFactoryBuilder setThreadFactory(ThreadFactory backingThreadFactory) { - this.backingThreadFactory = checkNotNull(backingThreadFactory); + this.backingThreadFactory = requireNonNull(backingThreadFactory); return this; } diff --git a/main/src/main/java/io/split/android/client/service/executor/parallel/SplitDeferredTaskItem.java b/executor/src/main/java/io/split/android/client/service/executor/parallel/SplitDeferredTaskItem.java similarity index 78% rename from main/src/main/java/io/split/android/client/service/executor/parallel/SplitDeferredTaskItem.java rename to executor/src/main/java/io/split/android/client/service/executor/parallel/SplitDeferredTaskItem.java index d905ca301..e32d0584f 100644 --- a/main/src/main/java/io/split/android/client/service/executor/parallel/SplitDeferredTaskItem.java +++ b/executor/src/main/java/io/split/android/client/service/executor/parallel/SplitDeferredTaskItem.java @@ -1,9 +1,8 @@ package io.split.android.client.service.executor.parallel; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.NonNull; +import java.util.Objects; import java.util.concurrent.Callable; public class SplitDeferredTaskItem implements Callable { @@ -11,7 +10,7 @@ public class SplitDeferredTaskItem implements Callable { private final Callable mCallable; public SplitDeferredTaskItem(@NonNull Callable callable) { - mCallable = checkNotNull(callable); + mCallable = Objects.requireNonNull(callable); } @Override diff --git a/main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutor.java b/executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutor.java rename to executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutor.java diff --git a/main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactory.java b/executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactory.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactory.java rename to executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactory.java diff --git a/main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactoryImpl.java b/executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactoryImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactoryImpl.java rename to executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorFactoryImpl.java diff --git a/main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImpl.java b/executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImpl.java rename to executor/src/main/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImpl.java diff --git a/main/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutor.java b/executor/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutor.java similarity index 100% rename from main/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutor.java rename to executor/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutor.java diff --git a/main/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImpl.java b/executor/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImpl.java similarity index 100% rename from main/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImpl.java rename to executor/src/main/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImpl.java diff --git a/events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java b/executor/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java similarity index 100% rename from events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java rename to executor/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutor.java diff --git a/events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java b/executor/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java similarity index 100% rename from events-domain/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java rename to executor/src/main/java/io/split/android/engine/scheduler/PausableThreadPoolExecutorImpl.java diff --git a/main/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java b/executor/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java rename to executor/src/test/java/io/split/android/client/service/SplitTaskExecutorTest.java diff --git a/main/src/test/java/io/split/android/client/service/executor/SplitTaskSerialWrapperTest.java b/executor/src/test/java/io/split/android/client/service/executor/SplitTaskSerialWrapperTest.java similarity index 80% rename from main/src/test/java/io/split/android/client/service/executor/SplitTaskSerialWrapperTest.java rename to executor/src/test/java/io/split/android/client/service/executor/SplitTaskSerialWrapperTest.java index 11dee9e9a..4b47a9b81 100644 --- a/main/src/test/java/io/split/android/client/service/executor/SplitTaskSerialWrapperTest.java +++ b/executor/src/test/java/io/split/android/client/service/executor/SplitTaskSerialWrapperTest.java @@ -12,13 +12,17 @@ public class SplitTaskSerialWrapperTest { + private static final SplitTaskType TASK_TYPE_A = new SplitTaskType() {}; + private static final SplitTaskType TASK_TYPE_B = new SplitTaskType() {}; + private static final SplitTaskType TASK_TYPE_C = new SplitTaskType() {}; + @Test public void successfulStatusContainsResultsOfEveryTask() { SplitTask task1 = mock(SplitTask.class); SplitTask task2 = mock(SplitTask.class); - when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_CONFIG_TASK)); - when(task2.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_STATS_TASK)); + when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_A)); + when(task2.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_B)); SplitTaskSerialWrapper wrapper = new SplitTaskSerialWrapper(task1, task2); @@ -27,10 +31,10 @@ public void successfulStatusContainsResultsOfEveryTask() { List results = (List) executionInfo.getObjectValue("serial_task_results"); assertEquals(SplitTaskExecutionStatus.SUCCESS, executionInfo.getStatus()); assertEquals(2, results.size()); - assertEquals(SplitTaskType.TELEMETRY_CONFIG_TASK, results.get(0).getTaskType()); + assertEquals(TASK_TYPE_A, results.get(0).getTaskType()); assertEquals(SplitTaskExecutionStatus.SUCCESS, results.get(0).getStatus()); - assertEquals(SplitTaskType.TELEMETRY_STATS_TASK, results.get(1).getTaskType()); + assertEquals(TASK_TYPE_B, results.get(1).getTaskType()); assertEquals(SplitTaskExecutionStatus.SUCCESS, results.get(1).getStatus()); } @@ -40,9 +44,9 @@ public void unsuccessfulResultContainsExecutionInfoUpToFirstUnsuccessfulTask() { SplitTask task2 = mock(SplitTask.class); SplitTask task3 = mock(SplitTask.class); - when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_CONFIG_TASK)); - when(task2.execute()).thenReturn(SplitTaskExecutionInfo.error(SplitTaskType.TELEMETRY_STATS_TASK)); - when(task3.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.IMPRESSIONS_RECORDER)); + when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_A)); + when(task2.execute()).thenReturn(SplitTaskExecutionInfo.error(TASK_TYPE_B)); + when(task3.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_C)); SplitTaskSerialWrapper wrapper = new SplitTaskSerialWrapper(task1, task2, task3); @@ -51,10 +55,10 @@ public void unsuccessfulResultContainsExecutionInfoUpToFirstUnsuccessfulTask() { List results = (List) executionInfo.getObjectValue("serial_task_results"); assertEquals(SplitTaskExecutionStatus.ERROR, executionInfo.getStatus()); assertEquals(2, results.size()); - assertEquals(SplitTaskType.TELEMETRY_CONFIG_TASK, results.get(0).getTaskType()); + assertEquals(TASK_TYPE_A, results.get(0).getTaskType()); assertEquals(SplitTaskExecutionStatus.SUCCESS, results.get(0).getStatus()); - assertEquals(SplitTaskType.TELEMETRY_STATS_TASK, results.get(1).getTaskType()); + assertEquals(TASK_TYPE_B, results.get(1).getTaskType()); assertEquals(SplitTaskExecutionStatus.ERROR, results.get(1).getStatus()); } @@ -64,9 +68,9 @@ public void successfulTasksAreAllExecuted() { SplitTask task2 = mock(SplitTask.class); SplitTask task3 = mock(SplitTask.class); - when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_CONFIG_TASK)); - when(task2.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_STATS_TASK)); - when(task3.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.IMPRESSIONS_RECORDER)); + when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_A)); + when(task2.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_B)); + when(task3.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_C)); SplitTaskSerialWrapper wrapper = new SplitTaskSerialWrapper(task1, task2, task3); @@ -84,10 +88,10 @@ public void tasksAreExecutedUpToUnsuccessfulOne() { SplitTask task3 = mock(SplitTask.class); SplitTask task4 = mock(SplitTask.class); - when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_CONFIG_TASK)); - when(task2.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.TELEMETRY_STATS_TASK)); - when(task3.execute()).thenReturn(SplitTaskExecutionInfo.error(SplitTaskType.IMPRESSIONS_RECORDER)); - when(task4.execute()).thenReturn(SplitTaskExecutionInfo.success(SplitTaskType.IMPRESSIONS_RECORDER)); + when(task1.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_A)); + when(task2.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_B)); + when(task3.execute()).thenReturn(SplitTaskExecutionInfo.error(TASK_TYPE_C)); + when(task4.execute()).thenReturn(SplitTaskExecutionInfo.success(TASK_TYPE_C)); SplitTaskSerialWrapper wrapper = new SplitTaskSerialWrapper(task1, task2, task3, task4); wrapper.execute(); diff --git a/main/src/test/java/io/split/android/client/service/executor/ThreadFactoryBuilderTest.java b/executor/src/test/java/io/split/android/client/service/executor/ThreadFactoryBuilderTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/service/executor/ThreadFactoryBuilderTest.java rename to executor/src/test/java/io/split/android/client/service/executor/ThreadFactoryBuilderTest.java diff --git a/main/src/test/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImplTest.java b/executor/src/test/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImplTest.java rename to executor/src/test/java/io/split/android/client/service/executor/parallel/SplitParallelTaskExecutorImplTest.java diff --git a/main/src/test/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImplTest.java b/executor/src/test/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImplTest.java rename to executor/src/test/java/io/split/android/engine/scheduler/PausableScheduledThreadPoolExecutorImplTest.java diff --git a/fallback/.gitignore b/fallback/.gitignore new file mode 100644 index 000000000..6009265cd --- /dev/null +++ b/fallback/.gitignore @@ -0,0 +1,6 @@ +/build +.gradle +*.iml +.DS_Store +.classpath +.settings diff --git a/fallback/README.md b/fallback/README.md new file mode 100644 index 000000000..b9bad4614 --- /dev/null +++ b/fallback/README.md @@ -0,0 +1,10 @@ +# Fallback module + +This module contains the fallback treatment logic for the Split SDK. + +It provides the types and resolution strategy used when the SDK is unable to evaluate a feature flag normally (e.g. during initialization or on error), returning a configured fallback treatment instead of the default "control". + +Key types: +- `FallbackTreatmentsConfiguration` — builder for configuring global and per-flag fallbacks +- `FallbackTreatment` — represents a fallback treatment value with optional config and label +- `FallbackTreatmentsCalculator` — resolves the applicable fallback for a given flag name diff --git a/fallback/build.gradle b/fallback/build.gradle new file mode 100644 index 000000000..18294b6f0 --- /dev/null +++ b/fallback/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.fallback' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + api clientModuleProject('logger') + + implementation libs.annotation + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/fallback/src/main/AndroidManifest.xml b/fallback/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9a40236b9 --- /dev/null +++ b/fallback/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/main/src/main/java/io/split/android/client/fallback/FallbackTreatment.java b/fallback/src/main/java/io/split/android/client/fallback/FallbackTreatment.java similarity index 100% rename from main/src/main/java/io/split/android/client/fallback/FallbackTreatment.java rename to fallback/src/main/java/io/split/android/client/fallback/FallbackTreatment.java diff --git a/main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java b/fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java similarity index 100% rename from main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java rename to fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java diff --git a/main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java b/fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java similarity index 86% rename from main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java rename to fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java index 0eb727a1e..d67a7c084 100644 --- a/main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java +++ b/fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java @@ -1,23 +1,21 @@ package io.split.android.client.fallback; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Map; - -import io.split.android.grammar.Treatments; +import java.util.Objects; public final class FallbackTreatmentsCalculatorImpl implements FallbackTreatmentsCalculator { private static final String LABEL_PREFIX = "fallback - "; + private static final String CONTROL = "control"; @NonNull private final FallbackTreatmentsConfiguration mConfig; public FallbackTreatmentsCalculatorImpl(@NonNull FallbackTreatmentsConfiguration config) { - mConfig = checkNotNull(config); + mConfig = Objects.requireNonNull(config); } @NonNull @@ -40,7 +38,7 @@ public FallbackTreatment resolve(@NonNull String flagName, @Nullable String labe if (global != null) { return global.copyWithLabel(resolveLabel(label)); } - return new FallbackTreatment(Treatments.CONTROL, null, label); + return new FallbackTreatment(CONTROL, null, label); } @Nullable diff --git a/main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java b/fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java similarity index 100% rename from main/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java rename to fallback/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java diff --git a/main/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java b/fallback/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java similarity index 100% rename from main/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java rename to fallback/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java diff --git a/main/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java b/fallback/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java similarity index 100% rename from main/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java rename to fallback/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java diff --git a/main/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java b/fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java rename to fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java diff --git a/main/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java b/fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java similarity index 98% rename from main/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java rename to fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java index 37c5353c0..c122f4257 100644 --- a/main/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java +++ b/fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java @@ -10,8 +10,6 @@ import java.util.HashMap; import java.util.Map; -import io.split.android.client.TreatmentLabels; - public class FallbackTreatmentsCalculatorTest { @Test @@ -103,7 +101,7 @@ public void labelIsPrefixed() { .build(); FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); - FallbackTreatment resolved = calculator.resolve("flagA", TreatmentLabels.EXCEPTION); + FallbackTreatment resolved = calculator.resolve("flagA", "exception"); assertNotNull(resolved); assertEquals("fallback - exception", resolved.getLabel()); diff --git a/main/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java b/fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java rename to fallback/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java diff --git a/main/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java b/fallback/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java similarity index 100% rename from main/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java rename to fallback/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java diff --git a/fallback/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java b/fallback/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java new file mode 100644 index 000000000..e8c53ef3d --- /dev/null +++ b/fallback/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java @@ -0,0 +1,66 @@ +package io.split.android.client.utils.logger; + +import android.util.Log; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +public class LogPrinterStub implements LogPrinter { + private final Set calls = new HashSet<>(); + private final Map> logs = new ConcurrentHashMap<>(); + + public LogPrinterStub() { + // Initialize for all Android log levels: VERBOSE(2) .. ASSERT(7) + for (int level = Log.VERBOSE; level <= Log.ASSERT; level++) { + logs.put(level, new ConcurrentLinkedDeque<>()); + } + } + + @Override + public void v(String tag, String msg, Throwable tr) { + logs.get(Log.VERBOSE).add(msg); + calls.add(Log.VERBOSE); + } + + @Override + public void d(String tag, String msg, Throwable tr) { + logs.get(Log.DEBUG).add(msg); + calls.add(Log.DEBUG); + } + + @Override + public void i(String tag, String msg, Throwable tr) { + logs.get(Log.INFO).add(msg); + calls.add(Log.INFO); + } + + @Override + public void w(String tag, String msg, Throwable tr) { + logs.get(Log.WARN).add(msg); + calls.add(Log.WARN); + } + + @Override + public void e(String tag, String msg, Throwable tr) { + logs.get(Log.ERROR).add(msg); + calls.add(Log.ERROR); + } + + @Override + public void wtf(String tag, String msg, Throwable tr) { + logs.get(Log.ASSERT).add(msg); + calls.add(Log.ASSERT); + } + + public boolean isCalled(Integer type) { + return calls.contains(type); + } + + public Map> getLoggedMessages() { + return new HashMap<>(logs); + } +} diff --git a/gradle/common-android-library.gradle b/gradle/common-android-library.gradle index fe96407c8..adf1d647d 100644 --- a/gradle/common-android-library.gradle +++ b/gradle/common-android-library.gradle @@ -14,6 +14,10 @@ tasks.withType(JavaCompile).configureEach { options.compilerArgs.add('-parameters') } +tasks.withType(Test).configureEach { + maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 +} + def kotlinCompileClass = null try { kotlinCompileClass = Class.forName('org.jetbrains.kotlin.gradle.tasks.KotlinCompile') @@ -29,5 +33,28 @@ if (kotlinCompileClass != null) { } } -// Enable Jacoco coverage configuration for all Android library modules -apply from: "$rootDir/gradle/jacoco-android.gradle" +// Enable JaCoCo only for standalone android-client builds. +if (rootProject.name == 'android-client') { + apply from: "$projectDir/../gradle/jacoco-android.gradle" +} + +/** + * Resolves an android-client module dependency in both layouts: + * - Standalone android-client root, and externally consumable + */ +ext.clientModuleProject = { String moduleName -> + def standalonePath = ":${moduleName}" + def parentPath = project.path.lastIndexOf(':') > 0 ? project.path.substring(0, project.path.lastIndexOf(':')) : null + def nestedPath = parentPath ? "${parentPath}:${moduleName}" : null + def candidates = [standalonePath, nestedPath].findAll { it != null } + def resolvedPath = candidates.find { findProject(it) != null } + + if (resolvedPath == null) { + throw new GradleException( + "Could not resolve module '${moduleName}' for project '${project.path}'. " + + "Tried: ${candidates.join(', ')}" + ) + } + + return project(resolvedPath) +} diff --git a/http-api/.gitignore b/http-api/.gitignore new file mode 100644 index 000000000..e4dbec6f2 --- /dev/null +++ b/http-api/.gitignore @@ -0,0 +1,3 @@ +/build +.classpath +.settings 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-api/build.gradle b/http-api/build.gradle new file mode 100644 index 000000000..b3938c59d --- /dev/null +++ b/http-api/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.network.api' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + implementation clientModuleProject('logger') + + testImplementation libs.junit4 + testImplementation libs.mockitoCore + testImplementation libs.mockitoInline + testImplementation libs.okhttpTls +} diff --git a/http-api/consumer-rules.pro b/http-api/consumer-rules.pro new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/http-api/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/http-api/proguard-rules.pro b/http-api/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/http-api/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-api/src/main/AndroidManifest.xml b/http-api/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/http-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/http-api/src/main/java/io/split/android/client/network/Algorithm.java b/http-api/src/main/java/io/split/android/client/network/Algorithm.java new file mode 100644 index 000000000..e0d669e05 --- /dev/null +++ b/http-api/src/main/java/io/split/android/client/network/Algorithm.java @@ -0,0 +1,7 @@ +package io.split.android.client.network; + +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-api/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-api/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-api/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-api/src/main/java/io/split/android/client/network/Authenticator.java b/http-api/src/main/java/io/split/android/client/network/Authenticator.java new file mode 100644 index 000000000..4fab265e4 --- /dev/null +++ b/http-api/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-api/src/main/java/io/split/android/client/network/Base64Decoder.java similarity index 100% rename from main/src/main/java/io/split/android/client/network/Base64Decoder.java rename to http-api/src/main/java/io/split/android/client/network/Base64Decoder.java diff --git a/main/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 main/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/main/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 main/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/main/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 92% rename from main/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 709534b88..09504f9e2 100644 --- a/main/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java +++ b/http-api/src/main/java/io/split/android/client/network/CertificateCheckerHelper.java @@ -18,7 +18,7 @@ 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-api/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-api/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-api/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-api/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-api/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-api/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-api/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-api/src/main/java/io/split/android/client/network/CertificatePinningFailureListener.java diff --git a/http-api/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java b/http-api/src/main/java/io/split/android/client/network/DefaultBase64Decoder.java new file mode 100644 index 000000000..b46f38309 --- /dev/null +++ b/http-api/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; + +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-api/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-api/src/main/java/io/split/android/client/network/DevelopmentSslConfig.java diff --git a/http-api/src/main/java/io/split/android/client/network/HttpClientConfiguration.java b/http-api/src/main/java/io/split/android/client/network/HttpClientConfiguration.java new file mode 100644 index 000000000..6bd6f7d58 --- /dev/null +++ b/http-api/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/main/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 main/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/main/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/main/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 100% rename from main/src/main/java/io/split/android/client/network/PinEncoder.java rename to http-api/src/main/java/io/split/android/client/network/PinEncoder.java diff --git a/main/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 100% rename from main/src/main/java/io/split/android/client/network/PinEncoderImpl.java rename to http-api/src/main/java/io/split/android/client/network/PinEncoderImpl.java diff --git a/main/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 main/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/main/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 main/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/main/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 81% rename from main/src/main/java/io/split/android/client/network/SplitAuthenticator.java rename to http-api/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-api/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-api/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-api/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-api/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-api/src/test/java/io/split/android/client/network/CertificatePinningConfigurationTest.java diff --git a/http-api/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java b/http-api/src/test/java/io/split/android/client/network/HttpClientConfigurationTest.java new file mode 100644 index 000000000..f722a3439 --- /dev/null +++ b/http-api/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()); + } +} diff --git a/main/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 main/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/.gitignore b/http/.gitignore new file mode 100644 index 000000000..e4dbec6f2 --- /dev/null +++ b/http/.gitignore @@ -0,0 +1,3 @@ +/build +.classpath +.settings diff --git a/http/README.md b/http/README.md new file mode 100644 index 000000000..12e59f39f --- /dev/null +++ b/http/README.md @@ -0,0 +1,124 @@ +# HTTP module + +HTTP client for the Split SDK. + +## Building an `HttpClient` + +### Minimal + +```java +HttpClient client = new HttpClientImpl.Builder() + .setConnectionTimeout(15_000) + .setReadTimeout(15_000) + .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 +// Simple GET +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(); + +// 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); +client.addStreamingHeaders(streamingHeaders); +``` + +## URI building + +```java +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 new file mode 100644 index 000000000..41bfe00c5 --- /dev/null +++ b/http/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../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 clientModuleProject('logger') + api clientModuleProject('http-api') + + 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/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 83% 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..8fbff1270 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 { @@ -165,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( @@ -180,7 +210,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 +218,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,18 +230,21 @@ 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(); + @Nullable + private HttpClientConfiguration mConfiguration; - public Builder setContext(Context context) { - mHostAppContext = context; + 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; @@ -277,15 +299,24 @@ 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 (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) { @@ -324,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/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/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/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 97% 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..14e5a5b06 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; @@ -100,6 +99,13 @@ static void applyTimeouts(long readTimeout, long connectionTimeout, HttpURLConne } } + 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/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..864a9836f 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,14 +29,19 @@ 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 { +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 95% 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..08d4f5376 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,10 +28,9 @@ 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 { +class HttpStreamRequestImpl implements HttpStreamRequest { private static final int STREAMING_READ_TIMEOUT_IN_MILLISECONDS = 80000; @@ -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 96% 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 index bf24d0e74..bae64f68d 100644 --- a/main/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/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 97% 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 index 9b426385c..cddb6370e 100644 --- a/main/src/main/java/io/split/android/client/network/SplitAuthenticatedRequest.java +++ b/http/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/http/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java similarity index 91% 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 index bd49d9ca4..b87c6699f 100644 --- a/main/src/main/java/io/split/android/client/network/SplitBasicAuthenticator.java +++ b/http/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/http/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java similarity index 88% 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 index fdb97f302..2c0cd3d5a 100644 --- a/main/src/main/java/io/split/android/client/network/SplitUrlConnectionAuthenticator.java +++ b/http/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/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 98% 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 index d12a6f995..11098b8e0 100644 --- a/main/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/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/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/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 91% 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 index 3380c43a1..dae394a09 100644 --- a/main/src/test/java/io/split/android/client/network/SplitAuthenticatorTest.java +++ b/http/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/http/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java similarity index 91% 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 index 7b56e0291..5b0f27531 100644 --- a/main/src/test/java/io/split/android/client/network/SplitBasicAuthenticatorTest.java +++ b/http/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"); } 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/logger/.gitignore b/logger/.gitignore index 3a11ced48..6009265cd 100644 --- a/logger/.gitignore +++ b/logger/.gitignore @@ -2,3 +2,5 @@ .gradle *.iml .DS_Store +.classpath +.settings diff --git a/logger/build.gradle b/logger/build.gradle index a45c8cd36..0cc88bb53 100644 --- a/logger/build.gradle +++ b/logger/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.logger' diff --git a/main/.gitignore b/main/.gitignore index 3a11ced48..6009265cd 100644 --- a/main/.gitignore +++ b/main/.gitignore @@ -2,3 +2,5 @@ .gradle *.iml .DS_Store +.classpath +.settings diff --git a/main/build.gradle b/main/build.gradle index 7ec1e3110..6d1432030 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -2,14 +2,14 @@ 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.main' defaultConfig { multiDexEnabled true - consumerProguardFiles "$rootDir/split-proguard-rules.pro" + consumerProguardFiles "$projectDir/../split-proguard-rules.pro" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' @@ -50,10 +50,20 @@ android { dependencies { // Public api modules - api project(':logger') - api project(':api') + api clientModuleProject('executor') + api clientModuleProject('logger') + api clientModuleProject('api') + api clientModuleProject('http-api') + api clientModuleProject('fallback') + implementation clientModuleProject('backoff') + implementation clientModuleProject('tracker') + api clientModuleProject('submitter') + // Internal module dependencies - implementation project(':events-domain') + implementation clientModuleProject(':http') + implementation clientModuleProject(':events-domain') + implementation clientModuleProject(':streaming') + implementation clientModuleProject(':streaming-support') // External dependencies implementation libs.roomRuntime 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; } diff --git a/main/src/androidTest/java/tests/integration/rollout/FreshInstallPrefetchPersistenceIntegrationTest.java b/main/src/androidTest/java/tests/integration/rollout/FreshInstallPrefetchPersistenceIntegrationTest.java new file mode 100644 index 000000000..3f18664b6 --- /dev/null +++ b/main/src/androidTest/java/tests/integration/rollout/FreshInstallPrefetchPersistenceIntegrationTest.java @@ -0,0 +1,131 @@ +package tests.integration.rollout; + +import static helper.IntegrationHelper.dummyApiKey; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import helper.DatabaseHelper; +import io.split.android.client.SplitFilter; +import io.split.android.client.dtos.Split; +import io.split.android.client.dtos.SplitChange; +import io.split.android.client.dtos.Status; +import io.split.android.client.service.splits.SplitChangeProcessor; +import io.split.android.client.storage.cipher.SplitCipher; +import io.split.android.client.storage.cipher.SplitCipherFactory; +import io.split.android.client.storage.db.SplitRoomDatabase; +import io.split.android.client.storage.splits.PersistentSplitsStorage; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.storage.splits.SplitsStorageImpl; +import io.split.android.client.storage.splits.SqLitePersistentSplitsStorage; + +public class FreshInstallPrefetchPersistenceIntegrationTest { + + private static final long CHANGE_NUMBER = 1778482333302L; + private static final int SPLIT_COUNT_OVER_ASYNC_THRESHOLD = 60; + private static final String FIRST_FLAG_NAME = "fresh_install_flag_0"; + + private SplitRoomDatabase mRoomDb; + private PersistentSplitsStorage mPersistentStorage; + private SplitChangeProcessor mSplitChangeProcessor; + + @Before + public void setUp() { + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + mRoomDb = DatabaseHelper.getTestDatabase(context); + mRoomDb.clearAllTables(); + + SplitCipher cipher = SplitCipherFactory.create(dummyApiKey(), false); + mPersistentStorage = new SqLitePersistentSplitsStorage(mRoomDb, cipher); + mSplitChangeProcessor = new SplitChangeProcessor((Map) null, null); + } + + @Test + public void processKillBeforeAsyncWriteCompletes_dbRemainsConsistent() throws InterruptedException { + // Block the executor so the first write doesn't complete + CountDownLatch blockLatch = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(() -> { + try { + blockLatch.await(); + } catch (InterruptedException e) { + // shutdownNow will interrupt this + } + }); + + SplitsStorage storage = new SplitsStorageImpl(mPersistentStorage); + + // First update queues behind the blocked task + storage.update( + mSplitChangeProcessor.process(SplitChange.create(-1, CHANGE_NUMBER, createSplits())), + executor); + + // Simulate process kill — first write never completes + executor.shutdownNow(); + executor.awaitTermination(1, TimeUnit.SECONDS); + + // Second update (empty delta) — submit is rejected since executor is shut down + storage.update( + mSplitChangeProcessor.process(SplitChange.create(CHANGE_NUMBER, CHANGE_NUMBER, new ArrayList<>())), + executor); + + // DB should be untouched — no partial CN write + SplitsStorage reloadedStorage = new SplitsStorageImpl(mPersistentStorage); + reloadedStorage.loadLocal(); + + assertEquals(-1, reloadedStorage.getTill()); + assertEquals(0, mRoomDb.splitDao().getAll().size()); + } + + @Test + public void fullSnapshotAndEmptyDeltaPersistCorrectlyWhenExecutorIsRunning() throws InterruptedException { + ExecutorService executor = Executors.newSingleThreadExecutor(); + SplitsStorage storage = new SplitsStorageImpl(mPersistentStorage); + + storage.update( + mSplitChangeProcessor.process(SplitChange.create(-1, CHANGE_NUMBER, createSplits())), + executor); + storage.update( + mSplitChangeProcessor.process(SplitChange.create(CHANGE_NUMBER, CHANGE_NUMBER, new ArrayList<>())), + executor); + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + + SplitsStorage reloadedStorage = new SplitsStorageImpl(mPersistentStorage); + reloadedStorage.loadLocal(); + + assertEquals(CHANGE_NUMBER, reloadedStorage.getTill()); + assertEquals(SPLIT_COUNT_OVER_ASYNC_THRESHOLD, mRoomDb.splitDao().getAll().size()); + assertNotNull(reloadedStorage.get(FIRST_FLAG_NAME)); + } + + private static List createSplits() { + List splits = new ArrayList<>(); + for (int i = 0; i < SPLIT_COUNT_OVER_ASYNC_THRESHOLD; i++) { + Split split = new Split(); + split.name = "fresh_install_flag_" + i; + split.status = Status.ACTIVE; + split.changeNumber = CHANGE_NUMBER; + split.trafficTypeName = "user"; + split.defaultTreatment = "on"; + split.killed = false; + splits.add(split); + } + return splits; + } +} diff --git a/main/src/androidTest/java/tests/service/CompressionTest.java b/main/src/androidTest/java/tests/service/CompressionTest.java index 1d5dbd15c..40a4a20aa 100644 --- a/main/src/androidTest/java/tests/service/CompressionTest.java +++ b/main/src/androidTest/java/tests/service/CompressionTest.java @@ -8,18 +8,17 @@ import org.junit.Before; import org.junit.Test; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.List; import java.util.UUID; import helper.CompressionHelper; import helper.FileHelper; +import io.split.android.client.streaming.support.CompressionUtil; +import io.split.android.client.streaming.support.Gzip; +import io.split.android.client.streaming.support.Zlib; import io.split.android.client.utils.Base64Util; -import io.split.android.client.utils.CompressionUtil; -import io.split.android.client.utils.Gzip; import io.split.android.client.utils.StringHelper; -import io.split.android.client.utils.Zlib; public class CompressionTest { diff --git a/main/src/androidTest/java/tests/service/MySegmentV2PayloadDecoderTest.java b/main/src/androidTest/java/tests/service/MySegmentV2PayloadDecoderTest.java index ebdf5057a..f9f139db0 100644 --- a/main/src/androidTest/java/tests/service/MySegmentV2PayloadDecoderTest.java +++ b/main/src/androidTest/java/tests/service/MySegmentV2PayloadDecoderTest.java @@ -17,9 +17,8 @@ import io.split.android.client.service.sseclient.notifications.KeyList; import io.split.android.client.service.sseclient.notifications.MySegmentsV2PayloadDecoder; import io.split.android.client.service.sseclient.notifications.NotificationParser; -import io.split.android.client.utils.Gzip; -import io.split.android.client.utils.MurmurHash3; -import io.split.android.client.utils.Zlib; +import io.split.android.client.streaming.support.Gzip; +import io.split.android.client.streaming.support.Zlib; public class MySegmentV2PayloadDecoderTest { diff --git a/main/src/androidTest/java/tests/storage/SplitsStorageTest.java b/main/src/androidTest/java/tests/storage/SplitsStorageTest.java index 7922a6126..e8fd2bd4c 100644 --- a/main/src/androidTest/java/tests/storage/SplitsStorageTest.java +++ b/main/src/androidTest/java/tests/storage/SplitsStorageTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -20,7 +21,10 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.Set; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -32,7 +36,9 @@ import io.split.android.client.storage.db.GeneralInfoEntity; import io.split.android.client.storage.db.SplitEntity; import io.split.android.client.storage.db.SplitRoomDatabase; +import io.split.android.client.storage.splits.PersistentSplitsStorage; import io.split.android.client.storage.splits.ProcessedSplitChange; +import io.split.android.client.storage.splits.SplitsSnapshot; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.storage.splits.SplitsStorageImpl; import io.split.android.client.storage.splits.SqLitePersistentSplitsStorage; @@ -534,6 +540,34 @@ public void nullFlagsSpecValueIsValid() { assertEquals("", flagsSpec); } + @Test + public void asyncPersistentUpdateReceivesMetadataSnapshot() { + CapturingPersistentSplitsStorage persistentStorage = new CapturingPersistentSplitsStorage(); + ControlledExecutorService executor = new ControlledExecutorService(); + SplitsStorage splitsStorage = new SplitsStorageImpl(persistentStorage); + + splitsStorage.update( + new ProcessedSplitChange( + Collections.singletonList(newSplit("split_1", Status.ACTIVE, "type_1", Collections.singleton("set_1"))), + Collections.emptyList(), + 1L, + 0L), + executor); + splitsStorage.update( + new ProcessedSplitChange( + Collections.singletonList(newSplit("split_2", Status.ACTIVE, "type_2", Collections.singleton("set_2"))), + Collections.emptyList(), + 2L, + 0L), + executor); + + executor.runNext(); + + assertEquals(Collections.singletonMap("type_1", 1), persistentStorage.lastTrafficTypes); + assertEquals(Collections.singleton("split_1"), persistentStorage.lastFlagSets.get("set_1")); + assertNull(persistentStorage.lastFlagSets.get("set_2")); + } + private Split newSplit(String name, Status status, String trafficType) { return newSplit(name, status, trafficType, Collections.emptySet()); } @@ -564,4 +598,108 @@ private static SplitEntity newSplitEntity(String name, String trafficType, Set tasks = new ConcurrentLinkedQueue<>(); + + void runNext() { + Runnable task = tasks.poll(); + assertNotNull(task); + task.run(); + } + + @Override + public void shutdown() { + } + + @Override + public List shutdownNow() { + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return false; + } + + @Override + public void execute(Runnable command) { + tasks.add(command); + } + } + + private static class CapturingPersistentSplitsStorage implements PersistentSplitsStorage { + Map lastTrafficTypes; + Map> lastFlagSets; + + @Override + public boolean update(ProcessedSplitChange splitChange, Map trafficTypes, Map> flagSets) { + lastTrafficTypes = new HashMap<>(trafficTypes); + lastFlagSets = new HashMap<>(); + for (Map.Entry> entry : flagSets.entrySet()) { + lastFlagSets.put(entry.getKey(), new HashSet<>(entry.getValue())); + } + return true; + } + + @Override + public SplitsSnapshot getSnapshot() { + return new SplitsSnapshot(Collections.emptyList(), -1L, 0L, "", "", Collections.emptyMap(), Collections.emptyMap()); + } + + @Override + public List getAll() { + return Collections.emptyList(); + } + + @Override + public void update(Split splitName) { + // no-op + } + + @Override + public String getFilterQueryString() { + return ""; + } + + @Override + public void updateFilterQueryString(String queryString) { + // no-op + } + + @Override + public String getFlagsSpec() { + return ""; + } + + @Override + public void updateFlagsSpec(String flagsSpec) { + // no-op + } + + @Override + public void delete(List splitNames) { + // no-op + } + + @Override + public void clear() { + // no-op + } + + @Override + public void close() { + // no-op + } + } } diff --git a/main/src/androidTest/java/tests/workmanager/WorkManagerWrapperTest.java b/main/src/androidTest/java/tests/workmanager/WorkManagerWrapperTest.java index 8f3539423..b2865386f 100644 --- a/main/src/androidTest/java/tests/workmanager/WorkManagerWrapperTest.java +++ b/main/src/androidTest/java/tests/workmanager/WorkManagerWrapperTest.java @@ -34,7 +34,7 @@ import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitFilter; import io.split.android.client.network.CertificatePinningConfiguration; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.synchronizer.WorkManagerWrapper; import io.split.android.client.service.workmanager.EventsRecorderWorker; import io.split.android.client.service.workmanager.ImpressionsRecorderWorker; diff --git a/main/src/main/java/io/split/android/client/EventsTracker.java b/main/src/main/java/io/split/android/client/EventsTracker.java deleted file mode 100644 index 800b8c0c2..000000000 --- a/main/src/main/java/io/split/android/client/EventsTracker.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.split.android.client; - -import java.util.Map; - -public interface EventsTracker { - void enableTracking(boolean enable); - boolean track(String key, String trafficType, String eventType, double value, Map properties, boolean isSdkReady); -} \ No newline at end of file diff --git a/main/src/main/java/io/split/android/client/EventsTrackerImpl.java b/main/src/main/java/io/split/android/client/EventsTrackerImpl.java deleted file mode 100644 index 0b8d18982..000000000 --- a/main/src/main/java/io/split/android/client/EventsTrackerImpl.java +++ /dev/null @@ -1,98 +0,0 @@ -package io.split.android.client; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import androidx.annotation.NonNull; - -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.split.android.client.dtos.Event; -import io.split.android.client.service.synchronizer.SyncManager; -import io.split.android.client.telemetry.model.Method; -import io.split.android.client.telemetry.storage.TelemetryStorageProducer; -import io.split.android.client.utils.logger.Logger; -import io.split.android.client.validators.EventValidator; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationErrorInfo; -import io.split.android.client.validators.ValidationMessageLogger; - -public class EventsTrackerImpl implements EventsTracker { - // Estimated event size without properties - private final static int ESTIMATED_EVENT_SIZE_WITHOUT_PROPS = 1024; - - private final EventValidator mEventValidator; - private final ValidationMessageLogger mValidationLogger; - private final TelemetryStorageProducer mTelemetryStorageProducer; - private final PropertyValidator mPropertyValidator; - private final SyncManager mSyncManager; - private final AtomicBoolean isTrackingEnabled = new AtomicBoolean(true); - - public EventsTrackerImpl(@NonNull EventValidator eventValidator, - @NonNull ValidationMessageLogger validationLogger, - @NonNull TelemetryStorageProducer telemetryStorageProducer, - @NonNull PropertyValidator eventPropertiesProcessor, - @NonNull SyncManager syncManager) { - - mEventValidator = checkNotNull(eventValidator); - mValidationLogger = checkNotNull(validationLogger); - mTelemetryStorageProducer = checkNotNull(telemetryStorageProducer); - mPropertyValidator = checkNotNull(eventPropertiesProcessor); - mSyncManager = checkNotNull(syncManager); - } - - public void enableTracking(boolean enable) { - isTrackingEnabled.set(enable); - } - - public boolean track(String key, String trafficType, String eventType, - double value, Map properties, boolean isSdkReady) { - - if (!isTrackingEnabled.get()) { - Logger.v("Event not tracked because tracking is disabled"); - return false; - } - - try { - final String validationTag = "track"; - - Event event = new Event(); - event.eventTypeId = eventType; - event.trafficTypeName = trafficType; - event.key = key; - event.value = value; - event.timestamp = System.currentTimeMillis(); - event.properties = properties; - - ValidationErrorInfo errorInfo = mEventValidator.validate(event, isSdkReady); - if (errorInfo != null) { - - if (errorInfo.isError()) { - mValidationLogger.e(errorInfo, validationTag); - return false; - } - mValidationLogger.w(errorInfo, validationTag); - event.trafficTypeName = event.trafficTypeName.toLowerCase(); - } - - PropertyValidator.Result processedProperties = - mPropertyValidator.validate(event.properties, validationTag); - if (!processedProperties.isValid()) { - return false; - } - - long startTime = System.currentTimeMillis(); - - event.properties = processedProperties.getProperties(); - event.setSizeInBytes(ESTIMATED_EVENT_SIZE_WITHOUT_PROPS + processedProperties.getSizeInBytes()); - mSyncManager.pushEvent(event); - - mTelemetryStorageProducer.recordLatency(Method.TRACK, System.currentTimeMillis() - startTime); - - return true; - } catch (Exception exception) { - mTelemetryStorageProducer.recordException(Method.TRACK); - } - return false; - } -} diff --git a/main/src/main/java/io/split/android/client/PropertyValidatorImpl.java b/main/src/main/java/io/split/android/client/PropertyValidatorImpl.java deleted file mode 100644 index 01cc06ef6..000000000 --- a/main/src/main/java/io/split/android/client/PropertyValidatorImpl.java +++ /dev/null @@ -1,63 +0,0 @@ -package io.split.android.client; - -import java.util.HashMap; -import java.util.Map; - -import io.split.android.client.utils.logger.Logger; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationConfig; - - -public class PropertyValidatorImpl implements PropertyValidator { - - private final static int MAX_PROPS_COUNT = 300; - private final static int MAXIMUM_EVENT_PROPERTY_BYTES = - ValidationConfig.getInstance().getMaximumEventPropertyBytes(); - - @Override - public Result validate(Map properties, String validationTag) { - if (properties == null) { - return Result.valid(null, 0); - } - - if (properties.size() > MAX_PROPS_COUNT) { - Logger.w(validationTag + "Event has more than " + MAX_PROPS_COUNT + - " properties. Some of them will be trimmed when processed"); - } - int sizeInBytes = 0; - Map finalProperties = new HashMap<>(properties); - - for (Map.Entry entry : properties.entrySet()) { - Object value = entry.getValue(); - String key = entry.getKey(); - - if (value != null && isInvalidValueType(value)) { - finalProperties.put(key, null); - } - sizeInBytes += calculateEventSizeInBytes(key, value); - - if (sizeInBytes > MAXIMUM_EVENT_PROPERTY_BYTES) { - Logger.w(validationTag + - "The maximum size allowed for the " + - " properties is 32kb. Current is " + key + - ". Event not queued"); - return Result.invalid("Event properties size is too large", sizeInBytes); - } - } - return Result.valid(finalProperties, sizeInBytes); - } - - private static boolean isInvalidValueType(Object value) { - return !(value instanceof Number) && - !(value instanceof Boolean) && - !(value instanceof String); - } - - private static int calculateEventSizeInBytes(String key, Object value) { - int valueSize = 0; - if(value != null && value.getClass() == String.class) { - valueSize = value.toString().getBytes().length; - } - return valueSize + key.getBytes().length; - } -} diff --git a/main/src/main/java/io/split/android/client/RetryBackoffCounterTimerFactory.java b/main/src/main/java/io/split/android/client/RetryBackoffCounterTimerFactory.java index 7e2ec611d..6c0154549 100644 --- a/main/src/main/java/io/split/android/client/RetryBackoffCounterTimerFactory.java +++ b/main/src/main/java/io/split/android/client/RetryBackoffCounterTimerFactory.java @@ -1,13 +1,13 @@ package io.split.android.client; import io.split.android.client.service.executor.SplitTaskExecutor; -import io.split.android.client.service.sseclient.FixedIntervalBackoffCounter; -import io.split.android.client.service.sseclient.ReconnectBackoffCounter; +import io.split.android.client.backoff.FixedIntervalBackoffCounter; +import io.split.android.client.backoff.ExponentialBackoffCounter; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; public class RetryBackoffCounterTimerFactory { public RetryBackoffCounterTimer create(SplitTaskExecutor splitTaskExecutor, int base) { - return new RetryBackoffCounterTimer(splitTaskExecutor, new ReconnectBackoffCounter(base)); + return new RetryBackoffCounterTimer(splitTaskExecutor, new ExponentialBackoffCounter(base)); } public RetryBackoffCounterTimer createWithFixedInterval(SplitTaskExecutor splitTaskExecutor, int retryIntervalInSeconds, int maxAttempts) { diff --git a/main/src/main/java/io/split/android/client/SplitClientConfig.java b/main/src/main/java/io/split/android/client/SplitClientConfig.java index 0d5b0ecc8..3fd0a845d 100644 --- a/main/src/main/java/io/split/android/client/SplitClientConfig.java +++ b/main/src/main/java/io/split/android/client/SplitClientConfig.java @@ -13,9 +13,9 @@ import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; -import io.split.android.client.main.BuildConfig; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.network.CertificatePinningConfiguration; +import io.split.android.client.network.SdkVersionProvider; import io.split.android.client.network.DevelopmentSslConfig; import io.split.android.client.network.HttpProxy; import io.split.android.client.network.ProxyConfiguration; @@ -242,7 +242,7 @@ private SplitClientConfig(String endpoint, mUserConsent = userConsent; - splitSdkVersion = "Android-" + BuildConfig.SPLIT_VERSION_NAME; + splitSdkVersion = SdkVersionProvider.getSdkVersion(); mShouldRecordTelemetry = shouldRecordTelemetry; diff --git a/main/src/main/java/io/split/android/client/SplitClientImpl.java b/main/src/main/java/io/split/android/client/SplitClientImpl.java index 571efa169..c3795a416 100644 --- a/main/src/main/java/io/split/android/client/SplitClientImpl.java +++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java @@ -18,6 +18,7 @@ import io.split.android.client.events.SplitEventsManager; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.tracker.Tracker; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.SplitValidator; import io.split.android.client.validators.TreatmentManager; @@ -35,7 +36,7 @@ public final class SplitClientImpl implements SplitClient { private final TreatmentManager mTreatmentManager; private final ValidationMessageLogger mValidationLogger; private final AttributesManager mAttributesManager; - private final EventsTracker mEventsTracker; + private final Tracker mEventsTracker; private static final double TRACK_DEFAULT_VALUE = 0.0; @@ -48,7 +49,7 @@ public SplitClientImpl(SplitFactory container, ImpressionListener impressionListener, SplitClientConfig config, SplitEventsManager eventsManager, - EventsTracker eventsTracker, + Tracker eventsTracker, AttributesManager attributesManager, SplitValidator splitValidator, TreatmentManager treatmentManager) { 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..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; @@ -42,7 +42,7 @@ import io.split.android.client.service.impressions.strategy.ImpressionStrategyProvider; import io.split.android.client.service.mysegments.AllSegmentsResponseParser; import io.split.android.client.service.sseclient.EventStreamParser; -import io.split.android.client.service.sseclient.ReconnectBackoffCounter; +import io.split.android.client.backoff.ExponentialBackoffCounter; import io.split.android.client.service.sseclient.SseJwtParser; import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; import io.split.android.client.service.sseclient.notifications.InstantUpdateChangeNotification; @@ -54,13 +54,22 @@ 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.SseClientImpl; +import io.split.android.client.service.sseclient.sseclient.HttpClientStreamingTransport; +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; 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; @@ -274,7 +283,7 @@ SyncManager buildSyncManager(SplitClientConfig config, BackoffCounterTimer backoffCounterTimer = null; if (config.syncEnabled()) { - backoffCounterTimer = new BackoffCounterTimer(splitTaskExecutor, new ReconnectBackoffCounter(1)); + backoffCounterTimer = new BackoffCounterTimer(splitTaskExecutor, new ExponentialBackoffCounter(1)); } return new SyncManagerImpl(config, @@ -288,18 +297,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,18 +317,21 @@ 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 EventStreamParser(), + EventSourceClientImpl eventSourceClient = new EventSourceClientImpl( + new HttpClientStreamingTransport(httpClient), + new EventStreamParser()); + + return new DefaultSseClient(URI.create(streamingServiceUrlString), + eventSourceClient, sseHandler); } @@ -396,22 +409,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/SplitFactoryImpl.java b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java index 8bb12d71f..b010fa6ee 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -20,9 +20,11 @@ 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; +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; @@ -32,7 +34,9 @@ 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; import io.split.android.client.service.SplitApiFacade; import io.split.android.client.service.executor.SplitSingleThreadTaskExecutor; @@ -66,15 +70,21 @@ import io.split.android.client.storage.general.GeneralInfoStorage; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.TelemetrySynchronizer; +import io.split.android.client.dtos.Event; +import io.split.android.client.telemetry.model.Method; import io.split.android.client.telemetry.storage.TelemetryStorage; +import io.split.android.client.tracker.DefaultTracker; +import io.split.android.client.tracker.Tracker; +import io.split.android.client.tracker.TrackerEvent; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.ApiKeyValidator; import io.split.android.client.validators.ApiKeyValidatorImpl; -import io.split.android.client.validators.EventValidator; import io.split.android.client.validators.EventValidatorImpl; import io.split.android.client.validators.KeyValidator; import io.split.android.client.validators.KeyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorImpl; import io.split.android.client.validators.SplitValidatorImpl; +import io.split.android.client.validators.TrafficTypeValidatorImpl; import io.split.android.client.validators.ValidationConfig; import io.split.android.client.validators.ValidationErrorInfo; import io.split.android.client.validators.ValidationMessageLogger; @@ -381,20 +391,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()) - .setContext(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) { @@ -411,6 +413,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; @@ -536,7 +551,7 @@ public static class EventsTrackerProvider { private final SplitsStorage mSplitsStorage; private final TelemetryStorage mTelemetryStorage; private final SyncManager mSyncManager; - private volatile EventsTracker mEventsTracker; + private volatile Tracker mEventsTracker; public EventsTrackerProvider(SplitsStorage splitsStorage, TelemetryStorage telemetryStorage, SyncManager syncManager) { mSplitsStorage = splitsStorage; @@ -544,13 +559,32 @@ public EventsTrackerProvider(SplitsStorage splitsStorage, TelemetryStorage telem mSyncManager = syncManager; } - public EventsTracker getEventsTracker() { + public Tracker getEventsTracker() { if (mEventsTracker == null) { synchronized (this) { if (mEventsTracker == null) { - EventValidator eventsValidator = new EventValidatorImpl(new KeyValidatorImpl(), mSplitsStorage); - mEventsTracker = new EventsTrackerImpl(eventsValidator, new ValidationMessageLoggerImpl(), mTelemetryStorage, - new PropertyValidatorImpl(), mSyncManager); + mEventsTracker = new DefaultTracker( + new EventValidatorImpl( + new KeyValidatorImpl(), + new TrafficTypeValidatorImpl(mSplitsStorage) + ), + new ValidationMessageLoggerImpl(), + new PropertyValidatorImpl( + new ValidationMessageLoggerImpl() + ), + trackerEvent -> { + Event event = new Event(); + event.eventTypeId = trackerEvent.eventType; + event.trafficTypeName = trackerEvent.trafficType; + event.key = trackerEvent.key; + event.value = trackerEvent.value; + event.timestamp = trackerEvent.timestamp; + event.properties = trackerEvent.properties; + event.setSizeInBytes(trackerEvent.sizeInBytes); + mSyncManager.pushEvent(event); + }, + latencyMs -> mTelemetryStorage.recordLatency(Method.TRACK, latencyMs), + () -> mTelemetryStorage.recordException(Method.TRACK)); } } } diff --git a/main/src/main/java/io/split/android/client/dtos/Event.java b/main/src/main/java/io/split/android/client/dtos/Event.java index fe8c986d9..d10397363 100644 --- a/main/src/main/java/io/split/android/client/dtos/Event.java +++ b/main/src/main/java/io/split/android/client/dtos/Event.java @@ -3,7 +3,7 @@ import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; -import io.split.android.client.storage.common.InBytesSizable; +import io.split.android.client.submitter.InBytesSizable; import io.split.android.client.utils.deserializer.EventDeserializer; @JsonAdapter(EventDeserializer.class) diff --git a/main/src/main/java/io/split/android/client/dtos/KeyImpression.java b/main/src/main/java/io/split/android/client/dtos/KeyImpression.java index 8bf7f2e7e..6cd3795e8 100644 --- a/main/src/main/java/io/split/android/client/dtos/KeyImpression.java +++ b/main/src/main/java/io/split/android/client/dtos/KeyImpression.java @@ -6,7 +6,7 @@ import java.util.Objects; import io.split.android.client.service.ServiceConstants; -import io.split.android.client.storage.common.InBytesSizable; +import io.split.android.client.submitter.InBytesSizable; import io.split.android.client.impressions.Impression; public class KeyImpression implements InBytesSizable, Identifiable { diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java index 1b5e58499..5fb309e76 100644 --- a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java +++ b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java @@ -16,7 +16,8 @@ import io.split.android.client.EvaluationOptions; import io.split.android.client.EvaluatorImpl; import io.split.android.client.FlagSetsFilter; -import io.split.android.client.PropertyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorAdapter; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitFactory; @@ -87,7 +88,9 @@ public LocalhostSplitClient(@NonNull LocalhostSplitFactory container, new SplitValidatorImpl(), getImpressionsListener(splitClientConfig), splitClientConfig.labelsEnabled(), eventsManager, attributesManager, attributesMerger, telemetryStorageProducer, flagSetsFilter, splitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), - new PropertyValidatorImpl(), mFallbackTreatmentsCalculator); + new PropertyValidatorAdapter( + new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), + mFallbackTreatmentsCalculator); } @Override diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostTrafficTypeValidator.java b/main/src/main/java/io/split/android/client/localhost/LocalhostTrafficTypeValidator.java new file mode 100644 index 000000000..2b4ed2010 --- /dev/null +++ b/main/src/main/java/io/split/android/client/localhost/LocalhostTrafficTypeValidator.java @@ -0,0 +1,18 @@ +package io.split.android.client.localhost; + +import io.split.android.client.tracker.TrafficTypeValidator; + +/** + * Traffic type validator for localhost mode. + *

+ * In localhost mode, all traffic types are considered valid since we're not + * connected to the Split backend and can't validate against real feature flags. + */ +public class LocalhostTrafficTypeValidator implements TrafficTypeValidator { + + @Override + public boolean isValid(String trafficTypeName) { + // In localhost mode, accept all traffic types + return true; + } +} 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/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/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/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..162fcee9b --- /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} interface with the + * :main module's {@link LegacyTlsUpdater} class. + */ +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/main/java/io/split/android/client/network/SdkVersionProvider.java b/main/src/main/java/io/split/android/client/network/SdkVersionProvider.java new file mode 100644 index 000000000..9198e60fd --- /dev/null +++ b/main/src/main/java/io/split/android/client/network/SdkVersionProvider.java @@ -0,0 +1,10 @@ +package io.split.android.client.network; + +import io.split.android.client.main.BuildConfig; + +public class SdkVersionProvider { + + public static String getSdkVersion() { + return "Android-" + BuildConfig.SPLIT_VERSION_NAME; + } +} diff --git a/main/src/main/java/io/split/android/client/service/CleanUpDatabaseTask.java b/main/src/main/java/io/split/android/client/service/CleanUpDatabaseTask.java index 1eca9ba92..a487d9ef4 100644 --- a/main/src/main/java/io/split/android/client/service/CleanUpDatabaseTask.java +++ b/main/src/main/java/io/split/android/client/service/CleanUpDatabaseTask.java @@ -4,7 +4,7 @@ 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.SplitTaskType; import io.split.android.client.service.impressions.observer.PersistentImpressionsObserverCacheStorage; import io.split.android.client.storage.events.PersistentEventsStorage; import io.split.android.client.storage.impressions.PersistentImpressionsCountStorage; diff --git a/main/src/main/java/io/split/android/client/service/HttpRecorderSubmitterAdapter.java b/main/src/main/java/io/split/android/client/service/HttpRecorderSubmitterAdapter.java new file mode 100644 index 000000000..beb51fa7b --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/HttpRecorderSubmitterAdapter.java @@ -0,0 +1,28 @@ +package io.split.android.client.service; + +import androidx.annotation.NonNull; + +import io.split.android.client.service.http.HttpRecorder; +import io.split.android.client.service.http.HttpRecorderException; +import io.split.android.client.service.http.HttpStatus; +import io.split.android.client.submitter.RecorderException; +import io.split.android.client.submitter.RecorderSubmitter; + +public class HttpRecorderSubmitterAdapter implements RecorderSubmitter { + private final HttpRecorder mHttpRecorder; + + public HttpRecorderSubmitterAdapter(@NonNull HttpRecorder httpRecorder) { + mHttpRecorder = httpRecorder; + } + + @Override + public void execute(@NonNull T data) throws RecorderException { + try { + mHttpRecorder.execute(data); + } catch (HttpRecorderException e) { + Integer httpStatus = e.getHttpStatus(); + boolean retryable = !HttpStatus.isNotRetryable(HttpStatus.fromCode(httpStatus)); + throw new RecorderException(e.getMessage(), httpStatus, retryable); + } + } +} diff --git a/main/src/main/java/io/split/android/client/service/SplitTaskType.java b/main/src/main/java/io/split/android/client/service/SplitTaskType.java new file mode 100644 index 000000000..0ee0d016e --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/SplitTaskType.java @@ -0,0 +1,25 @@ +package io.split.android.client.service; + +public enum SplitTaskType implements io.split.android.client.service.executor.SplitTaskType { + SPLITS_SYNC, + MY_SEGMENTS_SYNC, + EVENTS_RECORDER, + IMPRESSIONS_RECORDER, + LOAD_LOCAL_SPLITS, + LOAD_LOCAL_MY_SEGMENTS, + SSE_AUTHENTICATION_TASK, + SPLIT_KILL, + FILTER_SPLITS_CACHE, + CLEAN_UP_DATABASE, + IMPRESSIONS_COUNT_RECORDER, + SAVE_IMPRESSIONS_COUNT, + MY_SEGMENTS_UPDATE, + LOAD_LOCAL_ATTRIBUTES, + TELEMETRY_CONFIG_TASK, + TELEMETRY_STATS_TASK, + SAVE_UNIQUE_KEYS_TASK, + UNIQUE_KEYS_RECORDER_TASK, + MY_LARGE_SEGMENTS_UPDATE, + LOAD_LOCAL_RULE_BASED_SEGMENTS, + RULE_BASED_SEGMENT_SYNC, +} diff --git a/main/src/main/java/io/split/android/client/service/TelemetryRecorderAdapter.java b/main/src/main/java/io/split/android/client/service/TelemetryRecorderAdapter.java new file mode 100644 index 000000000..87610860f --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/TelemetryRecorderAdapter.java @@ -0,0 +1,33 @@ +package io.split.android.client.service; + +import androidx.annotation.NonNull; + +import io.split.android.client.submitter.RecorderTelemetry; +import io.split.android.client.telemetry.model.OperationType; +import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; + +public class TelemetryRecorderAdapter implements RecorderTelemetry { + private final TelemetryRuntimeProducer mTelemetryProducer; + private final OperationType mOperationType; + + public TelemetryRecorderAdapter(@NonNull TelemetryRuntimeProducer telemetryProducer, + @NonNull OperationType operationType) { + mTelemetryProducer = telemetryProducer; + mOperationType = operationType; + } + + @Override + public void recordSuccess(long timestamp) { + mTelemetryProducer.recordSuccessfulSync(mOperationType, timestamp); + } + + @Override + public void recordError(Integer httpStatus) { + mTelemetryProducer.recordSyncError(mOperationType, httpStatus); + } + + @Override + public void recordLatency(long latencyMs) { + mTelemetryProducer.recordSyncLatency(mOperationType, latencyMs); + } +} diff --git a/main/src/main/java/io/split/android/client/service/attributes/LoadAttributesTask.java b/main/src/main/java/io/split/android/client/service/attributes/LoadAttributesTask.java index fd7883012..58cebeee2 100644 --- a/main/src/main/java/io/split/android/client/service/attributes/LoadAttributesTask.java +++ b/main/src/main/java/io/split/android/client/service/attributes/LoadAttributesTask.java @@ -7,7 +7,7 @@ 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.SplitTaskType; import io.split.android.client.storage.attributes.AttributesStorage; import io.split.android.client.storage.attributes.PersistentAttributesStorage; diff --git a/main/src/main/java/io/split/android/client/service/events/EventsRecorderTask.java b/main/src/main/java/io/split/android/client/service/events/EventsRecorderTask.java index d380af4e7..e26d121da 100644 --- a/main/src/main/java/io/split/android/client/service/events/EventsRecorderTask.java +++ b/main/src/main/java/io/split/android/client/service/events/EventsRecorderTask.java @@ -1,116 +1,42 @@ package io.split.android.client.service.events; -import static io.split.android.client.utils.Utils.checkNotNull; -import static io.split.android.client.utils.Utils.partition; - import androidx.annotation.NonNull; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import io.split.android.client.dtos.Event; -import io.split.android.client.service.executor.SplitTask; -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.HttpRecorderSubmitterAdapter; +import io.split.android.client.service.TelemetryRecorderAdapter; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; -import io.split.android.client.service.http.HttpRecorderException; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.storage.events.PersistentEventsStorage; +import io.split.android.client.submitter.RecorderTask; import io.split.android.client.telemetry.model.OperationType; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; -import io.split.android.client.utils.logger.Logger; -public class EventsRecorderTask implements SplitTask { - public final static int FAILING_CHUNK_SIZE = 20; - private final PersistentEventsStorage mPersistentEventsStorage; - private final HttpRecorder> mHttpRecorder; - private final EventsRecorderTaskConfig mConfig; - private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; +public class EventsRecorderTask extends RecorderTask> { + + public static final int FAILING_CHUNK_SIZE = 20; public EventsRecorderTask(@NonNull HttpRecorder> httpRecorder, - @NonNull PersistentEventsStorage persistentEventsStorage, + @NonNull PersistentEventsStorage storage, @NonNull EventsRecorderTaskConfig config, @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer) { - mHttpRecorder = checkNotNull(httpRecorder); - mPersistentEventsStorage = checkNotNull(persistentEventsStorage); - mConfig = checkNotNull(config); - mTelemetryRuntimeProducer = checkNotNull(telemetryRuntimeProducer); + super(storage, + new HttpRecorderSubmitterAdapter<>(httpRecorder), + config.getEventsPerPush(), + SplitTaskType.EVENTS_RECORDER, + new TelemetryRecorderAdapter(telemetryRuntimeProducer, OperationType.EVENTS), + FAILING_CHUNK_SIZE); } @Override - @NonNull - public SplitTaskExecutionInfo execute() { - SplitTaskExecutionStatus status = SplitTaskExecutionStatus.SUCCESS; - int nonSentRecords = 0; - long nonSentBytes = 0; - List events; - List failingEvents = new ArrayList<>(); - boolean doNotRetry = false; - do { - events = mPersistentEventsStorage.pop(mConfig.getEventsPerPush()); - if (events.size() > 0) { - long startTime = System.currentTimeMillis(); - long latency = 0; - try { - Logger.d("Posting %d Split events", events.size()); - mHttpRecorder.execute(events); - - long now = System.currentTimeMillis(); - latency = now - startTime; - mTelemetryRuntimeProducer.recordSuccessfulSync(OperationType.EVENTS, now); - - mPersistentEventsStorage.delete(events); - Logger.d("%d split events sent", events.size()); - } catch (HttpRecorderException e) { - status = SplitTaskExecutionStatus.ERROR; - nonSentRecords += mConfig.getEventsPerPush(); - nonSentBytes += sumEventBytes(events); - Logger.e("Event recorder task: Some events couldn't be sent" + - "Saving to send them in a new iteration: " + - e.getLocalizedMessage()); - failingEvents.addAll(events); - - mTelemetryRuntimeProducer.recordSyncError(OperationType.EVENTS, e.getHttpStatus()); - - if (HttpStatus.isNotRetryable(e.getHttpStatus())) { - doNotRetry = true; - break; - } - } finally { - mTelemetryRuntimeProducer.recordSyncLatency(OperationType.EVENTS, latency); - } - } - } while (events.size() == mConfig.getEventsPerPush()); - - // Update events by chunks to avoid sqlite errors - List> failingChunks = partition(failingEvents, FAILING_CHUNK_SIZE); - for (List chunk : failingChunks) { - mPersistentEventsStorage.setActive(chunk); - } - - if (status == SplitTaskExecutionStatus.ERROR) { - Map data = new HashMap<>(); - data.put(SplitTaskExecutionInfo.NON_SENT_RECORDS, nonSentRecords); - data.put(SplitTaskExecutionInfo.NON_SENT_BYTES, nonSentBytes); - if (doNotRetry) { - data.put(SplitTaskExecutionInfo.DO_NOT_RETRY, true); - } - - return SplitTaskExecutionInfo.error( - SplitTaskType.EVENTS_RECORDER, data); - } - return SplitTaskExecutionInfo.success(SplitTaskType.EVENTS_RECORDER); + protected List transformForSubmission(List items) { + return items; } - private long sumEventBytes(List events) { - long totalBytes = 0; - for (Event event : events) { - totalBytes += event.getSizeInBytes(); - } - return totalBytes; + @Override + protected long estimateItemSize(Event item) { + return item.getSizeInBytes(); } } diff --git a/main/src/main/java/io/split/android/client/service/executor/SplitTaskFactoryImpl.java b/main/src/main/java/io/split/android/client/service/executor/SplitTaskFactoryImpl.java index 3dd20800c..d6038ee17 100644 --- a/main/src/main/java/io/split/android/client/service/executor/SplitTaskFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/service/executor/SplitTaskFactoryImpl.java @@ -44,7 +44,7 @@ import io.split.android.client.service.splits.SplitsSyncTask; import io.split.android.client.service.splits.SplitsUpdateTask; import io.split.android.client.service.splits.TargetingRulesCache; -import io.split.android.client.service.sseclient.ReconnectBackoffCounter; +import io.split.android.client.backoff.ExponentialBackoffCounter; import io.split.android.client.service.telemetry.TelemetryConfigRecorderTask; import io.split.android.client.service.telemetry.TelemetryStatsRecorderTask; import io.split.android.client.service.telemetry.TelemetryTaskFactory; @@ -104,7 +104,7 @@ public SplitTaskFactoryImpl(@NonNull SplitClientConfig splitClientConfig, ruleBasedSegmentStorageProducer, mSplitsStorageContainer.getGeneralInfoStorage(), mTelemetryRuntimeProducer, - new ReconnectBackoffCounter(1, testingConfig.getCdnBackoffTime()), + new ExponentialBackoffCounter(1, testingConfig.getCdnBackoffTime()), flagsSpecFromConfig, targetingRulesCache); } else { diff --git a/main/src/main/java/io/split/android/client/service/impressions/ImpressionsCountRecorderTask.java b/main/src/main/java/io/split/android/client/service/impressions/ImpressionsCountRecorderTask.java index cd761d75b..32ae11600 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/ImpressionsCountRecorderTask.java +++ b/main/src/main/java/io/split/android/client/service/impressions/ImpressionsCountRecorderTask.java @@ -1,96 +1,34 @@ package io.split.android.client.service.impressions; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.NonNull; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; +import io.split.android.client.service.HttpRecorderSubmitterAdapter; import io.split.android.client.service.ServiceConstants; -import io.split.android.client.service.executor.SplitTask; -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.TelemetryRecorderAdapter; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; -import io.split.android.client.service.http.HttpRecorderException; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.storage.impressions.PersistentImpressionsCountStorage; +import io.split.android.client.submitter.RecorderTask; import io.split.android.client.telemetry.model.OperationType; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; -import io.split.android.client.utils.logger.Logger; -public class ImpressionsCountRecorderTask implements SplitTask { - private final PersistentImpressionsCountStorage mPersistentStorage; - private final HttpRecorder mHttpRecorder; - private static int POP_COUNT = ServiceConstants.DEFAULT_IMPRESSION_COUNT_ROWS_POP; - private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; +public class ImpressionsCountRecorderTask extends RecorderTask { public ImpressionsCountRecorderTask(@NonNull HttpRecorder httpRecorder, @NonNull PersistentImpressionsCountStorage persistentStorage, @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer) { - mHttpRecorder = checkNotNull(httpRecorder); - mPersistentStorage = checkNotNull(persistentStorage); - mTelemetryRuntimeProducer = checkNotNull(telemetryRuntimeProducer); + super(persistentStorage, + new HttpRecorderSubmitterAdapter<>(httpRecorder), + ServiceConstants.DEFAULT_IMPRESSION_COUNT_ROWS_POP, + SplitTaskType.IMPRESSIONS_COUNT_RECORDER, + new TelemetryRecorderAdapter(telemetryRuntimeProducer, OperationType.IMPRESSIONS_COUNT), + 0); } @Override - @NonNull - public SplitTaskExecutionInfo execute() { - SplitTaskExecutionStatus status = SplitTaskExecutionStatus.SUCCESS; - - List countList = new ArrayList<>(); - List failedSent = new ArrayList<>(); - boolean doNotRetry = false; - do { - countList = mPersistentStorage.pop(POP_COUNT); - if (countList.size() > 0) { - long startTime = System.currentTimeMillis(); - long latency = 0; - try { - Logger.d("Posting %d Split impressions count", countList.size()); - mHttpRecorder.execute(new ImpressionsCount(countList)); - - long now = System.currentTimeMillis(); - latency = now - startTime; - mTelemetryRuntimeProducer.recordSuccessfulSync(OperationType.IMPRESSIONS_COUNT, now); - - mPersistentStorage.delete(countList); - Logger.d("%d split impressions count sent", countList.size()); - } catch (HttpRecorderException e) { - status = SplitTaskExecutionStatus.ERROR; - Logger.e("Impressions count recorder task: Some counts couldn't be sent. " + - "Saving to send them in a new iteration\n" + - e.getLocalizedMessage()); - failedSent.addAll(countList); - - mTelemetryRuntimeProducer.recordSyncError(OperationType.IMPRESSIONS_COUNT, e.getHttpStatus()); - - if (HttpStatus.isNotRetryable(HttpStatus.fromCode(e.getHttpStatus()))) { - doNotRetry = true; - break; - } - } finally { - mTelemetryRuntimeProducer.recordSyncLatency(OperationType.IMPRESSIONS_COUNT, latency); - } - } - } while (countList.size() == POP_COUNT); - - if (failedSent.size() > 0) { - mPersistentStorage.setActive(failedSent); - } - - if (status == SplitTaskExecutionStatus.ERROR) { - Map data = new HashMap<>(); - if (doNotRetry) { - data.put(SplitTaskExecutionInfo.DO_NOT_RETRY, true); - } - - return SplitTaskExecutionInfo.error(SplitTaskType.IMPRESSIONS_COUNT_RECORDER, data); - } - - return SplitTaskExecutionInfo.success(SplitTaskType.IMPRESSIONS_COUNT_RECORDER); + protected ImpressionsCount transformForSubmission(List items) { + return new ImpressionsCount(items); } } diff --git a/main/src/main/java/io/split/android/client/service/impressions/ImpressionsRecorderTask.java b/main/src/main/java/io/split/android/client/service/impressions/ImpressionsRecorderTask.java index 7a4b122c9..d6fdabb7b 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/ImpressionsRecorderTask.java +++ b/main/src/main/java/io/split/android/client/service/impressions/ImpressionsRecorderTask.java @@ -1,112 +1,43 @@ package io.split.android.client.service.impressions; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.NonNull; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import io.split.android.client.dtos.KeyImpression; -import io.split.android.client.service.executor.SplitTask; -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.HttpRecorderSubmitterAdapter; +import io.split.android.client.service.TelemetryRecorderAdapter; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; -import io.split.android.client.service.http.HttpRecorderException; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.storage.impressions.PersistentImpressionsStorage; +import io.split.android.client.submitter.RecorderTask; import io.split.android.client.telemetry.model.OperationType; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; -import io.split.android.client.utils.logger.Logger; -public class ImpressionsRecorderTask implements SplitTask { - public final static int FAILING_CHUNK_SIZE = 20; - private final PersistentImpressionsStorage mPersistenImpressionsStorage; - private final HttpRecorder> mHttpRecorder; - private final ImpressionsRecorderTaskConfig mConfig; - private final TelemetryRuntimeProducer mTelemetryRuntimeProducer; +public class ImpressionsRecorderTask extends RecorderTask> { + + private final long mEstimatedSizeInBytes; public ImpressionsRecorderTask(@NonNull HttpRecorder> httpRecorder, - @NonNull PersistentImpressionsStorage persistenEventsStorage, + @NonNull PersistentImpressionsStorage storage, @NonNull ImpressionsRecorderTaskConfig config, @NonNull TelemetryRuntimeProducer telemetryRuntimeProducer) { - mHttpRecorder = checkNotNull(httpRecorder); - mPersistenImpressionsStorage = checkNotNull(persistenEventsStorage); - mConfig = checkNotNull(config); - mTelemetryRuntimeProducer = checkNotNull(telemetryRuntimeProducer); + super(storage, + new HttpRecorderSubmitterAdapter<>(httpRecorder), + config.getImpressionsPerPush(), + SplitTaskType.IMPRESSIONS_RECORDER, + new TelemetryRecorderAdapter(telemetryRuntimeProducer, OperationType.IMPRESSIONS), + 0); + this.mEstimatedSizeInBytes = config.getEstimatedSizeInBytes(); } @Override - @NonNull - public SplitTaskExecutionInfo execute() { - SplitTaskExecutionStatus status = SplitTaskExecutionStatus.SUCCESS; - int nonSentRecords = 0; - long nonSentBytes = 0; - List impressions; - List failingImpressions = new ArrayList<>(); - boolean doNotRetry = false; - do { - impressions = mPersistenImpressionsStorage.pop(mConfig.getImpressionsPerPush()); - if (impressions.size() > 0) { - long startTime = System.currentTimeMillis(); - long latency = 0; - try { - Logger.d("Posting %d Split impressions", impressions.size()); - mHttpRecorder.execute(impressions); - - long now = System.currentTimeMillis(); - latency = now - startTime; - mTelemetryRuntimeProducer.recordSuccessfulSync(OperationType.IMPRESSIONS, now); - - mPersistenImpressionsStorage.delete(impressions); - Logger.d("%d split impressions sent", impressions.size()); - } catch (HttpRecorderException e) { - status = SplitTaskExecutionStatus.ERROR; - nonSentRecords += mConfig.getImpressionsPerPush(); - nonSentBytes += sumImpressionsBytes(impressions); - Logger.e("Impressions recorder task: Some impressions couldn't be sent. " + - "Saving to send them in a new iteration\n" + - e.getLocalizedMessage()); - failingImpressions.addAll(impressions); - - mTelemetryRuntimeProducer.recordSyncError(OperationType.IMPRESSIONS, e.getHttpStatus()); - - if (HttpStatus.isNotRetryable(HttpStatus.fromCode(e.getHttpStatus()))) { - doNotRetry = true; - break; - } - } finally { - mTelemetryRuntimeProducer.recordSyncLatency(OperationType.IMPRESSIONS, latency); - } - } - } while (impressions.size() == mConfig.getImpressionsPerPush()); - - if (failingImpressions.size() > 0) { - mPersistenImpressionsStorage.setActive(failingImpressions); - } - - if (status == SplitTaskExecutionStatus.ERROR) { - Map data = new HashMap<>(); - data.put(SplitTaskExecutionInfo.NON_SENT_RECORDS, nonSentRecords); - data.put(SplitTaskExecutionInfo.NON_SENT_BYTES, nonSentBytes); - if (doNotRetry) { - data.put(SplitTaskExecutionInfo.DO_NOT_RETRY, true); - } - - return SplitTaskExecutionInfo.error( - SplitTaskType.IMPRESSIONS_RECORDER, data); - } - return SplitTaskExecutionInfo.success(SplitTaskType.IMPRESSIONS_RECORDER); + protected List transformForSubmission(List items) { + return items; } - private long sumImpressionsBytes(List impressions) { - long totalBytes = 0; - for (KeyImpression impression : impressions) { - totalBytes += mConfig.getEstimatedSizeInBytes(); - } - return totalBytes; + @Override + protected long estimateItemSize(KeyImpression item) { + return mEstimatedSizeInBytes; } } diff --git a/main/src/main/java/io/split/android/client/service/impressions/SaveImpressionsCountTask.java b/main/src/main/java/io/split/android/client/service/impressions/SaveImpressionsCountTask.java index 564a137c1..8e8e5c303 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/SaveImpressionsCountTask.java +++ b/main/src/main/java/io/split/android/client/service/impressions/SaveImpressionsCountTask.java @@ -6,7 +6,7 @@ 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.SplitTaskType; import io.split.android.client.storage.impressions.PersistentImpressionsCountStorage; import static io.split.android.client.utils.Utils.checkNotNull; diff --git a/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugStrategy.java b/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugStrategy.java index 79ea6dc1c..8fe7e3a99 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugStrategy.java +++ b/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugStrategy.java @@ -16,7 +16,7 @@ import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.impressions.ImpressionsTaskFactory; import io.split.android.client.service.impressions.observer.ImpressionsObserver; -import io.split.android.client.service.synchronizer.RecorderSyncHelper; +import io.split.android.client.submitter.RecorderSyncHelper; import io.split.android.client.telemetry.model.ImpressionsDataType; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; diff --git a/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugTracker.java b/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugTracker.java index 36d86ff7b..ad1d8219f 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugTracker.java +++ b/main/src/main/java/io/split/android/client/service/impressions/strategy/DebugTracker.java @@ -15,7 +15,7 @@ import io.split.android.client.service.impressions.ImpressionsTaskFactory; import io.split.android.client.service.impressions.observer.ImpressionsObserver; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; -import io.split.android.client.service.synchronizer.RecorderSyncHelper; +import io.split.android.client.submitter.RecorderSyncHelper; class DebugTracker implements PeriodicTracker { diff --git a/main/src/main/java/io/split/android/client/service/impressions/strategy/ImpressionStrategyProvider.java b/main/src/main/java/io/split/android/client/service/impressions/strategy/ImpressionStrategyProvider.java index c331ee61f..3c1a29235 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/strategy/ImpressionStrategyProvider.java +++ b/main/src/main/java/io/split/android/client/service/impressions/strategy/ImpressionStrategyProvider.java @@ -5,7 +5,7 @@ import io.split.android.client.dtos.KeyImpression; import io.split.android.client.service.ServiceConstants; import io.split.android.client.service.executor.SplitTaskExecutor; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.impressions.ImpressionManagerRetryTimerProviderImpl; import io.split.android.client.service.impressions.ImpressionsCounter; import io.split.android.client.service.impressions.ImpressionsMode; @@ -13,7 +13,7 @@ import io.split.android.client.service.impressions.observer.ImpressionsObserverImpl; import io.split.android.client.service.impressions.unique.UniqueKeysTracker; import io.split.android.client.service.impressions.unique.UniqueKeysTrackerImpl; -import io.split.android.client.service.synchronizer.RecorderSyncHelperImpl; +import io.split.android.client.submitter.RecorderSyncHelperImpl; import io.split.android.client.storage.common.SplitStorageContainer; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; diff --git a/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedStrategy.java b/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedStrategy.java index 23f7c4b7c..5c3b85323 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedStrategy.java +++ b/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedStrategy.java @@ -18,7 +18,7 @@ import io.split.android.client.service.impressions.ImpressionsCounter; import io.split.android.client.service.impressions.ImpressionsTaskFactory; import io.split.android.client.service.impressions.observer.ImpressionsObserver; -import io.split.android.client.service.synchronizer.RecorderSyncHelper; +import io.split.android.client.submitter.RecorderSyncHelper; import io.split.android.client.telemetry.model.ImpressionsDataType; import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer; diff --git a/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedTracker.java b/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedTracker.java index 5f441dfaf..3ca2bc608 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedTracker.java +++ b/main/src/main/java/io/split/android/client/service/impressions/strategy/OptimizedTracker.java @@ -15,7 +15,7 @@ import io.split.android.client.service.impressions.ImpressionsTaskFactory; import io.split.android.client.service.impressions.observer.ImpressionsObserver; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; -import io.split.android.client.service.synchronizer.RecorderSyncHelper; +import io.split.android.client.submitter.RecorderSyncHelper; class OptimizedTracker implements PeriodicTracker { diff --git a/main/src/main/java/io/split/android/client/service/impressions/unique/SaveUniqueImpressionsTask.java b/main/src/main/java/io/split/android/client/service/impressions/unique/SaveUniqueImpressionsTask.java index 2772ea031..a25148f7b 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/unique/SaveUniqueImpressionsTask.java +++ b/main/src/main/java/io/split/android/client/service/impressions/unique/SaveUniqueImpressionsTask.java @@ -13,7 +13,7 @@ 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.SplitTaskType; import io.split.android.client.storage.impressions.PersistentImpressionsUniqueStorage; public class SaveUniqueImpressionsTask implements SplitTask { diff --git a/main/src/main/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTask.java b/main/src/main/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTask.java index a258f16ce..cac00741a 100644 --- a/main/src/main/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTask.java +++ b/main/src/main/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTask.java @@ -1,7 +1,5 @@ package io.split.android.client.service.impressions.unique; -import static io.split.android.client.utils.Utils.checkNotNull; - import androidx.annotation.NonNull; import java.util.ArrayList; @@ -11,111 +9,49 @@ import java.util.Map; import java.util.Set; -import io.split.android.client.service.executor.SplitTask; -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.HttpRecorderSubmitterAdapter; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; -import io.split.android.client.service.http.HttpRecorderException; -import io.split.android.client.service.http.HttpStatus; import io.split.android.client.storage.impressions.PersistentImpressionsUniqueStorage; -import io.split.android.client.utils.logger.Logger; +import io.split.android.client.submitter.RecorderTask; -public class UniqueKeysRecorderTask implements SplitTask { +public class UniqueKeysRecorderTask extends RecorderTask { - private final HttpRecorder mHttpRecorder; - private final PersistentImpressionsUniqueStorage mStorage; - private final UniqueKeysRecorderTaskConfig mConfig; + private final long mEstimatedSizeInBytes; public UniqueKeysRecorderTask(@NonNull HttpRecorder uniqueImpressionsRecorder, @NonNull PersistentImpressionsUniqueStorage storage, @NonNull UniqueKeysRecorderTaskConfig config) { - mHttpRecorder = checkNotNull(uniqueImpressionsRecorder); - mStorage = checkNotNull(storage); - mConfig = checkNotNull(config); + super(storage, + new HttpRecorderSubmitterAdapter<>(uniqueImpressionsRecorder), + config.getElementsPerPush(), + SplitTaskType.UNIQUE_KEYS_RECORDER_TASK, + null, + 0); + this.mEstimatedSizeInBytes = config.getEstimatedSizeInBytes(); } - @NonNull @Override - public SplitTaskExecutionInfo execute() { - SplitTaskExecutionStatus status = SplitTaskExecutionStatus.SUCCESS; - int nonSentRecords = 0; - long nonSentBytes = 0; - List keys; - List failingKeys = new ArrayList<>(); - boolean doNotRetry = false; - do { - keys = mStorage.pop(mConfig.getElementsPerPush()); - if (keys.size() > 0) { - try { - Logger.d("Posting %d Split MTKs", keys.size()); - mHttpRecorder.execute(buildMTK(keys)); - - mStorage.delete(keys); - Logger.d("%d split MTKs sent", keys.size()); - } catch (HttpRecorderException e) { - status = SplitTaskExecutionStatus.ERROR; - nonSentRecords += mConfig.getElementsPerPush(); - nonSentBytes += sumImpressionsBytes(keys); - Logger.e("MTKs recorder task: Some keys couldn't be sent. " + - "Saving to send them in a new iteration\n" + - e.getLocalizedMessage()); - failingKeys.addAll(keys); - - if (HttpStatus.isNotRetryable(HttpStatus.fromCode(e.getHttpStatus()))) { - doNotRetry = true; - break; - } - } - } - } while (keys.size() == mConfig.getElementsPerPush()); - - if (failingKeys.size() > 0) { - mStorage.setActive(failingKeys); - } - - if (status == SplitTaskExecutionStatus.ERROR) { - Map data = new HashMap<>(); - data.put(SplitTaskExecutionInfo.NON_SENT_RECORDS, nonSentRecords); - data.put(SplitTaskExecutionInfo.NON_SENT_BYTES, nonSentBytes); - if (doNotRetry) { - data.put(SplitTaskExecutionInfo.DO_NOT_RETRY, true); - } - - return SplitTaskExecutionInfo.error( - SplitTaskType.UNIQUE_KEYS_RECORDER_TASK, data); - } - - return SplitTaskExecutionInfo.success(SplitTaskType.UNIQUE_KEYS_RECORDER_TASK); - } - - @NonNull - private static MTK buildMTK(List keys) { + protected MTK transformForSubmission(List items) { Map map = new HashMap<>(); - for (UniqueKey key : keys) { + for (UniqueKey key : items) { String userKey = key.getKey(); if (!map.containsKey(userKey)) { map.put(userKey, new UniqueKey(userKey, new HashSet<>())); } - UniqueKey uniqueKey = map.get(userKey); if (uniqueKey != null) { Set originalFeatures = uniqueKey.getFeatures(); Set newFeatures = key.getFeatures(); newFeatures.addAll(originalFeatures); - map.put(userKey, new UniqueKey(userKey, newFeatures)); } } - return new MTK(new ArrayList<>(map.values())); } - private long sumImpressionsBytes(List keys) { - long totalBytes = 0; - for (UniqueKey key : keys) { - totalBytes += mConfig.getEstimatedSizeInBytes(); - } - return totalBytes; + @Override + protected long estimateItemSize(UniqueKey item) { + return mEstimatedSizeInBytes; } } diff --git a/main/src/main/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfig.java b/main/src/main/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfig.java index 451d58dad..99ab45950 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfig.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfig.java @@ -1,6 +1,6 @@ package io.split.android.client.service.mysegments; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; public class LoadMySegmentsTaskConfig { diff --git a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java index 5141b887d..873b44a32 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTask.java @@ -28,8 +28,8 @@ 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.BackoffCounter; -import io.split.android.client.service.sseclient.ReconnectBackoffCounter; +import io.split.android.client.backoff.BackoffCounter; +import io.split.android.client.backoff.ExponentialBackoffCounter; import io.split.android.client.service.synchronizer.MySegmentsChangeChecker; import io.split.android.client.storage.mysegments.MySegmentsStorage; import io.split.android.client.telemetry.model.OperationType; @@ -80,7 +80,7 @@ public MySegmentsSyncTask(@NonNull HttpFetcher mySegmentsFetc config, targetSegmentsChangeNumber, targetLargeSegmentsChangeNumber, - new ReconnectBackoffCounter(1, ON_DEMAND_FETCH_BACKOFF_MAX_WAIT), + new ExponentialBackoffCounter(1, ON_DEMAND_FETCH_BACKOFF_MAX_WAIT), ServiceConstants.ON_DEMAND_FETCH_BACKOFF_MAX_RETRIES); } diff --git a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java index 77ddd812d..42cadcde1 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfig.java @@ -3,7 +3,7 @@ import androidx.annotation.NonNull; import io.split.android.client.events.SplitInternalEvent; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.telemetry.model.OperationType; public class MySegmentsSyncTaskConfig { diff --git a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfig.java b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfig.java index 5a3caf515..ad5ba08eb 100644 --- a/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfig.java +++ b/main/src/main/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfig.java @@ -3,7 +3,7 @@ import androidx.annotation.NonNull; import io.split.android.client.events.SplitInternalEvent; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.telemetry.model.streaming.UpdatesFromSSEEnum; public class MySegmentsUpdateTaskConfig { diff --git a/main/src/main/java/io/split/android/client/service/rules/LoadRuleBasedSegmentsTask.java b/main/src/main/java/io/split/android/client/service/rules/LoadRuleBasedSegmentsTask.java index ba8731c4a..3a740d6f5 100644 --- a/main/src/main/java/io/split/android/client/service/rules/LoadRuleBasedSegmentsTask.java +++ b/main/src/main/java/io/split/android/client/service/rules/LoadRuleBasedSegmentsTask.java @@ -6,7 +6,7 @@ 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.SplitTaskType; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.utils.logger.Logger; diff --git a/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java index 72c05e4a2..656b5aff0 100644 --- a/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/rules/RuleBasedSegmentInPlaceUpdateTask.java @@ -10,7 +10,7 @@ import io.split.android.client.events.metadata.EventMetadataHelpers; 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.SplitTaskType; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.utils.logger.Logger; diff --git a/main/src/main/java/io/split/android/client/service/splits/FilterSplitsInCacheTask.java b/main/src/main/java/io/split/android/client/service/splits/FilterSplitsInCacheTask.java index 2fb16db3a..6b6357f3c 100644 --- a/main/src/main/java/io/split/android/client/service/splits/FilterSplitsInCacheTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/FilterSplitsInCacheTask.java @@ -12,7 +12,7 @@ import io.split.android.client.dtos.Split; 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.SplitTaskType; import io.split.android.client.storage.splits.PersistentSplitsStorage; import io.split.android.client.utils.logger.Logger; diff --git a/main/src/main/java/io/split/android/client/service/splits/LoadSplitsTask.java b/main/src/main/java/io/split/android/client/service/splits/LoadSplitsTask.java index 49ca1d512..ab9731bb3 100644 --- a/main/src/main/java/io/split/android/client/service/splits/LoadSplitsTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/LoadSplitsTask.java @@ -7,7 +7,7 @@ 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.SplitTaskType; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.utils.logger.Logger; diff --git a/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java index 6fb8fc8dc..2b6d886b5 100644 --- a/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/RuleBasedSegmentInPlaceUpdateTask.java @@ -10,7 +10,7 @@ import io.split.android.client.events.metadata.EventMetadataHelpers; 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.SplitTaskType; import io.split.android.client.service.rules.ProcessedRuleBasedSegmentChange; import io.split.android.client.service.rules.RuleBasedSegmentChangeProcessor; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java index 2c86042b0..2b8345f7d 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitInPlaceUpdateTask.java @@ -14,7 +14,7 @@ import io.split.android.client.events.metadata.EventMetadataHelpers; 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.SplitTaskType; import io.split.android.client.storage.splits.ProcessedSplitChange; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.model.streaming.UpdatesFromSSEEnum; diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java index 001d4ec04..53ee6f518 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitKillTask.java @@ -13,7 +13,7 @@ import io.split.android.client.events.metadata.EventMetadataHelpers; 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.SplitTaskType; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.utils.logger.Logger; diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java index 705331080..febbc82c7 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitsSyncHelper.java @@ -25,15 +25,15 @@ import io.split.android.client.network.SplitHttpHeadersBuilder; import io.split.android.client.service.ServiceConstants; import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; 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.rules.ProcessedRuleBasedSegmentChange; import io.split.android.client.service.rules.RuleBasedSegmentChangeProcessor; import io.split.android.client.storage.splits.ProcessedSplitChange; -import io.split.android.client.service.sseclient.BackoffCounter; -import io.split.android.client.service.sseclient.ReconnectBackoffCounter; +import io.split.android.client.backoff.BackoffCounter; +import io.split.android.client.backoff.ExponentialBackoffCounter; import io.split.android.client.storage.general.GeneralInfoStorage; import io.split.android.client.storage.rbs.RuleBasedSegmentStorageProducer; import io.split.android.client.storage.splits.SplitsStorage; @@ -81,7 +81,7 @@ public SplitsSyncHelper(@NonNull HttpFetcher splitFetcher, ruleBasedSegmentStorage, generalInfoStorage, telemetryRuntimeProducer, - new ReconnectBackoffCounter(1, ON_DEMAND_FETCH_BACKOFF_MAX_WAIT), + new ExponentialBackoffCounter(1, ON_DEMAND_FETCH_BACKOFF_MAX_WAIT), flagsSpec, forBackgroundSync, DEFAULT_PROXY_CHECK_INTERVAL_MILLIS, @@ -331,7 +331,9 @@ private void updateStorage(boolean clearBeforeUpdate, SplitChange splitChange, R mLastProcessedSplitChange.set(processedSplitChange); } mSplitsStorage.update(processedSplitChange, mExecutor); - updateRbsStorage(ruleBasedSegmentChange); + if (ruleBasedSegmentChange != null) { + updateRbsStorage(ruleBasedSegmentChange); + } } private boolean hasFlagUpdates(@Nullable ProcessedSplitChange processedSplitChange) { diff --git a/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java b/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java index d14600725..8bddb0d04 100644 --- a/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java +++ b/main/src/main/java/io/split/android/client/service/splits/SplitsUpdateTask.java @@ -17,7 +17,7 @@ import io.split.android.client.service.executor.SplitTask; 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.SplitTaskType; import io.split.android.client.service.synchronizer.SplitsChangeChecker; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java b/main/src/main/java/io/split/android/client/service/sseclient/StreamingConstants.java new file mode 100644 index 000000000..58c1b5e6a --- /dev/null +++ b/main/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/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/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 new file mode 100644 index 000000000..de3a8c2b0 --- /dev/null +++ b/main/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/main/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 new file mode 100644 index 000000000..e722c1962 --- /dev/null +++ b/main/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/main/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 new file mode 100644 index 000000000..ff1e2d185 --- /dev/null +++ b/main/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/main/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 new file mode 100644 index 000000000..506f8229e --- /dev/null +++ b/main/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/main/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 new file mode 100644 index 000000000..66e3402e0 --- /dev/null +++ b/main/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/BackoffCounterTimer.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/BackoffCounterTimer.java index fa682d765..cbc9a5b28 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/BackoffCounterTimer.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/BackoffCounterTimer.java @@ -8,7 +8,7 @@ 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.sseclient.BackoffCounter; +import io.split.android.client.backoff.BackoffCounter; import io.split.android.client.utils.logger.Logger; public class BackoffCounterTimer implements SplitTaskExecutionListener { 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/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/NotificationManagerKeeper.java b/main/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/main/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/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/PushNotificationManager.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/PushNotificationManager.java index 5217889b2..9cb4a546e 100644 --- a/main/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 @@ -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(); @@ -221,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)); @@ -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/RetryBackoffCounterTimer.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/RetryBackoffCounterTimer.java index 79c14f699..08ad35af8 100644 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/RetryBackoffCounterTimer.java +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/RetryBackoffCounterTimer.java @@ -11,7 +11,7 @@ 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.sseclient.BackoffCounter; +import io.split.android.client.backoff.BackoffCounter; import io.split.android.client.utils.logger.Logger; import java.util.concurrent.TimeUnit; 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..55180f313 --- /dev/null +++ b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SplitTaskExecutorStreamingScheduler.java @@ -0,0 +1,54 @@ +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.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); + } + } + }, 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/SseAuthenticator.java b/main/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/main/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/SseClientImpl.java b/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java deleted file mode 100644 index 78a8f316b..000000000 --- a/main/src/main/java/io/split/android/client/service/sseclient/sseclient/SseClientImpl.java +++ /dev/null @@ -1,169 +0,0 @@ -package io.split.android.client.service.sseclient.sseclient; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import androidx.annotation.NonNull; - -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.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.utils.StringHelper; -import io.split.android.client.utils.logger.Logger; - -public class SseClientImpl implements SseClient { - - private final URI mTargetUrl; - private final AtomicInteger mStatus; - private final HttpClient mHttpClient; - 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 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 HttpClient httpClient, - @NonNull EventStreamParser eventStreamParser, - @NonNull SseHandler sseHandler) { - mTargetUrl = checkNotNull(uri); - mHttpClient = checkNotNull(httpClient); - mEventStreamParser = checkNotNull(eventStreamParser); - mSseHandler = checkNotNull(sseHandler); - mStatus = new AtomicInteger(DISCONNECTED); - mIsDisconnectCalled = new AtomicBoolean(false); - mStringHelper = new StringHelper(); - mStatus.set(DISCONNECTED); - } - - @Override - public int status() { - return mStatus.get(); - } - - @Override - public void disconnect() { - if (!mIsDisconnectCalled.getAndSet(true)) { - close(); - } - } - - private void close() { - Logger.d("Disconnecting SSE client"); - if (mStatus.getAndSet(DISCONNECTED) != DISCONNECTED) { - // Close the HttpStreamResponse first to clean up sockets - if (mHttpStreamResponse != null) { - try { - mHttpStreamResponse.close(); - Logger.v("HttpStreamResponse closed successfully"); - } catch (IOException e) { - Logger.w("Failed to close HttpStreamResponse: " + e.getMessage()); - } - mHttpStreamResponse = null; - } - - // Close the HttpStreamRequest - if (mHttpStreamRequest != null) { - mHttpStreamRequest.close(); - mHttpStreamRequest = null; - } - Logger.d("SSE client disconnected"); - } - } - - @Override - public void connect(SseJwtToken token, ConnectionListener connectionListener) { - 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(); - mHttpStreamRequest = mHttpClient.streamRequest(url); - mHttpStreamResponse = mHttpStreamRequest.execute(); - if (mHttpStreamResponse.isSuccess()) { - bufferedReader = mHttpStreamResponse.getBufferedReader(); - if (bufferedReader != null) { - Logger.d("Streaming connection opened"); - mStatus.set(CONNECTED); - 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); - } - values = new HashMap<>(); - } - } - } else { - throw (new IOException("Buffer is null")); - } - } else { - Logger.e("Streaming connection error. Http return code " + mHttpStreamResponse.getHttpStatus()); - isErrorRetryable = !mHttpStreamResponse.isClientRelatedError(); - } - } catch (URISyntaxException e) { - logError("An error has occurred while creating stream Url ", e); - isErrorRetryable = false; - } catch (HttpException e) { - logError("An error has occurred while creating stream Url ", e); - isErrorRetryable = !HttpStatus.isNotRetryable(HttpStatus.fromCode(e.getStatusCode())); - } catch (IOException e) { - Logger.d("An error has occurred while parsing stream: " + e.getLocalizedMessage()); - isErrorRetryable = true; - } catch (Exception e) { - logError("An unexpected error has occurred while receiving stream events from: ", e); - isErrorRetryable = true; - } finally { - if (!mIsDisconnectCalled.getAndSet(false)) { - mSseHandler.handleError(isErrorRetryable); - close(); - } - } - } - - private static void logError(String message, Exception e) { - Logger.e(message + " : " + e.getLocalizedMessage()); - } -} 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 index 7b196202d..16d5c824a 100644 --- 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 @@ -4,37 +4,32 @@ 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.sseclient.spi.StreamingScheduler; import io.split.android.client.utils.logger.Logger; -public class SseDisconnectionTimer implements SplitTaskExecutionListener { +public class SseDisconnectionTimer { - private final SplitTaskExecutor mTaskExecutor; + private final StreamingScheduler mScheduler; private final int mInitialDelayInSeconds; private String mTaskId; - public SseDisconnectionTimer(@NonNull SplitTaskExecutor taskExecutor, int initialDelayInSeconds) { - mTaskExecutor = checkNotNull(taskExecutor); + public SseDisconnectionTimer(@NonNull StreamingScheduler scheduler, int initialDelayInSeconds) { + mScheduler = checkNotNull(scheduler); mInitialDelayInSeconds = initialDelayInSeconds; } public void cancel() { - if (mTaskId != null) { - mTaskExecutor.stopTask(mTaskId); - } + mScheduler.cancel(mTaskId); } - public void schedule(SplitTask task) { + public void schedule(Runnable task) { Logger.v("Scheduling disconnection in " + mInitialDelayInSeconds + " seconds"); cancel(); - mTaskId = mTaskExecutor.schedule(task, mInitialDelayInSeconds, this); - } - - @Override - public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { - mTaskId = null; + 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/main/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/main/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/main/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/main/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/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/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java b/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java index abf55e7fe..d0609534a 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java +++ b/main/src/main/java/io/split/android/client/service/synchronizer/SynchronizerImpl.java @@ -19,7 +19,7 @@ import io.split.android.client.service.executor.SplitTaskExecutionListener; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskFactory; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.impressions.StrategyImpressionManager; import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; @@ -30,7 +30,9 @@ import io.split.android.client.service.synchronizer.mysegments.MySegmentsSynchronizerRegistry; import io.split.android.client.service.synchronizer.mysegments.MySegmentsSynchronizerRegistryImpl; import io.split.android.client.shared.UserConsent; -import io.split.android.client.storage.common.StoragePusher; +import io.split.android.client.submitter.RecorderSyncHelper; +import io.split.android.client.submitter.RecorderSyncHelperImpl; +import io.split.android.client.submitter.StoragePusher; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.model.EventsDataRecordsEnum; import io.split.android.client.telemetry.model.streaming.SyncModeUpdateStreamingEvent; @@ -305,14 +307,12 @@ private void scheduleEventsRecorderTask() { @Override public void taskExecuted(@NonNull SplitTaskExecutionInfo taskInfo) { - switch (taskInfo.getTaskType()) { - case SPLITS_SYNC: - mFeatureFlagsSynchronizer.submitLoadingTask(null); - break; - case MY_SEGMENTS_SYNC: - Logger.d("Loading my segments updated in background"); - mMySegmentsSynchronizerRegistry.submitMySegmentsLoadingTask(); - break; + io.split.android.client.service.executor.SplitTaskType type = taskInfo.getTaskType(); + if (type == SplitTaskType.SPLITS_SYNC) { + mFeatureFlagsSynchronizer.submitLoadingTask(null); + } else if (type == SplitTaskType.MY_SEGMENTS_SYNC) { + Logger.d("Loading my segments updated in background"); + mMySegmentsSynchronizerRegistry.submitMySegmentsLoadingTask(); } } } diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/WorkManagerWrapper.java b/main/src/main/java/io/split/android/client/service/synchronizer/WorkManagerWrapper.java index 2b86786fd..0e7c94814 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/WorkManagerWrapper.java +++ b/main/src/main/java/io/split/android/client/service/synchronizer/WorkManagerWrapper.java @@ -29,7 +29,7 @@ import io.split.android.client.service.ServiceConstants; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutionListener; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.impressions.ImpressionManagerConfig; import io.split.android.client.service.synchronizer.mysegments.MySegmentsWorkManagerWrapper; import io.split.android.client.service.workmanager.EventsRecorderWorker; diff --git a/main/src/main/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTask.java b/main/src/main/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTask.java index 5083359dd..568668d86 100644 --- a/main/src/main/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTask.java +++ b/main/src/main/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTask.java @@ -8,7 +8,7 @@ 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.service.http.HttpStatus; diff --git a/main/src/main/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTask.java b/main/src/main/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTask.java index c6c8fbd21..464055ac2 100644 --- a/main/src/main/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTask.java +++ b/main/src/main/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTask.java @@ -8,7 +8,7 @@ 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.service.http.HttpStatus; diff --git a/main/src/main/java/io/split/android/client/service/workmanager/HttpClientProvider.java b/main/src/main/java/io/split/android/client/service/workmanager/HttpClientProvider.java index 2d7e7119d..6d235ef88 100644 --- a/main/src/main/java/io/split/android/client/service/workmanager/HttpClientProvider.java +++ b/main/src/main/java/io/split/android/client/service/workmanager/HttpClientProvider.java @@ -6,7 +6,6 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; -import io.split.android.client.main.BuildConfig; import io.split.android.client.dtos.HttpProxyDto; import io.split.android.client.network.BasicCredentialsProvider; import io.split.android.client.network.BearerCredentialsProvider; @@ -14,6 +13,7 @@ import io.split.android.client.network.CertificatePinningConfigurationProvider; import io.split.android.client.network.HttpClient; import io.split.android.client.network.HttpClientImpl; +import io.split.android.client.network.SdkVersionProvider; import io.split.android.client.network.HttpProxy; import io.split.android.client.network.SplitHttpHeadersBuilder; import io.split.android.client.storage.cipher.SplitCipherFactory; @@ -43,7 +43,7 @@ private static HttpClient buildHttpClient(String apiKey, @Nullable CertificatePi .build(); SplitHttpHeadersBuilder headersBuilder = new SplitHttpHeadersBuilder(); - headersBuilder.setClientVersion(BuildConfig.SPLIT_VERSION_NAME); + headersBuilder.setClientVersion(SdkVersionProvider.getSdkVersion()); headersBuilder.setApiToken(apiKey); headersBuilder.addJsonTypeHeaders(); httpClient.addHeaders(headersBuilder.build()); diff --git a/main/src/main/java/io/split/android/client/storage/common/PersistentStorage.java b/main/src/main/java/io/split/android/client/storage/common/PersistentStorage.java index c111303d1..21744d98f 100644 --- a/main/src/main/java/io/split/android/client/storage/common/PersistentStorage.java +++ b/main/src/main/java/io/split/android/client/storage/common/PersistentStorage.java @@ -4,16 +4,15 @@ import java.util.List; -public interface PersistentStorage extends StoragePusher { +import io.split.android.client.submitter.RecorderStorage; +import io.split.android.client.submitter.StoragePusher; + +public interface PersistentStorage extends StoragePusher, RecorderStorage { // Push method is defined in StoragePusher interface void pushMany(@NonNull List elements); - List pop(int count); - - void setActive(@NonNull List elements); - - void delete(@NonNull List elements); + // pop, delete, and setActive are inherited from RecorderStorage void deleteInvalid(long maxTimestamp); } diff --git a/main/src/main/java/io/split/android/client/storage/events/EventsStorage.java b/main/src/main/java/io/split/android/client/storage/events/EventsStorage.java index 1eb2d1ae7..5da50977a 100644 --- a/main/src/main/java/io/split/android/client/storage/events/EventsStorage.java +++ b/main/src/main/java/io/split/android/client/storage/events/EventsStorage.java @@ -11,7 +11,7 @@ import io.split.android.client.dtos.Event; import io.split.android.client.storage.common.Storage; -import io.split.android.client.storage.common.StoragePusher; +import io.split.android.client.submitter.StoragePusher; import io.split.android.client.utils.logger.Logger; public class EventsStorage implements Storage, StoragePusher { diff --git a/main/src/main/java/io/split/android/client/storage/impressions/ImpressionsStorage.java b/main/src/main/java/io/split/android/client/storage/impressions/ImpressionsStorage.java index 403fbe220..188e3d2db 100644 --- a/main/src/main/java/io/split/android/client/storage/impressions/ImpressionsStorage.java +++ b/main/src/main/java/io/split/android/client/storage/impressions/ImpressionsStorage.java @@ -12,7 +12,7 @@ import io.split.android.client.dtos.KeyImpression; import io.split.android.client.storage.common.PersistentStorage; import io.split.android.client.storage.common.Storage; -import io.split.android.client.storage.common.StoragePusher; +import io.split.android.client.submitter.StoragePusher; import io.split.android.client.utils.logger.Logger; public class ImpressionsStorage implements Storage, StoragePusher { diff --git a/main/src/main/java/io/split/android/client/storage/splits/SplitsStorageImpl.java b/main/src/main/java/io/split/android/client/storage/splits/SplitsStorageImpl.java index f4659ecaf..6439a69b0 100644 --- a/main/src/main/java/io/split/android/client/storage/splits/SplitsStorageImpl.java +++ b/main/src/main/java/io/split/android/client/storage/splits/SplitsStorageImpl.java @@ -24,12 +24,11 @@ import java.util.concurrent.atomic.AtomicBoolean; import io.split.android.client.dtos.Split; +import io.split.android.client.utils.logger.Logger; import io.split.android.client.utils.Json; public class SplitsStorageImpl implements SplitsStorage { - private static final int ASYNC_WRITE_THRESHOLD = 50; - private final PersistentSplitsStorage mPersistentStorage; private final Map mInMemorySplits; private final Map> mFlagSets; @@ -166,10 +165,14 @@ public boolean update(ProcessedSplitChange splitChange, ExecutorService mExecuto mChangeNumber = splitChange.getChangeNumber(); mUpdateTimestamp = splitChange.getUpdateTimestamp(); - // If the amount of elements is greater than the threshold, - // we will use the executor to update the persistent storage asynchronously - if (((activeSplits != null && activeSplits.size() > ASYNC_WRITE_THRESHOLD) || (archivedSplits != null && archivedSplits.size() > ASYNC_WRITE_THRESHOLD)) && mExecutor != null) { - mExecutor.submit(() -> mPersistentStorage.update(splitChange, mTrafficTypes, mFlagSets)); + if (mExecutor != null) { + try { + Map trafficTypesSnapshot = new HashMap<>(mTrafficTypes); + Map> flagSetsSnapshot = copyFlagSets(mFlagSets); + mExecutor.submit(() -> mPersistentStorage.update(splitChange, trafficTypesSnapshot, flagSetsSnapshot)); + } catch (Exception e) { + Logger.v("Failed to submit persistent write: " + e.getLocalizedMessage()); + } } else { mPersistentStorage.update(splitChange, mTrafficTypes, mFlagSets); } @@ -177,6 +180,15 @@ public boolean update(ProcessedSplitChange splitChange, ExecutorService mExecuto return appliedUpdates; } + @NonNull + private static Map> copyFlagSets(Map> flagSets) { + Map> flagSetsSnapshot = new HashMap<>(); + for (Map.Entry> entry : flagSets.entrySet()) { + flagSetsSnapshot.put(entry.getKey(), new HashSet<>(entry.getValue())); + } + return flagSetsSnapshot; + } + @Override @WorkerThread public void updateWithoutChecks(Split split) { diff --git a/main/src/main/java/io/split/android/client/telemetry/TelemetrySynchronizerImpl.java b/main/src/main/java/io/split/android/client/telemetry/TelemetrySynchronizerImpl.java index d6346b887..a5e01b9ae 100644 --- a/main/src/main/java/io/split/android/client/telemetry/TelemetrySynchronizerImpl.java +++ b/main/src/main/java/io/split/android/client/telemetry/TelemetrySynchronizerImpl.java @@ -11,7 +11,7 @@ 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.sseclient.FixedIntervalBackoffCounter; +import io.split.android.client.backoff.FixedIntervalBackoffCounter; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; import io.split.android.client.service.telemetry.TelemetryTaskFactory; 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/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<>(); diff --git a/main/src/main/java/io/split/android/client/validators/EventValidator.java b/main/src/main/java/io/split/android/client/validators/EventValidator.java deleted file mode 100644 index a1bd81220..000000000 --- a/main/src/main/java/io/split/android/client/validators/EventValidator.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.split.android.client.validators; - -import io.split.android.client.dtos.Event; - -/** - * Interface to implement by Track Events validators - */ -public interface EventValidator { - - /** - * Checks that a Track event is valid - * @param event: Event instance - * @return true when the key is valid, false when it is not - */ - ValidationErrorInfo validate(Event event, boolean validateTrafficType); - -} diff --git a/main/src/main/java/io/split/android/client/validators/EventValidatorImpl.java b/main/src/main/java/io/split/android/client/validators/EventValidatorImpl.java deleted file mode 100644 index a189a3a02..000000000 --- a/main/src/main/java/io/split/android/client/validators/EventValidatorImpl.java +++ /dev/null @@ -1,72 +0,0 @@ -package io.split.android.client.validators; - -import io.split.android.client.dtos.Event; -import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.client.utils.Utils; - -/** - * Contains func an instance of Event class. - */ -public class EventValidatorImpl implements EventValidator { - - private final String TYPE_REGEX = ValidationConfig.getInstance().getTrackEventNamePattern(); - private KeyValidator mKeyValidator; - private final SplitsStorage mSplitsStorage; - - public EventValidatorImpl(KeyValidator keyValidator, SplitsStorage splitsStorage) { - mKeyValidator = keyValidator; - mSplitsStorage = splitsStorage; - } - - @Override - public ValidationErrorInfo validate(Event event, boolean validateTrafficType) { - - if(event == null) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "Event could not be null"); - } - - ValidationErrorInfo errorInfo = mKeyValidator.validate(event.key, null); - if(errorInfo != null){ - return errorInfo; - } - - if (event.trafficTypeName == null) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string"); - } - - if (Utils.isNullOrEmpty(event.trafficTypeName.trim())) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed an empty traffic_type_name, traffic_type_name must be a non-empty string"); - } - - if (event.eventTypeId == null) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed a null or undefined event_type, event_type must be a non-empty String"); - } - - if (Utils.isNullOrEmpty(event.eventTypeId.trim())) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed an empty event_type, event_type must be a non-empty String"); - } - - if (!event.eventTypeId.matches(TYPE_REGEX)) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed " + event.eventTypeId - + ", event name must adhere to the regular expression " + TYPE_REGEX - + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " - + " underscore, period, or colon as separators of alphanumeric characters."); - } - - if(!event.trafficTypeName.toLowerCase().equals(event.trafficTypeName)) { - errorInfo = new ValidationErrorInfo(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS, "traffic_type_name should be all lowercase - converting string to lowercase", true); - } - - if (validateTrafficType && !mSplitsStorage.isValidTrafficType(event.trafficTypeName)) { - String message = "Traffic Type " + event.trafficTypeName + " does not have any corresponding feature flags in this environment, " - + "make sure you’re tracking your events to a valid traffic type defined in the Split user interface"; - if(errorInfo == null) { - errorInfo = new ValidationErrorInfo(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_WITHOUT_SPLIT_IN_ENVIRONMENT, message, true); - } else { - errorInfo.addWarning(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_WITHOUT_SPLIT_IN_ENVIRONMENT, message); - } - } - - return errorInfo; - } -} diff --git a/main/src/main/java/io/split/android/client/validators/PropertyValidatorAdapter.java b/main/src/main/java/io/split/android/client/validators/PropertyValidatorAdapter.java new file mode 100644 index 000000000..4406cd4ee --- /dev/null +++ b/main/src/main/java/io/split/android/client/validators/PropertyValidatorAdapter.java @@ -0,0 +1,32 @@ +package io.split.android.client.validators; + +import java.util.Map; + +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerPropertyValidator; + +/** + * Adapter that bridges the main module's PropertyValidator interface with + * the tracker module's TrackerPropertyValidator implementation. + */ +public class PropertyValidatorAdapter implements PropertyValidator { + + private final TrackerPropertyValidator mDelegate; + + public PropertyValidatorAdapter(TrackerPropertyValidator delegate) { + mDelegate = delegate; + } + + @Override + public Result validate(Map properties, String validationTag) { + // Call the tracker validator with initialSizeInBytes=0 since we're not tracking + TrackerPropertyValidator.TrackerPropertyResult trackerResult = + mDelegate.validate(properties, 0, validationTag); + + if (trackerResult.isValid()) { + return Result.valid(trackerResult.getProperties(), trackerResult.getSizeInBytes()); + } else { + return Result.invalid(trackerResult.getErrorMessage(), trackerResult.getSizeInBytes()); + } + } +} diff --git a/main/src/main/java/io/split/android/client/validators/TrafficTypeValidatorImpl.java b/main/src/main/java/io/split/android/client/validators/TrafficTypeValidatorImpl.java new file mode 100644 index 000000000..a46d998b8 --- /dev/null +++ b/main/src/main/java/io/split/android/client/validators/TrafficTypeValidatorImpl.java @@ -0,0 +1,23 @@ +package io.split.android.client.validators; + +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.tracker.TrafficTypeValidator; + +/** + * Implementation of {@link TrafficTypeValidator} that delegates to {@link SplitsStorage}. + *

+ * This implementation validates traffic type names by checking if they exist in the + * Split storage. It bridges the tracker module's abstraction with the SDK's storage layer. + */ +public class TrafficTypeValidatorImpl implements TrafficTypeValidator { + private final SplitsStorage mSplitsStorage; + + public TrafficTypeValidatorImpl(SplitsStorage splitsStorage) { + mSplitsStorage = splitsStorage; + } + + @Override + public boolean isValid(String trafficTypeName) { + return mSplitsStorage.isValidTrafficType(trafficTypeName); + } +} diff --git a/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java b/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java index 287fb94b4..28d54a578 100644 --- a/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java @@ -8,7 +8,7 @@ import io.split.android.client.Evaluator; import io.split.android.client.EvaluatorImpl; import io.split.android.client.FlagSetsFilter; -import io.split.android.client.PropertyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorImpl; import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; import io.split.android.client.attributes.AttributesMerger; @@ -65,7 +65,8 @@ public TreatmentManagerFactoryImpl(@NonNull KeyValidator keyValidator, mSplitsStorage = checkNotNull(splitsStorage); mValidationMessageLogger = new ValidationMessageLoggerImpl(); mFlagSetsValidator = new FlagSetsValidatorImpl(); - mPropertyValidator = new PropertyValidatorImpl(); + mPropertyValidator = new PropertyValidatorAdapter( + new PropertyValidatorImpl(new ValidationMessageLoggerImpl())); } @Override diff --git a/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java b/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java index c6678b276..a56866a28 100644 --- a/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java +++ b/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java @@ -3,12 +3,14 @@ import java.util.ArrayList; import java.util.List; +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerValidationError; import io.split.android.client.utils.logger.Logger; /** * Default implementation of ValidationMessageLogger interface */ -public class ValidationMessageLoggerImpl implements ValidationMessageLogger { +public class ValidationMessageLoggerImpl implements ValidationMessageLogger, TrackerLogger { @Override public void log(ValidationErrorInfo errorInfo, String tag) { @@ -52,4 +54,22 @@ private String sanitizeTag(String tag) { return (tag != null ? tag : ""); } + // TrackerLogger implementation + + @Override + public void log(TrackerValidationError errorInfo, String tag) { + if (errorInfo.isError()) { + logError(errorInfo.getMessage(), tag); + } else { + for (String warning : errorInfo.getWarnings()) { + logWarning(warning, tag); + } + } + } + + @Override + public void v(String message) { + Logger.v(message); + } + } 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/SplitClientImplBaseTest.java b/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java index 88cd686ee..b89a4f6e4 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java @@ -14,6 +14,7 @@ import io.split.android.client.storage.mysegments.MySegmentsStorageContainer; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.tracker.Tracker; import io.split.android.client.validators.SplitValidator; import io.split.android.client.validators.TreatmentManager; import io.split.android.engine.experiments.ParserCommons; @@ -41,7 +42,7 @@ public abstract class SplitClientImplBaseTest { @Mock protected SplitsStorage splitsStorage; @Mock - protected EventsTracker eventsTracker; + protected Tracker eventsTracker; @Mock protected SyncManager syncManager; @Mock diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java index 16d40a060..539982fc0 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -22,6 +22,7 @@ import io.split.android.client.events.SplitEventsManager; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.tracker.Tracker; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.SplitValidator; import io.split.android.client.validators.TreatmentManager; @@ -38,7 +39,7 @@ public class SplitClientImplEventRegistrationTest { @Mock private ImpressionListener impressionListener; @Mock - private EventsTracker eventsTracker; + private Tracker eventsTracker; @Mock private AttributesManager attributesManager; @Mock 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/SplitFactoryImplEventsTrackerProviderTest.java b/main/src/test/java/io/split/android/client/SplitFactoryImplEventsTrackerProviderTest.java new file mode 100644 index 000000000..456580004 --- /dev/null +++ b/main/src/test/java/io/split/android/client/SplitFactoryImplEventsTrackerProviderTest.java @@ -0,0 +1,141 @@ +package io.split.android.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +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 java.util.HashMap; +import java.util.Map; + +import io.split.android.client.dtos.Event; +import io.split.android.client.service.synchronizer.SyncManager; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.telemetry.model.Method; +import io.split.android.client.telemetry.storage.TelemetryStorage; +import io.split.android.client.tracker.Tracker; + +public class SplitFactoryImplEventsTrackerProviderTest { + + private SplitsStorage mSplitsStorage; + private TelemetryStorage mTelemetryStorage; + private SyncManager mSyncManager; + private SplitFactoryImpl.EventsTrackerProvider mProvider; + + @Before + public void setUp() { + mSplitsStorage = mock(SplitsStorage.class); + mTelemetryStorage = mock(TelemetryStorage.class); + mSyncManager = mock(SyncManager.class); + mProvider = new SplitFactoryImpl.EventsTrackerProvider( + mSplitsStorage, + mTelemetryStorage, + mSyncManager); + + // Set up default behavior for traffic type validation + when(mSplitsStorage.isValidTrafficType(anyString())).thenReturn(true); + } + + @Test + public void getEventsTrackerReturnsNonNullTracker() { + Tracker tracker = mProvider.getEventsTracker(); + + assertNotNull(tracker); + } + + @Test + public void getEventsTrackerReturnsSameInstanceOnSubsequentCalls() { + Tracker tracker1 = mProvider.getEventsTracker(); + Tracker tracker2 = mProvider.getEventsTracker(); + + assertSame(tracker1, tracker2); + } + + @Test + public void trackerCallbackInvokesSyncManagerPushEvent() { + Tracker tracker = mProvider.getEventsTracker(); + + Map properties = new HashMap<>(); + properties.put("key1", "value1"); + boolean result = tracker.track("user-key", "user", "purchase", 10.5, properties, true); + + assertTrue(result); + verify(mSyncManager).pushEvent(any(Event.class)); + } + + @Test + public void trackerCallbackCreatesEventWithCorrectFields() { + Tracker tracker = mProvider.getEventsTracker(); + + Map properties = new HashMap<>(); + properties.put("product", "widget"); + properties.put("quantity", 3); + + long beforeTrack = System.currentTimeMillis(); + tracker.track("test-key", "account", "conversion", 25.99, properties, true); + long afterTrack = System.currentTimeMillis(); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + verify(mSyncManager).pushEvent(eventCaptor.capture()); + + Event capturedEvent = eventCaptor.getValue(); + assertNotNull(capturedEvent); + assertEquals("conversion", capturedEvent.eventTypeId); + assertEquals("account", capturedEvent.trafficTypeName); + assertEquals("test-key", capturedEvent.key); + assertEquals(25.99, capturedEvent.value, 0.0001); + assertTrue(capturedEvent.timestamp >= beforeTrack && capturedEvent.timestamp <= afterTrack); + assertNotNull(capturedEvent.properties); + assertEquals("widget", capturedEvent.properties.get("product")); + assertEquals(3, capturedEvent.properties.get("quantity")); + assertTrue(capturedEvent.getSizeInBytes() > 0); + } + + @Test + public void trackerCallbackRecordsLatencyInTelemetry() { + Tracker tracker = mProvider.getEventsTracker(); + + tracker.track("key", "user", "event", 1.0, null, true); + + ArgumentCaptor latencyCaptor = ArgumentCaptor.forClass(Long.class); + verify(mTelemetryStorage).recordLatency(any(Method.class), latencyCaptor.capture()); + + Long latency = latencyCaptor.getValue(); + assertNotNull(latency); + assertTrue(latency >= 0); + } + + @Test + public void trackerCallbackRecordsExceptionInTelemetry() { + // Create a SyncManager that throws when pushEvent is called + SyncManager throwingSyncManager = mock(SyncManager.class); + doThrow(new RuntimeException("Push failed")) + .when(throwingSyncManager).pushEvent(any(Event.class)); + + SplitFactoryImpl.EventsTrackerProvider provider = new SplitFactoryImpl.EventsTrackerProvider( + mSplitsStorage, + mTelemetryStorage, + throwingSyncManager); + when(mSplitsStorage.isValidTrafficType(anyString())).thenReturn(true); + + Tracker tracker = provider.getEventsTracker(); + + boolean result = tracker.track("key", "user", "event", 1.0, null, true); + + // Track should return false due to exception + assertEquals(false, result); + verify(mTelemetryStorage).recordException(Method.TRACK); + } +} diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java index 4f8432e18..3d06f2f89 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; @@ -85,7 +87,7 @@ public void setUp() { mSplitsStorage, new ValidationMessageLoggerImpl(), mFlagSetsValidator, - new PropertyValidatorImpl(), + new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); when(evaluator.getTreatment(anyString(), anyString(), anyString(), anyMap())).thenReturn(new EvaluationResult("test", "label")); diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java index 222de7750..c7a3e0ec6 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; @@ -78,7 +80,7 @@ public void setUp() { mFlagSetsFilter, mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), - new PropertyValidatorImpl(), + new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); when(evaluator.getTreatment(anyString(), anyString(), anyString(), anyMap())).thenReturn(new EvaluationResult("test", "label")); diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerTest.java index ce889d69d..6a2dd7988 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.argThat; @@ -372,7 +374,7 @@ private TreatmentManager createTreatmentManager(String matchingKey, String bucke new KeyValidatorImpl(), splitValidator, mock(ImpressionListener.FederatedImpressionListener.class), config.labelsEnabled(), eventsManager, mock(AttributesManager.class), mock(AttributesMerger.class), - mock(TelemetryStorageProducer.class), mFlagSetsFilter, mSplitsStorage, validationLogger, new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + mock(TelemetryStorageProducer.class), mFlagSetsFilter, mSplitsStorage, validationLogger, new FlagSetsValidatorImpl(), new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } @@ -403,7 +405,7 @@ private TreatmentManagerImpl initializeTreatmentManager(Evaluator evaluator) { telemetryStorageProducer, mFlagSetsFilter, mSplitsStorage, - new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java index aa12c3d5e..8d51f2263 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -157,7 +159,7 @@ private void initializeTreatmentManager() { mAttributesMerger, mTelemetryStorageProducer, mFlagSetsFilter, - mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } diff --git a/main/src/test/java/io/split/android/client/UserConsentManagerTest.java b/main/src/test/java/io/split/android/client/UserConsentManagerTest.java index 8dc3a2194..0d133342f 100644 --- a/main/src/test/java/io/split/android/client/UserConsentManagerTest.java +++ b/main/src/test/java/io/split/android/client/UserConsentManagerTest.java @@ -17,6 +17,7 @@ import io.split.android.client.shared.UserConsent; import io.split.android.client.storage.events.EventsStorage; import io.split.android.client.storage.impressions.ImpressionsStorage; +import io.split.android.client.tracker.Tracker; import io.split.android.fake.SplitTaskExecutorStub; public class UserConsentManagerTest { @@ -30,7 +31,7 @@ public class UserConsentManagerTest { @Mock private SyncManager mSyncManager; @Mock - private EventsTracker mEventsTracker; + private Tracker mEventsTracker; @Mock private SplitFactoryImpl.EventsTrackerProvider mEventsTrackerProvider; @Mock diff --git a/main/src/test/java/io/split/android/client/localhost/LocalhostTrafficTypeValidatorTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostTrafficTypeValidatorTest.java new file mode 100644 index 000000000..6a0b04777 --- /dev/null +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostTrafficTypeValidatorTest.java @@ -0,0 +1,43 @@ +package io.split.android.client.localhost; + +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +public class LocalhostTrafficTypeValidatorTest { + + private LocalhostTrafficTypeValidator mValidator; + + @Before + public void setUp() { + mValidator = new LocalhostTrafficTypeValidator(); + } + + @Test + public void isValidReturnsTrueForAnyTrafficType() { + assertTrue(mValidator.isValid("user")); + assertTrue(mValidator.isValid("account")); + assertTrue(mValidator.isValid("random_traffic_type")); + } + + @Test + public void isValidReturnsTrueForNull() { + assertTrue(mValidator.isValid(null)); + } + + @Test + public void isValidReturnsTrueForEmptyString() { + assertTrue(mValidator.isValid("")); + } + + @Test + public void isValidReturnsTrueForWhitespace() { + assertTrue(mValidator.isValid(" ")); + } + + @Test + public void isValidReturnsTrueForSpecialCharacters() { + assertTrue(mValidator.isValid("!@#$%^&*()")); + } +} 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/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..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,11 +313,10 @@ public MockResponse dispatch(RecordedRequest request) { mProxyServer.start(); HttpClient client = new HttpClientImpl.Builder() - .setContext(mock(Context.class)) .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"); @@ -371,11 +367,10 @@ public MockResponse dispatch(RecordedRequest request) { mProxyServer.start(); HttpClient client = new HttpClientImpl.Builder() - .setContext(mock(Context.class)) .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"); @@ -407,36 +402,30 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest @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()); } diff --git a/main/src/test/java/io/split/android/client/network/SdkVersionProviderTest.java b/main/src/test/java/io/split/android/client/network/SdkVersionProviderTest.java new file mode 100644 index 000000000..505c514ee --- /dev/null +++ b/main/src/test/java/io/split/android/client/network/SdkVersionProviderTest.java @@ -0,0 +1,21 @@ +package io.split.android.client.network; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class SdkVersionProviderTest { + + @Test + public void getSdkVersionStartsWithAndroidPrefix() { + assertTrue(SdkVersionProvider.getSdkVersion().startsWith("Android-")); + } + + @Test + public void getSdkVersionContainsNonEmptyVersionAfterPrefix() { + String version = SdkVersionProvider.getSdkVersion(); + String suffix = version.substring("Android-".length()); + assertFalse(suffix.isEmpty()); + } +} diff --git a/main/src/test/java/io/split/android/client/service/EventsRecorderTaskTest.java b/main/src/test/java/io/split/android/client/service/EventsRecorderTaskTest.java index 9fe3c763a..f3c9d5420 100644 --- a/main/src/test/java/io/split/android/client/service/EventsRecorderTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/EventsRecorderTaskTest.java @@ -24,7 +24,7 @@ import io.split.android.client.service.events.EventsRecorderTaskConfig; 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.storage.events.PersistentEventsStorage; diff --git a/main/src/test/java/io/split/android/client/service/ImpressionsCountRecorderTaskTest.java b/main/src/test/java/io/split/android/client/service/ImpressionsCountRecorderTaskTest.java index 11d20af45..d8ac9d9ba 100644 --- a/main/src/test/java/io/split/android/client/service/ImpressionsCountRecorderTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/ImpressionsCountRecorderTaskTest.java @@ -24,7 +24,7 @@ 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.service.impressions.ImpressionsCount; diff --git a/main/src/test/java/io/split/android/client/service/ImpressionsRecorderTaskTest.java b/main/src/test/java/io/split/android/client/service/ImpressionsRecorderTaskTest.java index 913cd9511..632be84ab 100644 --- a/main/src/test/java/io/split/android/client/service/ImpressionsRecorderTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/ImpressionsRecorderTaskTest.java @@ -25,7 +25,7 @@ import io.split.android.client.service.executor.SplitTask; 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.service.impressions.ImpressionsRecorderTask; diff --git a/main/src/test/java/io/split/android/client/service/LoadSplitsTaskTest.java b/main/src/test/java/io/split/android/client/service/LoadSplitsTaskTest.java index 0720c7064..f07aa402f 100644 --- a/main/src/test/java/io/split/android/client/service/LoadSplitsTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/LoadSplitsTaskTest.java @@ -11,7 +11,7 @@ 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.SplitTaskType; import io.split.android.client.service.splits.LoadSplitsTask; import io.split.android.client.storage.splits.SplitsStorage; diff --git a/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java b/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java index cf99782c7..1d6e77c6b 100644 --- a/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/MySegmentsSyncTaskTest.java @@ -40,7 +40,7 @@ import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.mysegments.MySegmentsSyncTask; import io.split.android.client.service.mysegments.MySegmentsSyncTaskConfig; -import io.split.android.client.service.sseclient.BackoffCounter; +import io.split.android.client.backoff.BackoffCounter; import io.split.android.client.service.synchronizer.MySegmentsChangeChecker; import io.split.android.client.storage.mysegments.MySegmentsStorage; import io.split.android.client.telemetry.model.OperationType; diff --git a/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java index 663602b30..109d32bbc 100644 --- a/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/MySegmentsUpdateTaskTest.java @@ -29,7 +29,7 @@ import io.split.android.client.events.SplitInternalEvent; 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.SplitTaskType; import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.mysegments.MySegmentsUpdateTask; import io.split.android.client.service.mysegments.MySegmentsUpdateTaskConfig; diff --git a/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java index beb9d4043..e3dd8261b 100644 --- a/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitKillTaskTest.java @@ -17,7 +17,7 @@ import io.split.android.client.events.SplitInternalEvent; 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.SplitTaskType; import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.splits.SplitKillTask; import io.split.android.client.storage.splits.SplitsStorage; diff --git a/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java index 45bc7b228..e63578b11 100644 --- a/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitSyncTaskTest.java @@ -36,7 +36,7 @@ import io.split.android.client.events.SplitEventsManager; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.splits.SplitsSyncHelper; import io.split.android.client.service.splits.SplitsSyncTask; diff --git a/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java b/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java index fda1b1a89..d46d9dc04 100644 --- a/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitUpdateTaskTest.java @@ -29,7 +29,7 @@ import io.split.android.client.events.SplitInternalEvent; 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.SplitTaskType; import io.split.android.client.service.http.HttpFetcherException; import io.split.android.client.service.splits.SplitsSyncHelper; import io.split.android.client.service.splits.SplitsUpdateTask; diff --git a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java index ec8c7db04..7e005cb3b 100644 --- a/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java +++ b/main/src/test/java/io/split/android/client/service/SplitsSyncHelperTest.java @@ -53,7 +53,7 @@ import io.split.android.client.service.rules.RuleBasedSegmentChangeProcessor; import io.split.android.client.service.splits.SplitChangeProcessor; import io.split.android.client.service.splits.SplitsSyncHelper; -import io.split.android.client.service.sseclient.BackoffCounter; +import io.split.android.client.backoff.BackoffCounter; import io.split.android.client.storage.general.GeneralInfoStorage; import io.split.android.client.storage.rbs.RuleBasedSegmentStorageImplTest; import io.split.android.client.storage.rbs.RuleBasedSegmentStorageProducer; diff --git a/main/src/test/java/io/split/android/client/service/SynchronizerTest.java b/main/src/test/java/io/split/android/client/service/SynchronizerTest.java index 6a5e4aa15..c6eb2ac1b 100644 --- a/main/src/test/java/io/split/android/client/service/SynchronizerTest.java +++ b/main/src/test/java/io/split/android/client/service/SynchronizerTest.java @@ -53,7 +53,7 @@ import io.split.android.client.service.executor.SplitTaskExecutionListener; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskFactory; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.http.HttpFetcher; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.impressions.ImpressionManagerConfig; @@ -71,7 +71,7 @@ import io.split.android.client.service.splits.SplitsUpdateTask; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; import io.split.android.client.service.synchronizer.FeatureFlagsSynchronizer; -import io.split.android.client.service.synchronizer.RecorderSyncHelper; +import io.split.android.client.submitter.RecorderSyncHelper; import io.split.android.client.service.synchronizer.SynchronizerImpl; import io.split.android.client.service.synchronizer.WorkManagerWrapper; import io.split.android.client.service.synchronizer.attributes.AttributesSynchronizer; diff --git a/main/src/test/java/io/split/android/client/service/attributes/LoadAttributesTaskTest.java b/main/src/test/java/io/split/android/client/service/attributes/LoadAttributesTaskTest.java index 052581c27..eb71fc99c 100644 --- a/main/src/test/java/io/split/android/client/service/attributes/LoadAttributesTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/attributes/LoadAttributesTaskTest.java @@ -15,7 +15,7 @@ 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.SplitTaskType; import io.split.android.client.storage.attributes.AttributesStorage; import io.split.android.client.storage.attributes.PersistentAttributesStorage; diff --git a/main/src/test/java/io/split/android/client/service/events/EventsTrackerTest.java b/main/src/test/java/io/split/android/client/service/events/EventsTrackerTest.java deleted file mode 100644 index bf0b601e6..000000000 --- a/main/src/test/java/io/split/android/client/service/events/EventsTrackerTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package io.split.android.client.service.events; - -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -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.Mock; -import org.mockito.MockitoAnnotations; - -import io.split.android.client.EventsTracker; -import io.split.android.client.EventsTrackerImpl; -import io.split.android.client.ProcessedEventProperties; -import io.split.android.client.events.SplitEventsManager; -import io.split.android.client.service.synchronizer.SyncManager; -import io.split.android.client.telemetry.model.Method; -import io.split.android.client.telemetry.storage.TelemetryStorageProducer; -import io.split.android.client.validators.EventValidator; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationMessageLogger; - -public class EventsTrackerTest { - @Mock - private SplitEventsManager mEventsManager; - @Mock - private EventValidator mEventValidator; - @Mock - private ValidationMessageLogger mValidationLogger; - @Mock - private TelemetryStorageProducer mTelemetryStorageProducer; - @Mock - private PropertyValidator mPropertyValidator; - @Mock - private SyncManager mSyncManager; - - private EventsTracker mEventsTracker; - - @Before - public void setup() { - MockitoAnnotations.openMocks(this); - when(mEventValidator.validate(any(), anyBoolean())).thenReturn(null); - when(mEventsManager.eventAlreadyTriggered(any())).thenReturn(true); - when(mPropertyValidator.validate(any(), any())).thenReturn(PropertyValidator.Result.valid(null, 0)); - - mEventsTracker = new EventsTrackerImpl(mEventValidator, mValidationLogger, mTelemetryStorageProducer, - mPropertyValidator, mSyncManager); - } - - @Test - public void testTrackEnabled() throws InterruptedException { - trackingEnabledTest(true); - } - - @Test - public void testTrackDisabled() throws InterruptedException { - trackingEnabledTest(false); - } - - private void trackingEnabledTest(boolean enabled) throws InterruptedException { - mEventsTracker.enableTracking(enabled); - boolean res = mEventsTracker.track("pepe", "tt", null, 1.0, null, true); - Thread.sleep(500); - assertEquals(enabled, res); - if (enabled) { - verify(mSyncManager, times(1)).pushEvent(any()); - verify(mTelemetryStorageProducer, times(1)).recordLatency(Method.TRACK, 0L); - } else { - verify(mSyncManager, never()).pushEvent(any()); - verify(mTelemetryStorageProducer, never()).recordLatency(Method.TRACK, 0L); - } - } - - @Test - public void trackRecordsLatencyInEvaluationProducer() { - ProcessedEventProperties processedEventProperties = mock(ProcessedEventProperties.class); - when(processedEventProperties.isValid()).thenReturn(true); - mEventsTracker.track("any", "tt", "ev", 1, null, true); - - verify(mTelemetryStorageProducer).recordLatency(eq(Method.TRACK), anyLong()); - } - - @Test - public void trackRecordsExceptionInCaseThereIsOne() { - when(mPropertyValidator.validate(any(), any())).thenAnswer(invocation -> { - throw new Exception("test exception"); - }); - - mEventsTracker.track("event", "tt", "ev", 0, null, true); - - verify(mTelemetryStorageProducer).recordException(Method.TRACK); - } -} diff --git a/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugStrategyTest.kt b/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugStrategyTest.kt index 333a16b8d..cf6034c85 100644 --- a/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugStrategyTest.kt +++ b/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugStrategyTest.kt @@ -4,11 +4,11 @@ import io.split.android.client.dtos.KeyImpression 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.SplitTaskType import io.split.android.client.service.impressions.ImpressionsRecorderTask import io.split.android.client.service.impressions.ImpressionsTaskFactory import io.split.android.client.service.impressions.observer.ImpressionsObserver -import io.split.android.client.service.synchronizer.RecorderSyncHelper +import io.split.android.client.submitter.RecorderSyncHelper import io.split.android.client.telemetry.model.ImpressionsDataType import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer import org.junit.Before diff --git a/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugTrackerTest.kt b/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugTrackerTest.kt index 6ddca42f7..76d5ee1d9 100644 --- a/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugTrackerTest.kt +++ b/main/src/test/java/io/split/android/client/service/impressions/strategy/DebugTrackerTest.kt @@ -4,12 +4,12 @@ import io.split.android.client.dtos.KeyImpression 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.SplitTaskType import io.split.android.client.service.impressions.ImpressionsRecorderTask import io.split.android.client.service.impressions.ImpressionsTaskFactory import io.split.android.client.service.impressions.observer.ImpressionsObserver import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer -import io.split.android.client.service.synchronizer.RecorderSyncHelper +import io.split.android.client.submitter.RecorderSyncHelper import org.junit.Before import org.junit.Test import org.mockito.ArgumentCaptor diff --git a/main/src/test/java/io/split/android/client/service/impressions/strategy/NoneTrackerTest.kt b/main/src/test/java/io/split/android/client/service/impressions/strategy/NoneTrackerTest.kt index 8d1ebe1f9..617a578f9 100644 --- a/main/src/test/java/io/split/android/client/service/impressions/strategy/NoneTrackerTest.kt +++ b/main/src/test/java/io/split/android/client/service/impressions/strategy/NoneTrackerTest.kt @@ -6,7 +6,7 @@ import io.split.android.client.service.executor.SplitTaskExecutionInfo.DO_NOT_RE import io.split.android.client.service.executor.SplitTaskExecutionListener import io.split.android.client.service.executor.SplitTaskExecutor import io.split.android.client.service.executor.SplitTaskSerialWrapper -import io.split.android.client.service.executor.SplitTaskType +import io.split.android.client.service.SplitTaskType import io.split.android.client.service.impressions.* import io.split.android.client.service.impressions.unique.SaveUniqueImpressionsTask import io.split.android.client.service.impressions.unique.UniqueKeysRecorderTask diff --git a/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedStrategyTest.kt b/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedStrategyTest.kt index 198e4d43b..ac312c089 100644 --- a/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedStrategyTest.kt +++ b/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedStrategyTest.kt @@ -6,12 +6,12 @@ import io.split.android.client.service.executor.SplitTaskExecutionInfo import io.split.android.client.service.executor.SplitTaskExecutionInfo.DO_NOT_RETRY 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.SplitTaskType import io.split.android.client.service.impressions.ImpressionsCounter import io.split.android.client.service.impressions.ImpressionsRecorderTask import io.split.android.client.service.impressions.ImpressionsTaskFactory import io.split.android.client.service.impressions.observer.ImpressionsObserverImpl -import io.split.android.client.service.synchronizer.RecorderSyncHelper +import io.split.android.client.submitter.RecorderSyncHelper import io.split.android.client.telemetry.model.ImpressionsDataType import io.split.android.client.telemetry.storage.TelemetryRuntimeProducer import org.junit.Before diff --git a/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedTrackerTest.kt b/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedTrackerTest.kt index 71915b9ca..dca54065c 100644 --- a/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedTrackerTest.kt +++ b/main/src/test/java/io/split/android/client/service/impressions/strategy/OptimizedTrackerTest.kt @@ -6,11 +6,11 @@ import io.split.android.client.service.executor.SplitTaskExecutionInfo.DO_NOT_RE import io.split.android.client.service.executor.SplitTaskExecutionListener import io.split.android.client.service.executor.SplitTaskExecutor import io.split.android.client.service.executor.SplitTaskSerialWrapper -import io.split.android.client.service.executor.SplitTaskType +import io.split.android.client.service.SplitTaskType import io.split.android.client.service.impressions.* import io.split.android.client.service.impressions.observer.ImpressionsObserver import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer -import io.split.android.client.service.synchronizer.RecorderSyncHelper +import io.split.android.client.submitter.RecorderSyncHelper import org.junit.Before import org.junit.Test import org.mockito.ArgumentCaptor diff --git a/main/src/test/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTaskTest.java b/main/src/test/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTaskTest.java index 93839a0ad..f12c33e44 100644 --- a/main/src/test/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/impressions/unique/UniqueKeysRecorderTaskTest.java @@ -25,7 +25,7 @@ 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.storage.impressions.PersistentImpressionsUniqueStorage; diff --git a/main/src/test/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfigTest.java b/main/src/test/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfigTest.java index 9973189e6..6e1e224de 100644 --- a/main/src/test/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfigTest.java +++ b/main/src/test/java/io/split/android/client/service/mysegments/LoadMySegmentsTaskConfigTest.java @@ -4,7 +4,7 @@ import org.junit.Test; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; public class LoadMySegmentsTaskConfigTest { diff --git a/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java b/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java index 7e2d8b3c0..207c5c479 100644 --- a/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java +++ b/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsSyncTaskConfigTest.java @@ -5,7 +5,7 @@ import org.junit.Test; import io.split.android.client.events.SplitInternalEvent; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.telemetry.model.OperationType; public class MySegmentsSyncTaskConfigTest { diff --git a/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfigTest.java b/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfigTest.java index ea7f26181..b212b848e 100644 --- a/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfigTest.java +++ b/main/src/test/java/io/split/android/client/service/mysegments/MySegmentsUpdateTaskConfigTest.java @@ -5,7 +5,7 @@ import org.junit.Test; import io.split.android.client.events.SplitInternalEvent; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.telemetry.model.streaming.UpdatesFromSSEEnum; public class MySegmentsUpdateTaskConfigTest { 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/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/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/SplitUpdateWorkerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/SplitUpdateWorkerTest.java index b946113db..e81b07a94 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,12 +22,12 @@ 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; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.rules.RuleBasedSegmentInPlaceUpdateTask; import io.split.android.client.service.splits.SplitInPlaceUpdateTask; import io.split.android.client.service.sseclient.notifications.InstantUpdateChangeNotification; @@ -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/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/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/RetryBackoffCounterTimerTest.java b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/RetryBackoffCounterTimerTest.java index 36f847b39..2aafc3dcd 100644 --- a/main/src/test/java/io/split/android/client/service/sseclient/sseclient/RetryBackoffCounterTimerTest.java +++ b/main/src/test/java/io/split/android/client/service/sseclient/sseclient/RetryBackoffCounterTimerTest.java @@ -26,8 +26,8 @@ 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.BackoffCounter; +import io.split.android.client.service.SplitTaskType; +import io.split.android.client.backoff.BackoffCounter; public class RetryBackoffCounterTimerTest { 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/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/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()); + } +} 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/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java b/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java index 515d3dd3e..32215d873 100644 --- a/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java +++ b/main/src/test/java/io/split/android/client/service/synchronizer/FeatureFlagsSynchronizerImplTest.java @@ -34,7 +34,7 @@ import io.split.android.client.service.executor.SplitTaskExecutionListener; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskFactory; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.rules.LoadRuleBasedSegmentsTask; import io.split.android.client.service.splits.FilterSplitsInCacheTask; import io.split.android.client.service.splits.LoadSplitsTask; diff --git a/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java b/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java index 252c29696..8a6a861e9 100644 --- a/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java +++ b/main/src/test/java/io/split/android/client/service/synchronizer/LoadLocalDataListenerTest.java @@ -16,7 +16,7 @@ import io.split.android.client.events.ISplitEventsManager; import io.split.android.client.events.SplitInternalEvent; import io.split.android.client.service.executor.SplitTaskExecutionInfo; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; public class LoadLocalDataListenerTest { diff --git a/main/src/test/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImplTest.java b/main/src/test/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImplTest.java index 1888bf831..56d1be867 100644 --- a/main/src/test/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImplTest.java +++ b/main/src/test/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImplTest.java @@ -10,8 +10,9 @@ 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.storage.common.StoragePusher; +import io.split.android.client.service.SplitTaskType; +import io.split.android.client.submitter.RecorderSyncHelperImpl; +import io.split.android.client.submitter.StoragePusher; public class RecorderSyncHelperImplTest { diff --git a/main/src/test/java/io/split/android/client/service/synchronizer/mysegments/MySegmentsSynchronizerImplTest.java b/main/src/test/java/io/split/android/client/service/synchronizer/mysegments/MySegmentsSynchronizerImplTest.java index 2c87b101a..a542cbd0a 100644 --- a/main/src/test/java/io/split/android/client/service/synchronizer/mysegments/MySegmentsSynchronizerImplTest.java +++ b/main/src/test/java/io/split/android/client/service/synchronizer/mysegments/MySegmentsSynchronizerImplTest.java @@ -28,7 +28,7 @@ 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.SplitTaskType; import io.split.android.client.service.mysegments.LoadMySegmentsTask; import io.split.android.client.service.mysegments.MySegmentUpdateParams; import io.split.android.client.service.mysegments.MySegmentsSyncTask; diff --git a/main/src/test/java/io/split/android/client/service/telemetry/SynchronizerImplTelemetryTest.java b/main/src/test/java/io/split/android/client/service/telemetry/SynchronizerImplTelemetryTest.java index 2c83e3ada..914b73e8f 100644 --- a/main/src/test/java/io/split/android/client/service/telemetry/SynchronizerImplTelemetryTest.java +++ b/main/src/test/java/io/split/android/client/service/telemetry/SynchronizerImplTelemetryTest.java @@ -22,7 +22,7 @@ import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskFactory; -import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.service.SplitTaskType; import io.split.android.client.service.impressions.StrategyImpressionManager; import io.split.android.client.service.splits.SplitsSyncTask; import io.split.android.client.service.sseclient.feedbackchannel.PushManagerEventBroadcaster; diff --git a/main/src/test/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTaskTest.java b/main/src/test/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTaskTest.java index f3182ffdc..6ee10c633 100644 --- a/main/src/test/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/telemetry/TelemetryConfigRecorderTaskTest.java @@ -20,7 +20,7 @@ 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.telemetry.model.Config; diff --git a/main/src/test/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTaskTest.java b/main/src/test/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTaskTest.java index 7ab3064ae..adbe1137e 100644 --- a/main/src/test/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTaskTest.java +++ b/main/src/test/java/io/split/android/client/service/telemetry/TelemetryStatsRecorderTaskTest.java @@ -19,7 +19,7 @@ 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.SplitTaskType; import io.split.android.client.service.http.HttpRecorder; import io.split.android.client.service.http.HttpRecorderException; import io.split.android.client.telemetry.model.OperationType; diff --git a/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java b/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java index 7ee312106..db0f905c3 100644 --- a/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java +++ b/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java @@ -28,7 +28,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; -import io.split.android.client.EventsTracker; +import io.split.android.client.tracker.Tracker; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitClientFactory; @@ -68,7 +68,7 @@ public class SplitClientContainerImplTest { private MySegmentsWorkManagerWrapper mWorkManagerWrapper; @Mock - private EventsTracker mEventsTracker; + private Tracker mEventsTracker; private final String mDefaultMatchingKey = "matching_key"; private SplitClientContainer mClientContainer; diff --git a/main/src/test/java/io/split/android/client/telemetry/TelemetrySynchronizerImplTest.java b/main/src/test/java/io/split/android/client/telemetry/TelemetrySynchronizerImplTest.java index bf099811e..2c565c82c 100644 --- a/main/src/test/java/io/split/android/client/telemetry/TelemetrySynchronizerImplTest.java +++ b/main/src/test/java/io/split/android/client/telemetry/TelemetrySynchronizerImplTest.java @@ -22,7 +22,7 @@ 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.SplitTaskType; import io.split.android.client.service.sseclient.sseclient.RetryBackoffCounterTimer; import io.split.android.client.service.telemetry.TelemetryConfigRecorderTask; import io.split.android.client.service.telemetry.TelemetryStatsRecorderTask; diff --git a/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java b/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java index 50fec3e8f..e2857760b 100644 --- a/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java +++ b/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java @@ -6,7 +6,7 @@ import java.util.Collections; -import io.split.android.client.EventsTracker; +import io.split.android.client.tracker.Tracker; import io.split.android.client.FlagSetsFilterImpl; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitClientImpl; @@ -53,7 +53,7 @@ false, new AttributesMergerImpl(), telemetryStorage, splitParser, new ImpressionListener.NoopImpressionListener(), cfg, eventsManager, - mock(EventsTracker.class), + mock(Tracker.class), attributesManager, mock(SplitValidator.class), treatmentManagerFactory.getTreatmentManager(key, eventsManager, attributesManager) @@ -74,7 +74,7 @@ public static SplitClientImpl get(Key key, ImpressionListener impressionListener impressionListener, cfg, new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()), - mock(EventsTracker.class), + mock(Tracker.class), mock(AttributesManager.class), mock(SplitValidator.class), mock(TreatmentManager.class) @@ -91,7 +91,7 @@ public static SplitClientImpl get(Key key, SplitEventsManager eventsManager) { new ImpressionListener.NoopImpressionListener(), SplitClientConfig.builder().build(), eventsManager, - mock(EventsTracker.class), + mock(Tracker.class), mock(AttributesManager.class), mock(SplitValidator.class), mock(TreatmentManager.class) diff --git a/main/src/test/java/io/split/android/client/validators/EventValidatorTest.java b/main/src/test/java/io/split/android/client/validators/EventValidatorTest.java deleted file mode 100644 index 7f1e033da..000000000 --- a/main/src/test/java/io/split/android/client/validators/EventValidatorTest.java +++ /dev/null @@ -1,305 +0,0 @@ -package io.split.android.client.validators; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import io.split.android.client.dtos.Event; -import io.split.android.client.dtos.Split; -import io.split.android.client.dtos.Status; -import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.client.utils.Utils; - -public class EventValidatorTest { - - private EventValidator validator; - - @Before - public void setUp() { - - SplitsStorage splitsStorage = mock(SplitsStorage.class); - - when(splitsStorage.isValidTrafficType("traffic1")).thenReturn(true); - when(splitsStorage.isValidTrafficType("trafficType1")).thenReturn(true); - when(splitsStorage.isValidTrafficType("custom")).thenReturn(true); - - validator = new EventValidatorImpl(new KeyValidatorImpl(), splitsStorage); - } - - @Test - public void testValidEventAllValues() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = "pepe"; - event.value = 1.0; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNull(errorInfo); - } - - @Test - public void testValidEventNullValue() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = "pepe"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNull(errorInfo); - } - - @Test - public void testNullKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = null; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed a null key, matching key must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testEmptyKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = ""; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testAllSpacesInKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = " "; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testLongKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = Utils.repeat("p", 300); - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("matching key too long - must be " + ValidationConfig.getInstance().getMaximumKeyLength() + " characters or less", errorInfo.getErrorMessage()); - } - - @Test - public void testNullType() { - Event event = new Event(); - event.eventTypeId = null; - event.trafficTypeName = "traffic1"; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed a null or undefined event_type, event_type must be a non-empty String", errorInfo.getErrorMessage()); - } - - @Test - public void testEmptyType() { - Event event = new Event(); - event.eventTypeId = ""; - event.trafficTypeName = "traffic1"; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", errorInfo.getErrorMessage()); - } - - @Test - public void testAllSpacesInType() { - Event event = new Event(); - event.eventTypeId = " "; - event.trafficTypeName = "traffic1"; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", errorInfo.getErrorMessage()); - } - - @Test - public void testTypeName() { - - EventTypeNameHelper nameHelper = new EventTypeNameHelper(); - Event event1 = newEventTypeName(); - Event event2 = newEventTypeName(); - Event event3 = newEventTypeName(); - Event event4 = newEventTypeName(); - Event event5 = newEventTypeName(); - - event1.eventTypeId = nameHelper.getValidAllValidChars(); - event2.eventTypeId = nameHelper.getValidStartNumber(); - event3.eventTypeId = nameHelper.getInvalidChars(); - event4.eventTypeId = nameHelper.getInvalidUndercoreStart(); - event5.eventTypeId = nameHelper.getInvalidHypenStart(); - - ValidationErrorInfo errorInfo1 = validator.validate(event1, true); - ValidationErrorInfo errorInfo2 = validator.validate(event2, true); - ValidationErrorInfo errorInfo3 = validator.validate(event3, true); - ValidationErrorInfo errorInfo4 = validator.validate(event4, true); - ValidationErrorInfo errorInfo5 = validator.validate(event5, true); - - Assert.assertNull(errorInfo1); - - Assert.assertNull(errorInfo2); - - Assert.assertNotNull(errorInfo3); - Assert.assertTrue(errorInfo3.isError()); - Assert.assertEquals(buildEventTypeValidationMessage(event3.eventTypeId), errorInfo3.getErrorMessage()); - - Assert.assertNotNull(errorInfo4); - Assert.assertTrue(errorInfo4.isError()); - Assert.assertEquals(buildEventTypeValidationMessage(event4.eventTypeId), errorInfo4.getErrorMessage()); - - Assert.assertNotNull(errorInfo5); - Assert.assertTrue(errorInfo5.isError()); - Assert.assertEquals(buildEventTypeValidationMessage(event5.eventTypeId), errorInfo5.getErrorMessage()); - } - - @Test - public void testNullTrafficType() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = null; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testEmptyTrafficType() { - - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = ""; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testAllSpacesInTrafficType() { - - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = " "; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testUppercaseCharsInTrafficType() { - - Event event0 = newEventUppercase(); - Event event1 = newEventUppercase(); - Event event2 = newEventUppercase(); - Event event3 = newEventUppercase(); - - final String uppercaseMessage = "traffic_type_name should be all lowercase - converting string to lowercase"; - - event0.trafficTypeName = "custom"; - event1.trafficTypeName = "Custom"; - event2.trafficTypeName = "cUSTom"; - event3.trafficTypeName = "custoM"; - - ValidationErrorInfo errorInfo0 = validator.validate(event0, true); - ValidationErrorInfo errorInfo1 = validator.validate(event1, true); - ValidationErrorInfo errorInfo2 = validator.validate(event2, true); - ValidationErrorInfo errorInfo3 = validator.validate(event3, true); - - - Assert.assertNull(errorInfo0); - - Assert.assertNotNull(errorInfo1); - Assert.assertFalse(errorInfo1.isError()); - Assert.assertEquals(uppercaseMessage, errorInfo1.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS)); - - Assert.assertNotNull(errorInfo2); - Assert.assertFalse(errorInfo2.isError()); - Assert.assertEquals(uppercaseMessage, errorInfo2.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS)); - - Assert.assertNotNull(errorInfo3); - Assert.assertFalse(errorInfo3.isError()); - Assert.assertEquals(uppercaseMessage, errorInfo3.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS)); - } - - @Test - public void noChachedServerTrafficType() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "nocached"; - event.key = "key1"; - - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertFalse(errorInfo.isError()); - Assert.assertEquals("Traffic Type nocached does not have any corresponding feature flags in this environment, " - + "make sure you’re tracking your events to a valid traffic type defined in the Split user interface", errorInfo.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_WITHOUT_SPLIT_IN_ENVIRONMENT)); - } - - private Event newEventTypeName() { - Event event = new Event(); - event.trafficTypeName = "traffic1"; - event.key = "key1"; - return event; - } - - private Event newEventUppercase() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.key = "key1"; - return event; - } - - private String buildEventTypeValidationMessage(String eventType) { - return "you passed " + eventType - + ", event name must adhere to the regular expression " + ValidationConfig.getInstance().getTrackEventNamePattern() - + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " - + " underscore, period, or colon as separators of alphanumeric characters."; - } - - private Split newSplit(String name, String trafficType) { - Split split = new Split(); - split.name = name; - split.trafficTypeName = trafficType; - split.status = Status.ACTIVE; - return split; - } -} diff --git a/main/src/test/java/io/split/android/client/validators/PropertyValidatorAdapterTest.java b/main/src/test/java/io/split/android/client/validators/PropertyValidatorAdapterTest.java new file mode 100644 index 000000000..3c5bc6c4c --- /dev/null +++ b/main/src/test/java/io/split/android/client/validators/PropertyValidatorAdapterTest.java @@ -0,0 +1,83 @@ +package io.split.android.client.validators; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.tracker.TrackerPropertyValidator; + +public class PropertyValidatorAdapterTest { + + @Mock + private TrackerPropertyValidator mDelegate; + + private PropertyValidatorAdapter mAdapter; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + mAdapter = new PropertyValidatorAdapter(mDelegate); + } + + @Test + public void validateDelegatesToTrackerValidatorAndReturnsValidResult() { + Map properties = new HashMap<>(); + properties.put("key1", "value1"); + + TrackerPropertyValidator.TrackerPropertyResult delegateResult = + TrackerPropertyValidator.TrackerPropertyResult.valid(properties, 100); + when(mDelegate.validate(eq(properties), eq(0), eq("test-tag"))) + .thenReturn(delegateResult); + + PropertyValidator.Result result = mAdapter.validate(properties, "test-tag"); + + assertTrue(result.isValid()); + assertEquals(properties, result.getProperties()); + assertEquals(100, result.getSizeInBytes()); + assertNull(result.getErrorMessage()); + verify(mDelegate).validate(eq(properties), eq(0), eq("test-tag")); + } + + @Test + public void validateDelegatesToTrackerValidatorAndReturnsInvalidResult() { + Map properties = new HashMap<>(); + + TrackerPropertyValidator.TrackerPropertyResult delegateResult = + TrackerPropertyValidator.TrackerPropertyResult.invalid("Properties are too large", 50); + when(mDelegate.validate(eq(properties), eq(0), eq("test-tag"))) + .thenReturn(delegateResult); + + PropertyValidator.Result result = mAdapter.validate(properties, "test-tag"); + + assertFalse(result.isValid()); + assertNull(result.getProperties()); + assertEquals(50, result.getSizeInBytes()); + assertEquals("Properties are too large", result.getErrorMessage()); + verify(mDelegate).validate(eq(properties), eq(0), eq("test-tag")); + } + + @Test + public void validatePassesZeroAsInitialSizeInBytes() { + Map properties = new HashMap<>(); + TrackerPropertyValidator.TrackerPropertyResult delegateResult = + TrackerPropertyValidator.TrackerPropertyResult.valid(properties, 0); + when(mDelegate.validate(eq(properties), eq(0), eq("tag"))) + .thenReturn(delegateResult); + + mAdapter.validate(properties, "tag"); + + verify(mDelegate).validate(eq(properties), eq(0), eq("tag")); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/TrafficTypeValidatorImplTest.java b/main/src/test/java/io/split/android/client/validators/TrafficTypeValidatorImplTest.java new file mode 100644 index 000000000..cc0d7e071 --- /dev/null +++ b/main/src/test/java/io/split/android/client/validators/TrafficTypeValidatorImplTest.java @@ -0,0 +1,47 @@ +package io.split.android.client.validators; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.split.android.client.storage.splits.SplitsStorage; + +public class TrafficTypeValidatorImplTest { + + @Mock + private SplitsStorage mSplitsStorage; + + private TrafficTypeValidatorImpl mValidator; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + mValidator = new TrafficTypeValidatorImpl(mSplitsStorage); + } + + @Test + public void isValidDelegatesToSplitsStorage() { + when(mSplitsStorage.isValidTrafficType("user")).thenReturn(true); + + boolean result = mValidator.isValid("user"); + + assertTrue(result); + verify(mSplitsStorage).isValidTrafficType("user"); + } + + @Test + public void isValidReturnsFalseWhenStorageReturnsFalse() { + when(mSplitsStorage.isValidTrafficType("unknown")).thenReturn(false); + + boolean result = mValidator.isValid("unknown"); + + assertFalse(result); + verify(mSplitsStorage).isValidTrafficType("unknown"); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/ValidationMessageLoggerImplTest.java b/main/src/test/java/io/split/android/client/validators/ValidationMessageLoggerImplTest.java new file mode 100644 index 000000000..cd2c47670 --- /dev/null +++ b/main/src/test/java/io/split/android/client/validators/ValidationMessageLoggerImplTest.java @@ -0,0 +1,170 @@ +package io.split.android.client.validators; + +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; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.tracker.TrackerValidationError; +import io.split.android.client.utils.logger.Logger; + +public class ValidationMessageLoggerImplTest { + + private ValidationMessageLoggerImpl mLogger; + + @Before + public void setUp() { + mLogger = new ValidationMessageLoggerImpl(); + } + + @Test + public void logErrorInfoWithErrorMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(200, "error message"); + + mLogger.log(errorInfo, "test-tag"); + + // Due to parameter swap in e() method, actual output is "error message: test-tag" + loggerMock.verify(() -> Logger.e(eq("error message: test-tag"))); + } + } + + @Test + public void logErrorInfoWithWarnings() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(100, "warning 1", true); + errorInfo.addWarning(101, "warning 2"); + + mLogger.log(errorInfo, "test-tag"); + + // Due to parameter swap in w() method, actual output is "warning X: test-tag" + loggerMock.verify(() -> Logger.w(eq("warning 1: test-tag"))); + loggerMock.verify(() -> Logger.w(eq("warning 2: test-tag"))); + } + } + + @Test + public void logErrorInfoWithNullErrorMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(100, "warning message", true); + + mLogger.log(errorInfo, "test-tag"); + + loggerMock.verify(() -> Logger.w(eq("warning message: test-tag"))); + loggerMock.verify(() -> Logger.e(anyString()), never()); + } + } + + @Test + public void logErrorWithValidationErrorInfo() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(200, "error message"); + + mLogger.e(errorInfo, "test-tag"); + + loggerMock.verify(() -> Logger.e(eq("error message: test-tag"))); + } + } + + @Test + public void logWarningWithValidationErrorInfo() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(100, "first warning", true); + errorInfo.addWarning(101, "second warning"); + + mLogger.w(errorInfo, "test-tag"); + + loggerMock.verify(() -> Logger.w(eq("first warning: test-tag"))); + loggerMock.verify(() -> Logger.w(eq("second warning: test-tag"))); + } + } + + @Test + public void logErrorWithStringMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + // Note: parameter order is (message, tag) in signature, but used as (tag, message) in implementation + mLogger.e("test-tag", "error message"); + + loggerMock.verify(() -> Logger.e(eq("error message: test-tag"))); + } + } + + @Test + public void logWarningWithStringMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + // Note: parameter order is (message, tag) in signature, but used as (tag, message) in implementation + mLogger.w("test-tag", "warning message"); + + loggerMock.verify(() -> Logger.w(eq("warning message: test-tag"))); + } + } + + @Test + public void sanitizeTagWithNullTag() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + mLogger.e((String) null, "error message"); + + loggerMock.verify(() -> Logger.e(eq("error message: null"))); + } + } + + // TrackerLogger implementation tests + + @Test + public void trackerLoggerLogWithError() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + TrackerValidationError errorInfo = new TrackerValidationError(true, "tracker error"); + + mLogger.log(errorInfo, "tracker-tag"); + + loggerMock.verify(() -> Logger.e(eq("tracker-tag: tracker error"))); + } + } + + @Test + public void trackerLoggerLogWithWarnings() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + TrackerValidationError errorInfo = new TrackerValidationError( + Arrays.asList("warning 1", "warning 2", "warning 3")); + + mLogger.log(errorInfo, "tracker-tag"); + + loggerMock.verify(() -> Logger.w(eq("tracker-tag: warning 1"))); + loggerMock.verify(() -> Logger.w(eq("tracker-tag: warning 2"))); + loggerMock.verify(() -> Logger.w(eq("tracker-tag: warning 3"))); + loggerMock.verify(() -> Logger.e(anyString()), never()); + } + } + + @Test + public void trackerLoggerLogWithEmptyWarnings() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + TrackerValidationError errorInfo = new TrackerValidationError(Collections.emptyList()); + + mLogger.log(errorInfo, "tracker-tag"); + + loggerMock.verify(() -> Logger.w(anyString()), never()); + loggerMock.verify(() -> Logger.e(anyString()), never()); + } + } + + @Test + public void trackerLoggerVerboseMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + mLogger.v("verbose message"); + + loggerMock.verify(() -> Logger.v(eq("verbose message"))); + } + } +} diff --git a/settings.gradle b/settings.gradle index b584365a6..a435328d4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,15 @@ rootProject.name = 'android-client' include ':api' include ':logger' +include ':http-api' +include ':http' +include ':fallback' include ':main' include ':events' include ':events-domain' +include ':streaming' +include ':streaming-support' +include ':executor' +include ':backoff' +include ':tracker' +include ':submitter' diff --git a/sonar-project.properties b/sonar-project.properties index f598dd559..684e89b56 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,15 +3,24 @@ 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: api, events-domain, main, events, logger, http-api, http, fallback, backoff, tracker, submitter +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,fallback/src/main/java,backoff/src/main/java,tracker/src/main/java,submitter/src/main/java,streaming/src/main/java # Path to compiled classes (multi-module) -# Include binary paths for all modules: main, events, logger +# 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,\ 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-api/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + http/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + streaming/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes + fallback/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + backoff/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + tracker/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ + submitter/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes # Path to dependency/libraries jars (multi-module) sonar.java.libraries=\ @@ -26,11 +35,47 @@ 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,\ + 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,\ + 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 + fallback/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + fallback/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + fallback/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + fallback/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + backoff/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + backoff/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + backoff/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + backoff/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + tracker/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + tracker/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + tracker/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + tracker/build/intermediates/compile_and_runtime_r_class_jar/debugUnitTest/generateDebugUnitTestStubRFile/R.jar,\ + submitter/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar,\ + submitter/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar,\ + submitter/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar,\ + submitter/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=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,fallback/src/test/java,backoff/src/test/java,tracker/src/test/java,submitter/src/test/java # Encoding of the source code sonar.sourceEncoding=UTF-8 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..6ecd47470 --- /dev/null +++ b/streaming-support/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../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(); + } +} 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..f6c11bd07 --- /dev/null +++ b/streaming/README.md @@ -0,0 +1,61 @@ +# Streaming Module + +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). + +## 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. | + +### 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. | + +## Usage + +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. +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 diff --git a/streaming/build.gradle b/streaming/build.gradle new file mode 100644 index 000000000..ec095cedf --- /dev/null +++ b/streaming/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../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 + + // 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/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/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/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/EventSourceClientImpl.java b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java new file mode 100644 index 000000000..905e4a842 --- /dev/null +++ b/streaming/src/main/java/io/split/android/client/service/sseclient/sseclient/EventSourceClientImpl.java @@ -0,0 +1,139 @@ +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 java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +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; +import io.split.android.client.utils.logger.Logger; + +/** + * 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 AtomicInteger mStatus; + private final StreamingTransport mStreamingTransport; + private final EventStreamParser mEventStreamParser; + private final AtomicBoolean mIsDisconnectCalled; + + @Nullable + private volatile StreamingConnection mStreamingConnection; + @Nullable + private volatile StreamingResponse mStreamingResponse; + + public EventSourceClientImpl(@NonNull StreamingTransport streamingTransport, + @NonNull EventStreamParser eventStreamParser) { + mStreamingTransport = Objects.requireNonNull(streamingTransport); + mEventStreamParser = Objects.requireNonNull(eventStreamParser); + mStatus = new AtomicInteger(DISCONNECTED); + mIsDisconnectCalled = new AtomicBoolean(false); + } + + @Override + public int status() { + return mStatus.get(); + } + + @Override + public void disconnect() { + if (!mIsDisconnectCalled.getAndSet(true)) { + close(); + } + } + + @Override + public void connect(@NonNull URI url, @NonNull EventHandler handler) { + mIsDisconnectCalled.set(false); + mStatus.set(CONNECTING); + boolean isErrorRetryable = true; + BufferedReader bufferedReader = null; + try { + mStreamingConnection = mStreamingTransport.connect(url); + mStreamingResponse = mStreamingConnection.execute(); + if (mStreamingResponse.isSuccess()) { + bufferedReader = mStreamingResponse.getBufferedReader(); + if (bufferedReader != null) { + 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)) { + handler.onMessage(values); + values = new HashMap<>(); + } + } + } else { + throw new IOException("Buffer is null"); + } + } else { + Logger.e("SSE connection error. Http return code " + mStreamingResponse.getHttpStatus()); + isErrorRetryable = !mStreamingResponse.isClientRelatedError(); + } + } catch (StreamingTransportException e) { + logError("An error has occurred during SSE transport", e); + isErrorRetryable = !isNotRetryableStatusCode(e.getStatusCode()); + } catch (IOException e) { + Logger.d("SSE stream read error: " + e.getLocalizedMessage()); + isErrorRetryable = true; + } catch (Exception e) { + logError("An unexpected error has occurred during SSE connection", e); + isErrorRetryable = true; + } finally { + if (!mIsDisconnectCalled.getAndSet(false)) { + 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(@Nullable Integer statusCode) { + if (statusCode == null) { + return false; + } + 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/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))); + } +} diff --git a/submitter/.gitignore b/submitter/.gitignore new file mode 100644 index 000000000..e4dbec6f2 --- /dev/null +++ b/submitter/.gitignore @@ -0,0 +1,3 @@ +/build +.classpath +.settings diff --git a/submitter/README.md b/submitter/README.md new file mode 100644 index 000000000..5735f8783 --- /dev/null +++ b/submitter/README.md @@ -0,0 +1,12 @@ +# submitter + +Generic batch recorder task abstraction. + +## Purpose + +Encapsulates the logic for submitting batched data (such as impressions and events) to the backend. It provides a reusable abstraction for recorder tasks, decoupled from the SDK's internal storage and networking layers. Dependencies are injected via callbacks. + +## Design notes + +- For now depends on `events-domain` for the executor types. +- Depends on `logger` for logging. diff --git a/submitter/build.gradle b/submitter/build.gradle new file mode 100644 index 000000000..02bba6959 --- /dev/null +++ b/submitter/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.submitter' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation clientModuleProject('executor') + implementation clientModuleProject('logger') + implementation libs.annotation + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/submitter/src/main/AndroidManifest.xml b/submitter/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9a40236b9 --- /dev/null +++ b/submitter/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/main/src/main/java/io/split/android/client/storage/common/InBytesSizable.java b/submitter/src/main/java/io/split/android/client/submitter/InBytesSizable.java similarity index 57% rename from main/src/main/java/io/split/android/client/storage/common/InBytesSizable.java rename to submitter/src/main/java/io/split/android/client/submitter/InBytesSizable.java index 645224611..6cb1fbcd1 100644 --- a/main/src/main/java/io/split/android/client/storage/common/InBytesSizable.java +++ b/submitter/src/main/java/io/split/android/client/submitter/InBytesSizable.java @@ -1,4 +1,4 @@ -package io.split.android.client.storage.common; +package io.split.android.client.submitter; public interface InBytesSizable { long getSizeInBytes(); diff --git a/submitter/src/main/java/io/split/android/client/submitter/RecorderException.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderException.java new file mode 100644 index 000000000..31e49ffcb --- /dev/null +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderException.java @@ -0,0 +1,20 @@ +package io.split.android.client.submitter; + +public class RecorderException extends Exception { + private final Integer mHttpStatus; + private final boolean mRetryable; + + public RecorderException(String message, Integer httpStatus, boolean retryable) { + super(message); + this.mHttpStatus = httpStatus; + this.mRetryable = retryable; + } + + public Integer getHttpStatus() { + return mHttpStatus; + } + + public boolean isRetryable() { + return mRetryable; + } +} diff --git a/submitter/src/main/java/io/split/android/client/submitter/RecorderStorage.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderStorage.java new file mode 100644 index 000000000..350190c54 --- /dev/null +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderStorage.java @@ -0,0 +1,10 @@ +package io.split.android.client.submitter; + +import androidx.annotation.NonNull; +import java.util.List; + +public interface RecorderStorage { + List pop(int count); + void delete(@NonNull List items); + void setActive(@NonNull List items); +} diff --git a/submitter/src/main/java/io/split/android/client/submitter/RecorderSubmitter.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderSubmitter.java new file mode 100644 index 000000000..9ea278617 --- /dev/null +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderSubmitter.java @@ -0,0 +1,7 @@ +package io.split.android.client.submitter; + +import androidx.annotation.NonNull; + +public interface RecorderSubmitter { + void execute(@NonNull T data) throws RecorderException; +} diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/RecorderSyncHelper.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderSyncHelper.java similarity index 75% rename from main/src/main/java/io/split/android/client/service/synchronizer/RecorderSyncHelper.java rename to submitter/src/main/java/io/split/android/client/submitter/RecorderSyncHelper.java index fc84c75e4..fd70cba57 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/RecorderSyncHelper.java +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderSyncHelper.java @@ -1,7 +1,6 @@ -package io.split.android.client.service.synchronizer; +package io.split.android.client.submitter; import io.split.android.client.service.executor.SplitTaskExecutionListener; -import io.split.android.client.storage.common.InBytesSizable; public interface RecorderSyncHelper extends SplitTaskExecutionListener { boolean pushAndCheckIfFlushNeeded(T entity); diff --git a/main/src/main/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImpl.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderSyncHelperImpl.java similarity index 90% rename from main/src/main/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImpl.java rename to submitter/src/main/java/io/split/android/client/submitter/RecorderSyncHelperImpl.java index 4351930fb..09488a69e 100644 --- a/main/src/main/java/io/split/android/client/service/synchronizer/RecorderSyncHelperImpl.java +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderSyncHelperImpl.java @@ -1,11 +1,10 @@ -package io.split.android.client.service.synchronizer; - -import static io.split.android.client.utils.Utils.checkNotNull; +package io.split.android.client.submitter; import androidx.annotation.NonNull; import java.lang.ref.WeakReference; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -16,8 +15,6 @@ 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.storage.common.InBytesSizable; -import io.split.android.client.storage.common.StoragePusher; public class RecorderSyncHelperImpl implements RecorderSyncHelper { @@ -35,9 +32,9 @@ public RecorderSyncHelperImpl(SplitTaskType taskType, int maxQueueSize, long maxQueueSizeInBytes, SplitTaskExecutor splitTaskExecutor) { - mTaskType = checkNotNull(taskType); - mStorage = checkNotNull(storage); - mSplitTaskExecutor = checkNotNull(splitTaskExecutor); + mTaskType = Objects.requireNonNull(taskType); + mStorage = Objects.requireNonNull(storage); + mSplitTaskExecutor = Objects.requireNonNull(splitTaskExecutor); mPushedCount = new AtomicInteger(0); mTotalPushedSizeInBytes = new AtomicLong(0); mMaxQueueSize = maxQueueSize; diff --git a/submitter/src/main/java/io/split/android/client/submitter/RecorderTask.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderTask.java new file mode 100644 index 000000000..5a822812e --- /dev/null +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderTask.java @@ -0,0 +1,145 @@ +package io.split.android.client.submitter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.split.android.client.service.executor.SplitTask; +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.utils.logger.Logger; + +/** + * Abstract base class for batch submission tasks. + *

+ * Encapsulates the common pop-submit-retry-setActive pattern used by + * impressions, events, and other batch recorder tasks. + * + * @param The storage item type (e.g., KeyImpression, Event) + * @param The submission payload type (e.g., List, ImpressionsCount) + */ +public abstract class RecorderTask implements SplitTask { + + private final RecorderStorage mStorage; + private final RecorderSubmitter mSubmitter; + private final int mBatchSize; + private final SplitTaskType mTaskType; + @Nullable + private final RecorderTelemetry mTelemetry; + private final int mFailingChunkSize; // 0 = no chunking + + protected RecorderTask(@NonNull RecorderStorage storage, + @NonNull RecorderSubmitter submitter, + int batchSize, + @NonNull SplitTaskType taskType, + @Nullable RecorderTelemetry telemetry, + int failingChunkSize) { + mStorage = storage; + mSubmitter = submitter; + mBatchSize = batchSize; + mTaskType = taskType; + mTelemetry = telemetry; + mFailingChunkSize = failingChunkSize; + } + + @NonNull + @Override + public final SplitTaskExecutionInfo execute() { + SplitTaskExecutionStatus status = SplitTaskExecutionStatus.SUCCESS; + int nonSentRecords = 0; + long nonSentBytes = 0; + List items; + List failingItems = new ArrayList<>(); + boolean doNotRetry = false; + + do { + items = mStorage.pop(mBatchSize); + if (!items.isEmpty()) { + long startTime = System.currentTimeMillis(); + try { + R payload = transformForSubmission(items); + mSubmitter.execute(payload); + + long now = System.currentTimeMillis(); + if (mTelemetry != null) { + mTelemetry.recordSuccess(now); + } + + mStorage.delete(items); + } catch (RecorderException e) { + status = SplitTaskExecutionStatus.ERROR; + nonSentRecords += items.size(); + nonSentBytes += sumBytes(items); + Logger.e("RecorderTask: " + items.size() + " items couldn't be submitted. " + + "Saving to retry in a new iteration: " + e.getLocalizedMessage()); + failingItems.addAll(items); + + if (mTelemetry != null) { + mTelemetry.recordError(e.getHttpStatus()); + } + + if (!e.isRetryable()) { + doNotRetry = true; + break; + } + } finally { + if (mTelemetry != null) { + mTelemetry.recordLatency(System.currentTimeMillis() - startTime); + } + } + } + } while (items.size() == mBatchSize); + + // Re-queue failed items for retry + if (!failingItems.isEmpty()) { + if (mFailingChunkSize > 0) { + // Chunk to avoid SQLite errors (used by EventsRecorderTask) + int size = failingItems.size(); + for (int i = 0; i < size; i += mFailingChunkSize) { + mStorage.setActive(failingItems.subList(i, Math.min(i + mFailingChunkSize, size))); + } + } else { + mStorage.setActive(failingItems); + } + } + + if (status == SplitTaskExecutionStatus.ERROR) { + Map data = new HashMap<>(); + data.put(SplitTaskExecutionInfo.NON_SENT_RECORDS, nonSentRecords); + data.put(SplitTaskExecutionInfo.NON_SENT_BYTES, nonSentBytes); + if (doNotRetry) { + data.put(SplitTaskExecutionInfo.DO_NOT_RETRY, true); + } + return SplitTaskExecutionInfo.error(mTaskType, data); + } + + return SplitTaskExecutionInfo.success(mTaskType); + } + + /** + * Transform storage items into the submission payload before submitting. + */ + protected abstract R transformForSubmission(List items); + + /** + * Estimate the byte size of one storage item for tracking non-sent bytes. + *

+ * Default returns 0. Override to enable byte tracking. + */ + protected long estimateItemSize(T item) { + return 0; + } + + private long sumBytes(List items) { + long total = 0; + for (T item : items) { + total += estimateItemSize(item); + } + return total; + } +} diff --git a/submitter/src/main/java/io/split/android/client/submitter/RecorderTelemetry.java b/submitter/src/main/java/io/split/android/client/submitter/RecorderTelemetry.java new file mode 100644 index 000000000..7f8665ab2 --- /dev/null +++ b/submitter/src/main/java/io/split/android/client/submitter/RecorderTelemetry.java @@ -0,0 +1,7 @@ +package io.split.android.client.submitter; + +public interface RecorderTelemetry { + void recordSuccess(long timestamp); + void recordError(Integer httpStatus); + void recordLatency(long latencyMs); +} diff --git a/main/src/main/java/io/split/android/client/storage/common/StoragePusher.java b/submitter/src/main/java/io/split/android/client/submitter/StoragePusher.java similarity index 69% rename from main/src/main/java/io/split/android/client/storage/common/StoragePusher.java rename to submitter/src/main/java/io/split/android/client/submitter/StoragePusher.java index aa8e6cd6d..1ec49d849 100644 --- a/main/src/main/java/io/split/android/client/storage/common/StoragePusher.java +++ b/submitter/src/main/java/io/split/android/client/submitter/StoragePusher.java @@ -1,4 +1,4 @@ -package io.split.android.client.storage.common; +package io.split.android.client.submitter; import androidx.annotation.NonNull; diff --git a/submitter/src/test/java/io/split/android/client/submitter/RecorderTaskTest.java b/submitter/src/test/java/io/split/android/client/submitter/RecorderTaskTest.java new file mode 100644 index 000000000..e9e3c6e36 --- /dev/null +++ b/submitter/src/test/java/io/split/android/client/submitter/RecorderTaskTest.java @@ -0,0 +1,546 @@ +package io.split.android.client.submitter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +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 androidx.annotation.NonNull; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.List; + +import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskExecutionStatus; +import io.split.android.client.service.executor.SplitTaskType; + +@SuppressWarnings("unchecked") +public class RecorderTaskTest { + + private static final int BATCH_SIZE = 10; + private static final SplitTaskType TASK_TYPE = SplitTaskType.GENERIC_TASK; + + private RecorderStorage mStorage; + private RecorderSubmitter> mSubmitter; + private RecorderTelemetry mTelemetry; + + @Before + public void setUp() { + mStorage = Mockito.mock(RecorderStorage.class); + mSubmitter = Mockito.mock(RecorderSubmitter.class); + mTelemetry = Mockito.mock(RecorderTelemetry.class); + } + + // region Successful submission + + @Test + public void successfulSingleBatchSubmission() throws RecorderException { + List batch = createItems(5); // less than BATCH_SIZE → loop terminates after one iteration + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + verify(mStorage, times(1)).pop(BATCH_SIZE); + verify(mSubmitter, times(1)).execute(batch); + verify(mStorage, times(1)).delete(batch); + verify(mStorage, never()).setActive(any()); + assertEquals(TASK_TYPE, result.getTaskType()); + assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); + assertNull(result.getIntegerValue(SplitTaskExecutionInfo.NON_SENT_RECORDS)); + assertNull(result.getLongValue(SplitTaskExecutionInfo.NON_SENT_BYTES)); + assertNull(result.getBoolValue(SplitTaskExecutionInfo.DO_NOT_RETRY)); + } + + @Test + public void successfulMultiBatchSubmissionLoopsUntilSmallBatch() throws RecorderException { + List fullBatch = createItems(BATCH_SIZE); + List partialBatch = createItems(3); + + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(fullBatch) + .thenReturn(fullBatch) + .thenReturn(partialBatch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + // Three pops: full, full, partial (terminates) + verify(mStorage, times(3)).pop(BATCH_SIZE); + verify(mSubmitter, times(3)).execute(any()); + verify(mStorage, times(3)).delete(any()); + verify(mStorage, never()).setActive(any()); + assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); + } + + @Test + public void emptyFirstPopSkipsSubmissionAndSucceeds() throws RecorderException { + when(mStorage.pop(BATCH_SIZE)).thenReturn(new ArrayList<>()); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + verify(mStorage, times(1)).pop(BATCH_SIZE); + verify(mSubmitter, never()).execute(any()); + verify(mStorage, never()).delete(any()); + verify(mStorage, never()).setActive(any()); + assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); + } + + // endregion + + // region Error handling + + @Test + public void retryableErrorCollectsFailuresAndContinuesLoop() throws RecorderException { + List batch = createItems(BATCH_SIZE); + List partialBatch = createItems(3); + + // First pop returns a full batch (fails), second also returns full (succeeds), + // third returns partial (terminates) + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(batch) + .thenReturn(partialBatch); + + // Throw only on the first call to execute; subsequent calls succeed + doThrow(new RecorderException("retryable error", 500, true)) + .doNothing() + .doNothing() + .when(mSubmitter).execute(any()); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + // Three pops total: two full batches + one partial + verify(mStorage, times(3)).pop(BATCH_SIZE); + // First batch failed → not deleted; second and partial → deleted twice + verify(mStorage, times(2)).delete(any()); + // Failing items (one batch worth) are re-queued + verify(mStorage, times(1)).setActive(any()); + + assertEquals(SplitTaskExecutionStatus.ERROR, result.getStatus()); + assertEquals(Integer.valueOf(BATCH_SIZE), result.getIntegerValue(SplitTaskExecutionInfo.NON_SENT_RECORDS)); + } + + @Test + public void retryableErrorPopulatesNonSentRecordsCount() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + assertEquals(Integer.valueOf(BATCH_SIZE), result.getIntegerValue(SplitTaskExecutionInfo.NON_SENT_RECORDS)); + assertEquals(SplitTaskExecutionStatus.ERROR, result.getStatus()); + } + + @Test + public void nonRetryableErrorStopsLoopImmediately() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(batch); // Would be popped if loop continued + doThrow(new RecorderException("non-retryable", 9009, false)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + // Only one pop — loop broke immediately on non-retryable error + verify(mStorage, times(1)).pop(BATCH_SIZE); + verify(mStorage, never()).delete(any()); + verify(mStorage, times(1)).setActive(any()); + + assertEquals(SplitTaskExecutionStatus.ERROR, result.getStatus()); + assertTrue(Boolean.TRUE.equals(result.getBoolValue(SplitTaskExecutionInfo.DO_NOT_RETRY))); + } + + @Test + public void retryableErrorDoesNotSetDoNotRetry() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + assertNull(result.getBoolValue(SplitTaskExecutionInfo.DO_NOT_RETRY)); + } + + // endregion + + // region setActive + + @Test + public void setActiveIsCalledWithFailedItemsOnError() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mStorage, times(1)).setActive(batch); + } + + @Test + public void setActiveIsNotCalledOnSuccess() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mStorage, never()).setActive(any()); + } + + @Test + public void chunkedSetActiveWhenFailingChunkSizeIsPositive() throws RecorderException { + int failingChunkSize = 3; + // Create items whose count is a multiple of failingChunkSize for predictable verification + List batch = createItems(9); // 9 items / chunkSize 3 = 3 setActive calls + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, failingChunkSize); + task.execute(); + + // 9 items chunked into 3 → 3 setActive calls + verify(mStorage, times(3)).setActive(any()); + } + + @Test + public void chunkedSetActiveHandlesNonEvenDivision() throws RecorderException { + int failingChunkSize = 3; + // 10 items / chunkSize 3 = 4 calls (chunks of 3, 3, 3, 1) + List batch = createItems(10); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, failingChunkSize); + task.execute(); + + verify(mStorage, times(4)).setActive(any()); + } + + @Test + public void noChunkingWhenFailingChunkSizeIsZero() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + // No chunking → exactly one setActive call with all failing items + verify(mStorage, times(1)).setActive(batch); + } + + // endregion + + // region Byte tracking via estimateItemSize + + @Test + public void byteTrackingViaEstimateItemSizeOverride() throws RecorderException { + long itemSizeBytes = 50L; + List batch = createItems(BATCH_SIZE); // 10 items * 50 bytes = 500 bytes + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTaskWithItemSize(BATCH_SIZE, TASK_TYPE, mTelemetry, 0, itemSizeBytes); + SplitTaskExecutionInfo result = task.execute(); + + long expectedBytes = BATCH_SIZE * itemSizeBytes; + assertEquals(Long.valueOf(expectedBytes), result.getLongValue(SplitTaskExecutionInfo.NON_SENT_BYTES)); + } + + @Test + public void byteTrackingDefaultsToZeroWhenNotOverridden() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + // Default estimateItemSize returns 0 → nonSentBytes = 0 + assertEquals(Long.valueOf(0L), result.getLongValue(SplitTaskExecutionInfo.NON_SENT_BYTES)); + } + + // endregion + + // region transformForSubmission + + @Test + public void transformForSubmissionHookIsApplied() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + // Build a task with a custom transform that wraps items in a new list + RecorderTask> task = new SimpleRecorderTask( + mStorage, mSubmitter, BATCH_SIZE, TASK_TYPE, mTelemetry, 0) { + @Override + protected List transformForSubmission(List items) { + List transformed = new ArrayList<>(); + for (String item : items) { + transformed.add(item.toUpperCase()); + } + return transformed; + } + }; + task.execute(); + + // The submitter should receive the transformed list (all uppercase) + List expectedTransformed = new ArrayList<>(); + for (String item : batch) { + expectedTransformed.add(item.toUpperCase()); + } + verify(mSubmitter, times(1)).execute(expectedTransformed); + } + + // endregion + + // region Null telemetry + + @Test + public void nullTelemetryDoesNotThrowNpeOnSuccess() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, null, 0); + // Should not throw + SplitTaskExecutionInfo result = task.execute(); + + assertEquals(SplitTaskExecutionStatus.SUCCESS, result.getStatus()); + } + + @Test + public void nullTelemetryDoesNotThrowNpeOnError() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, null, 0); + // Should not throw + SplitTaskExecutionInfo result = task.execute(); + + assertEquals(SplitTaskExecutionStatus.ERROR, result.getStatus()); + } + + // endregion + + // region Telemetry interactions + + @Test + public void telemetryRecordSuccessCalledOnSuccessfulSubmission() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, times(1)).recordSuccess(anyLong()); + } + + @Test + public void telemetryRecordSuccessCalledOncePerBatch() throws RecorderException { + List fullBatch = createItems(BATCH_SIZE); + List partialBatch = createItems(3); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(fullBatch) + .thenReturn(partialBatch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, times(2)).recordSuccess(anyLong()); + } + + @Test + public void telemetryRecordLatencyCalledOnSuccess() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, atLeastOnce()).recordLatency(anyLong()); + } + + @Test + public void telemetryRecordLatencyCalledOnError() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, atLeastOnce()).recordLatency(anyLong()); + } + + @Test + public void telemetryRecordErrorCalledWithHttpStatusOnError() throws RecorderException { + int httpStatus = 500; + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", httpStatus, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, times(1)).recordError(httpStatus); + } + + @Test + public void telemetryRecordSuccessNotCalledOnError() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, never()).recordSuccess(anyLong()); + } + + @Test + public void telemetryRecordErrorNotCalledOnSuccess() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + task.execute(); + + verify(mTelemetry, never()).recordError(any(Integer.class)); + } + + // endregion + + // region Task type + + @Test + public void taskTypeIsPreservedInSuccessResult() throws RecorderException { + List batch = createItems(3); + when(mStorage.pop(BATCH_SIZE)).thenReturn(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + assertEquals(TASK_TYPE, result.getTaskType()); + } + + @Test + public void taskTypeIsPreservedInErrorResult() throws RecorderException { + List batch = createItems(BATCH_SIZE); + when(mStorage.pop(BATCH_SIZE)) + .thenReturn(batch) + .thenReturn(new ArrayList<>()); + doThrow(new RecorderException("error", 500, true)).when(mSubmitter).execute(batch); + + RecorderTask> task = buildTask(BATCH_SIZE, TASK_TYPE, mTelemetry, 0); + SplitTaskExecutionInfo result = task.execute(); + + assertEquals(TASK_TYPE, result.getTaskType()); + } + + // endregion + + // region Helpers + + private List createItems(int count) { + List items = new ArrayList<>(); + for (int i = 0; i < count; i++) { + items.add("item_" + i); + } + return items; + } + + /** + * Builds a standard {@link RecorderTask} with no custom overrides. + * Uses the default {@link RecorderTask#transformForSubmission} (identity cast) and + * {@link RecorderTask#estimateItemSize} (returns 0). + */ + private RecorderTask> buildTask(int batchSize, + SplitTaskType taskType, + RecorderTelemetry telemetry, + int failingChunkSize) { + return new SimpleRecorderTask(mStorage, mSubmitter, batchSize, taskType, telemetry, failingChunkSize); + } + + /** + * Builds a {@link RecorderTask} with a custom fixed item size returned from + * {@link RecorderTask#estimateItemSize}, to exercise byte tracking. + */ + private RecorderTask> buildTaskWithItemSize(int batchSize, + SplitTaskType taskType, + RecorderTelemetry telemetry, + int failingChunkSize, + long itemSizeBytes) { + return new SimpleRecorderTask(mStorage, mSubmitter, batchSize, taskType, telemetry, failingChunkSize) { + @Override + protected long estimateItemSize(String item) { + return itemSizeBytes; + } + }; + } + + /** + * Minimal concrete subclass of {@link RecorderTask} for testing. + * T = String, R = List<String> (identity transform — same type). + */ + private static class SimpleRecorderTask extends RecorderTask> { + + SimpleRecorderTask(@NonNull RecorderStorage storage, + @NonNull RecorderSubmitter> submitter, + int batchSize, + @NonNull SplitTaskType taskType, + RecorderTelemetry telemetry, + int failingChunkSize) { + super(storage, submitter, batchSize, taskType, telemetry, failingChunkSize); + } + + @Override + protected List transformForSubmission(List items) { + return items; + } + } + + // endregion +} diff --git a/tracker/.gitignore b/tracker/.gitignore new file mode 100644 index 000000000..e4dbec6f2 --- /dev/null +++ b/tracker/.gitignore @@ -0,0 +1,3 @@ +/build +.classpath +.settings diff --git a/tracker/README.md b/tracker/README.md new file mode 100644 index 000000000..b6cdd9b1d --- /dev/null +++ b/tracker/README.md @@ -0,0 +1,38 @@ +# tracker + +Self-contained event-tracking module. + +## Purpose + +Encapsulates the logic for validating and dispatching track events. Dependencies are injected via callbacks. + +## Public API + +| Class / Interface | Role | +|---|---| +| `Tracker` | Primary interface. `enableTracking(boolean)` / `track(...)` | +| `DefaultTracker` | Default implementation | +| `TrackerEvent` | Domain object representing a validated event (no serialization concerns) | +| `TrackerEventValidator` | Validates key, traffic type, event type, value | +| `TrackerPropertyValidator` | Validates event properties; returns `TrackerPropertyResult` | +| `TrackerLogger` | Logging abstraction (`log`, `e`, `v`) | +| `TrackerValidationError` | Simple error/warning result (`isError`, `getMessage`) | + +## Wiring (in `main/`) + +`DefaultTracker` is wired in `SplitFactoryImpl.EventsTrackerProvider`: + +```java +new DefaultTracker( + new EventValidatorImpl(keyValidator, splitsStorage), // implements TrackerEventValidator + new ValidationMessageLoggerImpl(), // implements TrackerLogger + new PropertyValidatorImpl(), // implements TrackerPropertyValidator + trackerEvent -> { + // convert TrackerEvent → Event DTO, then push + mSyncManager.pushEvent(toEvent(trackerEvent)); + }, + latencyMs -> mTelemetryStorage.recordLatency(Method.TRACK, latencyMs) +); +``` + +The `onTrackLatency` callback is optional (pass `null` to skip telemetry). diff --git a/tracker/build.gradle b/tracker/build.gradle new file mode 100644 index 000000000..0ba030834 --- /dev/null +++ b/tracker/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.tracker' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/DefaultTracker.java b/tracker/src/main/java/io/split/android/client/tracker/DefaultTracker.java new file mode 100644 index 000000000..8e06a1e26 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/DefaultTracker.java @@ -0,0 +1,110 @@ +package io.split.android.client.tracker; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +public class DefaultTracker implements Tracker { + + // Estimated event size in bytes without properties + private static final int ESTIMATED_EVENT_SIZE_WITHOUT_PROPS = 1024; + + /** Callback invoked with the validated event when tracking succeeds. */ + public interface OnEventPush { + void accept(TrackerEvent event); + } + + /** Callback invoked with the track latency in milliseconds. May be null to skip telemetry. */ + public interface OnTrackLatency { + void accept(long latencyMs); + } + + /** Callback invoked when an exception occurs during tracking. May be null to skip telemetry. */ + public interface OnTrackException { + void accept(); + } + + @NonNull private final TrackerEventValidator mEventValidator; + @NonNull private final TrackerLogger mTrackerLogger; + @NonNull private final TrackerPropertyValidator mPropertyValidator; + @NonNull private final OnEventPush mOnEventPush; + @Nullable private final OnTrackLatency mOnTrackLatency; + @Nullable private final OnTrackException mOnTrackException; + private final AtomicBoolean isTrackingEnabled = new AtomicBoolean(true); + + public DefaultTracker(@NonNull TrackerEventValidator eventValidator, + @NonNull TrackerLogger trackerLogger, + @NonNull TrackerPropertyValidator propertyValidator, + @NonNull OnEventPush onEventPush, + @Nullable OnTrackLatency onTrackLatency, + @Nullable OnTrackException onTrackException) { + mEventValidator = Objects.requireNonNull(eventValidator, "eventValidator must not be null"); + mTrackerLogger = Objects.requireNonNull(trackerLogger, "trackerLogger must not be null"); + mPropertyValidator = Objects.requireNonNull(propertyValidator, "propertyValidator must not be null"); + mOnEventPush = Objects.requireNonNull(onEventPush, "onEventPush must not be null"); + mOnTrackLatency = onTrackLatency; + mOnTrackException = onTrackException; + } + + @Override + public void enableTracking(boolean enable) { + isTrackingEnabled.set(enable); + } + + @Override + public boolean track(String key, String trafficType, String eventType, + double value, Map properties, boolean isSdkReady) { + if (!isTrackingEnabled.get()) { + mTrackerLogger.v("Event not tracked because tracking is disabled"); + return false; + } + + try { + final String validationTag = "track"; + + TrackerValidationError errorInfo = mEventValidator.validate( + key, trafficType, eventType, value, properties, isSdkReady); + if (errorInfo != null) { + if (errorInfo.isError()) { + mTrackerLogger.e(errorInfo.getMessage(), validationTag); + return false; + } + mTrackerLogger.log(errorInfo, validationTag); + trafficType = trafficType.toLowerCase(); + } + + TrackerPropertyValidator.TrackerPropertyResult processedProperties = + mPropertyValidator.validate(properties, ESTIMATED_EVENT_SIZE_WITHOUT_PROPS, validationTag); + if (!processedProperties.isValid()) { + return false; + } + + long startTime = System.currentTimeMillis(); + + TrackerEvent event = new TrackerEvent(); + event.eventType = eventType; + event.trafficType = trafficType; + event.key = key; + event.value = value; + event.timestamp = System.currentTimeMillis(); + event.properties = processedProperties.getProperties(); + event.sizeInBytes = processedProperties.getSizeInBytes(); + mOnEventPush.accept(event); + + if (mOnTrackLatency != null) { + mOnTrackLatency.accept(System.currentTimeMillis() - startTime); + } + + return true; + } catch (Exception exception) { + mTrackerLogger.e("Exception while tracking event: " + exception.getMessage(), "track"); + if (mOnTrackException != null) { + mOnTrackException.accept(); + } + } + return false; + } +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/Tracker.java b/tracker/src/main/java/io/split/android/client/tracker/Tracker.java new file mode 100644 index 000000000..aa2d2401f --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/Tracker.java @@ -0,0 +1,10 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +public interface Tracker { + void enableTracking(boolean enable); + + boolean track(String key, String trafficType, String eventType, double value, + Map properties, boolean isSdkReady); +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerEvent.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerEvent.java new file mode 100644 index 000000000..dcdade61a --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerEvent.java @@ -0,0 +1,17 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +/** + * Domain object representing a track event inside the tracker module. + * This is intentionally separate from the networking DTO (Event) used in main/. + */ +public class TrackerEvent { + public String trafficType; + public String eventType; + public String key; + public double value; + public long timestamp; + public Map properties; + public int sizeInBytes; +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerEventValidator.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerEventValidator.java new file mode 100644 index 000000000..a9d6285e3 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerEventValidator.java @@ -0,0 +1,12 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +/** + * Validates event parameters before tracking. + * Returns null if valid, or a {@link TrackerValidationError} with error/warning info. + */ +public interface TrackerEventValidator { + TrackerValidationError validate(String key, String trafficTypeName, String eventTypeId, + Double value, Map properties, boolean isSdkReady); +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerLogger.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerLogger.java new file mode 100644 index 000000000..bc8a46873 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerLogger.java @@ -0,0 +1,15 @@ +package io.split.android.client.tracker; + +/** + * Logging abstraction for the tracker module. + */ +public interface TrackerLogger { + /** Log a validation result (error or warning) with a tag. */ + void log(TrackerValidationError errorInfo, String tag); + + /** Log an error message with a tag. */ + void e(String message, String tag); + + /** Log a verbose message. */ + void v(String message); +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerPropertyValidator.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerPropertyValidator.java new file mode 100644 index 000000000..2246109da --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerPropertyValidator.java @@ -0,0 +1,61 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +/** + * Validates and processes event properties. + */ +public interface TrackerPropertyValidator { + + /** + * Validates event properties. + * + * @param properties raw properties map (may be null) + * @param initialSizeInBytes base event size in bytes (before properties), added to computed + * property size to produce the total in {@link TrackerPropertyResult#getSizeInBytes()} + * @param validationTag tag used for log messages + * @return validation result containing processed properties and total size + */ + TrackerPropertyResult validate(Map properties, int initialSizeInBytes, + String validationTag); + + class TrackerPropertyResult { + private final boolean mIsValid; + private final Map mProperties; + private final int mSizeInBytes; + private final String mErrorMessage; + + private TrackerPropertyResult(boolean isValid, Map properties, + int sizeInBytes, String errorMessage) { + mIsValid = isValid; + mProperties = properties; + mSizeInBytes = sizeInBytes; + mErrorMessage = errorMessage; + } + + public static TrackerPropertyResult valid(Map properties, int sizeInBytes) { + return new TrackerPropertyResult(true, properties, sizeInBytes, null); + } + + public static TrackerPropertyResult invalid(String errorMessage, int sizeInBytes) { + return new TrackerPropertyResult(false, null, sizeInBytes, errorMessage); + } + + public boolean isValid() { + return mIsValid; + } + + public Map getProperties() { + return mProperties; + } + + /** Total event size in bytes (initial base size + properties size). */ + public int getSizeInBytes() { + return mSizeInBytes; + } + + public String getErrorMessage() { + return mErrorMessage; + } + } +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerValidationError.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerValidationError.java new file mode 100644 index 000000000..099a0516f --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerValidationError.java @@ -0,0 +1,37 @@ +package io.split.android.client.tracker; + +import java.util.Collections; +import java.util.List; + +/** + * Simple error/warning result from tracker validation. + */ +public class TrackerValidationError { + private final boolean mIsError; + private final String mMessage; + private final List mWarnings; + + public TrackerValidationError(boolean isError, String message) { + mIsError = isError; + mMessage = message; + mWarnings = Collections.emptyList(); + } + + public TrackerValidationError(List warnings) { + mIsError = false; + mMessage = null; + mWarnings = (warnings != null) ? warnings : Collections.emptyList(); + } + + public boolean isError() { + return mIsError; + } + + public String getMessage() { + return mMessage; + } + + public List getWarnings() { + return mWarnings; + } +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrafficTypeValidator.java b/tracker/src/main/java/io/split/android/client/tracker/TrafficTypeValidator.java new file mode 100644 index 000000000..d1278947e --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrafficTypeValidator.java @@ -0,0 +1,14 @@ +package io.split.android.client.tracker; + +/** + * Interface for validating traffic type names. + */ +public interface TrafficTypeValidator { + /** + * Checks if the given traffic type name is valid. + * + * @param trafficTypeName the traffic type name to validate + * @return true if the traffic type is valid, false otherwise + */ + boolean isValid(String trafficTypeName); +} diff --git a/tracker/src/main/java/io/split/android/client/validators/EventValidatorImpl.java b/tracker/src/main/java/io/split/android/client/validators/EventValidatorImpl.java new file mode 100644 index 000000000..7c477e3ba --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/validators/EventValidatorImpl.java @@ -0,0 +1,73 @@ +package io.split.android.client.validators; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.split.android.client.tracker.TrafficTypeValidator; +import io.split.android.client.tracker.TrackerEventValidator; +import io.split.android.client.tracker.TrackerValidationError; + +/** + * Event validator implementation for the tracker module. + */ +public class EventValidatorImpl implements TrackerEventValidator { + + private final String TYPE_REGEX = ValidationConfig.getInstance().getTrackEventNamePattern(); + private final KeyValidator mKeyValidator; + private final TrafficTypeValidator mTrafficTypeValidator; + + public EventValidatorImpl(KeyValidator keyValidator, TrafficTypeValidator trafficTypeValidator) { + mKeyValidator = keyValidator; + mTrafficTypeValidator = trafficTypeValidator; + } + + @Override + public TrackerValidationError validate(String key, String trafficTypeName, String eventTypeId, + Double value, Map properties, boolean isSdkReady) { + ValidationErrorInfo errorInfo = mKeyValidator.validate(key, null); + if(errorInfo != null){ + return new TrackerValidationError(true, errorInfo.getErrorMessage()); + } + + if (trafficTypeName == null) { + return new TrackerValidationError(true, "you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string"); + } + + if (ValidationUtils.isNullOrEmpty(trafficTypeName.trim())) { + return new TrackerValidationError(true, "you passed an empty traffic_type_name, traffic_type_name must be a non-empty string"); + } + + if (eventTypeId == null) { + return new TrackerValidationError(true, "you passed a null or undefined event_type, event_type must be a non-empty String"); + } + + if (ValidationUtils.isNullOrEmpty(eventTypeId.trim())) { + return new TrackerValidationError(true, "you passed an empty event_type, event_type must be a non-empty String"); + } + + if (!eventTypeId.matches(TYPE_REGEX)) { + return new TrackerValidationError(true, "you passed " + eventTypeId + + ", event name must adhere to the regular expression " + TYPE_REGEX + + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " + + " underscore, period, or colon as separators of alphanumeric characters."); + } + + List warnings = new ArrayList<>(); + + if(!trafficTypeName.toLowerCase().equals(trafficTypeName)) { + warnings.add("traffic_type_name should be all lowercase - converting string to lowercase"); + } + + if (isSdkReady && !mTrafficTypeValidator.isValid(trafficTypeName)) { + String message = "Traffic Type " + trafficTypeName + " does not have any corresponding feature flags in this environment, " + + "make sure you’re tracking your events to a valid traffic type defined in the Split user interface"; + warnings.add(message); + } + + if (warnings.isEmpty()) { + return null; + } + return new TrackerValidationError(warnings); + } +} diff --git a/main/src/main/java/io/split/android/client/validators/KeyValidator.java b/tracker/src/main/java/io/split/android/client/validators/KeyValidator.java similarity index 100% rename from main/src/main/java/io/split/android/client/validators/KeyValidator.java rename to tracker/src/main/java/io/split/android/client/validators/KeyValidator.java diff --git a/main/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java b/tracker/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java similarity index 90% rename from main/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java rename to tracker/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java index c22a8daa5..fcdf0d931 100644 --- a/main/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java +++ b/tracker/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java @@ -1,7 +1,5 @@ package io.split.android.client.validators; -import io.split.android.client.utils.Utils; - /** * Validates an instance of Key class. */ @@ -17,7 +15,7 @@ public ValidationErrorInfo validate(String matchingKey, String bucketingKey) { return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed a null key, matching key must be a non-empty string"); } - if (Utils.isNullOrEmpty(matchingKey.trim())) { + if (ValidationUtils.isNullOrEmpty(matchingKey.trim())) { return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME,"you passed an empty string, matching key must be a non-empty string"); } @@ -26,7 +24,7 @@ public ValidationErrorInfo validate(String matchingKey, String bucketingKey) { } if (bucketingKey != null) { - if (Utils.isNullOrEmpty(bucketingKey.trim())) { + if (ValidationUtils.isNullOrEmpty(bucketingKey.trim())) { return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed an empty string, bucketing key must be null or a non-empty string"); } diff --git a/tracker/src/main/java/io/split/android/client/validators/PropertyValidatorImpl.java b/tracker/src/main/java/io/split/android/client/validators/PropertyValidatorImpl.java new file mode 100644 index 000000000..343a87428 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/validators/PropertyValidatorImpl.java @@ -0,0 +1,99 @@ +package io.split.android.client.validators; + +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerPropertyValidator; + + +public class PropertyValidatorImpl implements TrackerPropertyValidator { + + private final TrackerLogger mLogger; + + private final static int MAX_PROPS_COUNT = 300; + private final static int MAXIMUM_EVENT_PROPERTY_BYTES = + ValidationConfig.getInstance().getMaximumEventPropertyBytes(); + + public PropertyValidatorImpl(TrackerLogger logger) { + mLogger = logger; + } + + /** + * Internal validation logic - returns a simple result with properties and size. + */ + private InternalResult validateInternal(Map properties, String validationTag) { + if (properties == null) { + return new InternalResult(true, null, 0, null); + } + + if (properties.size() > MAX_PROPS_COUNT) { + mLogger.v(validationTag + "Event has more than " + MAX_PROPS_COUNT + + " properties. Some of them will be trimmed when processed"); + } + int sizeInBytes = 0; + Map finalProperties = new HashMap<>(properties); + + for (Map.Entry entry : properties.entrySet()) { + Object value = entry.getValue(); + String key = entry.getKey(); + + if (value != null && isInvalidValueType(value)) { + finalProperties.put(key, null); + } + sizeInBytes += calculateEventSizeInBytes(key, value); + + if (sizeInBytes > MAXIMUM_EVENT_PROPERTY_BYTES) { + mLogger.v(validationTag + + "The maximum size allowed for the " + + " properties is 32kb. Current is " + key + + ". Event not queued"); + return new InternalResult(false, null, sizeInBytes, "Event properties size is too large"); + } + } + return new InternalResult(true, finalProperties, sizeInBytes, null); + } + + private static boolean isInvalidValueType(Object value) { + return !(value instanceof Number) && + !(value instanceof Boolean) && + !(value instanceof String); + } + + private static int calculateEventSizeInBytes(String key, Object value) { + int valueSize = 0; + if(value != null && value.getClass() == String.class) { + valueSize = value.toString().getBytes().length; + } + return valueSize + key.getBytes().length; + } + + @Override + public TrackerPropertyResult validate(Map properties, int initialSizeInBytes, + String validationTag) { + InternalResult result = validateInternal(properties, validationTag); + int totalSize = initialSizeInBytes + result.sizeInBytes; + if (result.isValid) { + return TrackerPropertyResult.valid(result.properties, totalSize); + } else { + return TrackerPropertyResult.invalid(result.errorMessage, totalSize); + } + } + + /** + * Internal result class to avoid depending on main module's PropertyValidator.Result. + */ + private static class InternalResult { + final boolean isValid; + final Map properties; + final int sizeInBytes; + final String errorMessage; + + InternalResult(boolean isValid, Map properties, int sizeInBytes, String errorMessage) { + this.isValid = isValid; + this.properties = properties; + this.sizeInBytes = sizeInBytes; + this.errorMessage = errorMessage; + } + } +} diff --git a/main/src/main/java/io/split/android/client/validators/ValidationConfig.java b/tracker/src/main/java/io/split/android/client/validators/ValidationConfig.java similarity index 100% rename from main/src/main/java/io/split/android/client/validators/ValidationConfig.java rename to tracker/src/main/java/io/split/android/client/validators/ValidationConfig.java diff --git a/main/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java b/tracker/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java similarity index 91% rename from main/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java rename to tracker/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java index ef1346a1d..6e920d7e4 100644 --- a/main/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java +++ b/tracker/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java @@ -19,11 +19,11 @@ public class ValidationErrorInfo { private Map mWarnings = new HashMap<>(); @SuppressWarnings("SameParameterValue") - ValidationErrorInfo(int code, String message) { + public ValidationErrorInfo(int code, String message) { this(code, message, false); } - ValidationErrorInfo(int code, String message, boolean isWarning) { + public ValidationErrorInfo(int code, String message, boolean isWarning) { if(!isWarning){ mError = code; mErrorMessage = message; diff --git a/tracker/src/main/java/io/split/android/client/validators/ValidationUtils.java b/tracker/src/main/java/io/split/android/client/validators/ValidationUtils.java new file mode 100644 index 000000000..32593df4f --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/validators/ValidationUtils.java @@ -0,0 +1,23 @@ +package io.split.android.client.validators; + +import androidx.annotation.Nullable; + +/** + * Utility methods for validator implementations. + */ +public class ValidationUtils { + + /** + * Checks if a string is null or empty. + * + * @param string the string to check + * @return true if the string is null or empty, false otherwise + */ + public static boolean isNullOrEmpty(@Nullable String string) { + return string == null || string.isEmpty(); + } + + private ValidationUtils() { + // Utility class + } +} diff --git a/tracker/src/test/java/io/split/android/client/tracker/DefaultTrackerTest.java b/tracker/src/test/java/io/split/android/client/tracker/DefaultTrackerTest.java new file mode 100644 index 000000000..4c2a9fb62 --- /dev/null +++ b/tracker/src/test/java/io/split/android/client/tracker/DefaultTrackerTest.java @@ -0,0 +1,217 @@ +package io.split.android.client.tracker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.mockito.ArgumentCaptor; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class DefaultTrackerTest { + + @Mock + private TrackerEventValidator mEventValidator; + @Mock + private TrackerLogger mTrackerLogger; + @Mock + private TrackerPropertyValidator mPropertyValidator; + @Mock + private DefaultTracker.OnEventPush mOnEventPush; + @Mock + private DefaultTracker.OnTrackLatency mOnTrackLatency; + @Mock + private DefaultTracker.OnTrackException mOnTrackException; + + private DefaultTracker mTracker; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(null); + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.valid(null, 0)); + + mTracker = new DefaultTracker(mEventValidator, mTrackerLogger, mPropertyValidator, + mOnEventPush, mOnTrackLatency, mOnTrackException); + } + + @Test + public void trackingEnabledByDefault() { + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mOnEventPush).accept(any()); + } + + @Test + public void trackDisabledReturnsFalse() { + mTracker.enableTracking(false); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + verify(mOnEventPush, never()).accept(any()); + } + + @Test + public void trackDisabledLogsVerbose() { + mTracker.enableTracking(false); + + mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + verify(mTrackerLogger).v("Event not tracked because tracking is disabled"); + } + + @Test + public void validationErrorBlocksTracking() { + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(new TrackerValidationError(true, "bad event")); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + verify(mTrackerLogger).e(eq("bad event"), anyString()); + verify(mOnEventPush, never()).accept(any()); + } + + @Test + public void validationWarningAllowsTracking() { + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(new TrackerValidationError(Collections.singletonList("traffic type uppercase"))); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mTrackerLogger).log(any(TrackerValidationError.class), anyString()); + verify(mOnEventPush).accept(any()); + } + + @Test + public void validationWarningLowercasesTrafficType() { + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(new TrackerValidationError(Collections.singletonList("traffic type has uppercase chars"))); + + mTracker.track("key", "TRAFFIC", "eventType", 1.0, null, true); + + verify(mOnEventPush).accept(argThat(event -> "traffic".equals(event.trafficType))); + } + + @Test + public void propertyValidationErrorBlocksTracking() { + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.invalid("too large", 0)); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, new HashMap<>(), true); + + assertFalse(result); + verify(mOnEventPush, never()).accept(any()); + } + + @Test + public void successfulTrackInvokesOnEventPush() { + Map props = new HashMap<>(); + props.put("k", "v"); + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.valid(props, 1024)); + + boolean result = mTracker.track("key", "traffic", "eventType", 2.0, props, true); + + assertTrue(result); + verify(mOnEventPush).accept(any()); + } + + @Test + public void successfulTrackInvokesLatencyCallback() { + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mOnTrackLatency).accept(any(Long.class)); + } + + @Test + public void nullLatencyCallbackDoesNotCrash() { + mTracker = new DefaultTracker(mEventValidator, mTrackerLogger, mPropertyValidator, + mOnEventPush, null, null); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mOnEventPush).accept(any()); + } + + @Test + public void exceptionDuringTrackingInvokesOnTrackException() { + doThrow(new RuntimeException("push failed")).when(mOnEventPush).accept(any()); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + verify(mOnTrackException).accept(); + } + + @Test + public void nullExceptionCallbackDoesNotCrashOnException() { + mTracker = new DefaultTracker(mEventValidator, mTrackerLogger, mPropertyValidator, + mOnEventPush, null, null); + doThrow(new RuntimeException("push failed")).when(mOnEventPush).accept(any()); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + } + + @Test + public void successfulTrackPopulatesEventFieldsCorrectly() { + Map props = new HashMap<>(); + props.put("k", "v"); + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.valid(props, 512)); + + long beforeTrack = System.currentTimeMillis(); + mTracker.track("myKey", "myTraffic", "myEventType", 3.14, props, true); + long afterTrack = System.currentTimeMillis(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TrackerEvent.class); + verify(mOnEventPush).accept(captor.capture()); + + TrackerEvent captured = captor.getValue(); + assertNotNull(captured); + assertEquals("myKey", captured.key); + assertEquals("myTraffic", captured.trafficType); + assertEquals("myEventType", captured.eventType); + assertEquals(3.14, captured.value, 0.0001); + assertTrue(captured.timestamp >= beforeTrack && captured.timestamp <= afterTrack); + assertEquals(512, captured.sizeInBytes); + } + + // Helper matcher for verifying TrackerEvent fields + private static T argThat(ArgumentMatcherWithReturn matcher) { + return org.mockito.ArgumentMatchers.argThat(matcher::matches); + } + + @FunctionalInterface + interface ArgumentMatcherWithReturn { + boolean matches(T argument); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java b/tracker/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java similarity index 100% rename from main/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java rename to tracker/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java diff --git a/tracker/src/test/java/io/split/android/client/validators/EventValidatorTest.java b/tracker/src/test/java/io/split/android/client/validators/EventValidatorTest.java new file mode 100644 index 000000000..a1f4d5070 --- /dev/null +++ b/tracker/src/test/java/io/split/android/client/validators/EventValidatorTest.java @@ -0,0 +1,198 @@ +package io.split.android.client.validators; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import io.split.android.client.tracker.TrafficTypeValidator; +import io.split.android.client.tracker.TrackerValidationError; + +public class EventValidatorTest { + + private EventValidatorImpl validator; + + @Before + public void setUp() { + + TrafficTypeValidator trafficTypeValidator = mock(TrafficTypeValidator.class); + + when(trafficTypeValidator.isValid("traffic1")).thenReturn(true); + when(trafficTypeValidator.isValid("trafficType1")).thenReturn(true); + when(trafficTypeValidator.isValid("custom")).thenReturn(true); + + validator = new EventValidatorImpl(new KeyValidatorImpl(), trafficTypeValidator); + } + + @Test + public void testValidEventAllValues() { + TrackerValidationError error = validator.validate("pepe", "traffic1", "type1", 1.0, null, true); + Assert.assertNull(error); + } + + @Test + public void testValidEventNullValue() { + TrackerValidationError error = validator.validate("pepe", "traffic1", "type1", null, null, true); + Assert.assertNull(error); + } + + @Test + public void testNullKey() { + TrackerValidationError error = validator.validate(null, "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed a null key, matching key must be a non-empty string", error.getMessage()); + } + + @Test + public void testEmptyKey() { + TrackerValidationError error = validator.validate("", "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", error.getMessage()); + } + + @Test + public void testAllSpacesInKey() { + TrackerValidationError error = validator.validate(" ", "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", error.getMessage()); + } + + @Test + public void testLongKey() { + TrackerValidationError error = validator.validate(repeat("p", 300), "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("matching key too long - must be " + ValidationConfig.getInstance().getMaximumKeyLength() + " characters or less", error.getMessage()); + } + + @Test + public void testNullType() { + TrackerValidationError error = validator.validate("key1", "traffic1", null, null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed a null or undefined event_type, event_type must be a non-empty String", error.getMessage()); + } + + @Test + public void testEmptyType() { + TrackerValidationError error = validator.validate("key1", "traffic1", "", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", error.getMessage()); + } + + @Test + public void testAllSpacesInType() { + TrackerValidationError error = validator.validate("key1", "traffic1", " ", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", error.getMessage()); + } + + @Test + public void testTypeName() { + EventTypeNameHelper nameHelper = new EventTypeNameHelper(); + + TrackerValidationError error1 = validator.validate("key1", "traffic1", nameHelper.getValidAllValidChars(), null, null, true); + TrackerValidationError error2 = validator.validate("key1", "traffic1", nameHelper.getValidStartNumber(), null, null, true); + TrackerValidationError error3 = validator.validate("key1", "traffic1", nameHelper.getInvalidChars(), null, null, true); + TrackerValidationError error4 = validator.validate("key1", "traffic1", nameHelper.getInvalidUndercoreStart(), null, null, true); + TrackerValidationError error5 = validator.validate("key1", "traffic1", nameHelper.getInvalidHypenStart(), null, null, true); + + Assert.assertNull(error1); + Assert.assertNull(error2); + + Assert.assertNotNull(error3); + Assert.assertTrue(error3.isError()); + Assert.assertEquals(buildEventTypeValidationMessage(nameHelper.getInvalidChars()), error3.getMessage()); + + Assert.assertNotNull(error4); + Assert.assertTrue(error4.isError()); + Assert.assertEquals(buildEventTypeValidationMessage(nameHelper.getInvalidUndercoreStart()), error4.getMessage()); + + Assert.assertNotNull(error5); + Assert.assertTrue(error5.isError()); + Assert.assertEquals(buildEventTypeValidationMessage(nameHelper.getInvalidHypenStart()), error5.getMessage()); + } + + @Test + public void testNullTrafficType() { + TrackerValidationError error = validator.validate("key1", null, "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string", error.getMessage()); + } + + @Test + public void testEmptyTrafficType() { + TrackerValidationError error = validator.validate("key1", "", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", error.getMessage()); + } + + @Test + public void testAllSpacesInTrafficType() { + TrackerValidationError error = validator.validate("key1", " ", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", error.getMessage()); + } + + @Test + public void testUppercaseCharsInTrafficType() { + final String uppercaseMessage = "traffic_type_name should be all lowercase - converting string to lowercase"; + + TrackerValidationError error0 = validator.validate("key1", "custom", "type1", null, null, true); + TrackerValidationError error1 = validator.validate("key1", "Custom", "type1", null, null, true); + TrackerValidationError error2 = validator.validate("key1", "cUSTom", "type1", null, null, true); + TrackerValidationError error3 = validator.validate("key1", "custoM", "type1", null, null, true); + + Assert.assertNull(error0); + + Assert.assertNotNull(error1); + Assert.assertFalse(error1.isError()); + Assert.assertTrue(error1.getWarnings().contains(uppercaseMessage)); + + Assert.assertNotNull(error2); + Assert.assertFalse(error2.isError()); + Assert.assertTrue(error2.getWarnings().contains(uppercaseMessage)); + + Assert.assertNotNull(error3); + Assert.assertFalse(error3.isError()); + Assert.assertTrue(error3.getWarnings().contains(uppercaseMessage)); + } + + @Test + public void noChachedServerTrafficType() { + TrackerValidationError error = validator.validate("key1", "nocached", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertFalse(error.isError()); + Assert.assertEquals(1, error.getWarnings().size()); + String actualWarning = error.getWarnings().get(0); + Assert.assertTrue("Expected warning to contain 'Traffic Type nocached'", + actualWarning.contains("Traffic Type nocached")); + Assert.assertTrue("Expected warning to contain 'does not have any corresponding feature flags'", + actualWarning.contains("does not have any corresponding feature flags")); + } + + private String buildEventTypeValidationMessage(String eventType) { + return "you passed " + eventType + + ", event name must adhere to the regular expression " + ValidationConfig.getInstance().getTrackEventNamePattern() + + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " + + " underscore, period, or colon as separators of alphanumeric characters."; + } + + private String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/KeyValidatorTest.java b/tracker/src/test/java/io/split/android/client/validators/KeyValidatorTest.java similarity index 90% rename from main/src/test/java/io/split/android/client/validators/KeyValidatorTest.java rename to tracker/src/test/java/io/split/android/client/validators/KeyValidatorTest.java index f3e3cdeb7..ec538e048 100644 --- a/main/src/test/java/io/split/android/client/validators/KeyValidatorTest.java +++ b/tracker/src/test/java/io/split/android/client/validators/KeyValidatorTest.java @@ -4,8 +4,6 @@ import org.junit.Before; import org.junit.Test; -import io.split.android.client.utils.Utils; - public class KeyValidatorTest { private KeyValidator validator; @@ -60,7 +58,7 @@ public void testInvalidAllSpacesInMatchingKey() { @Test public void testInvalidLongMatchingKey() { - ValidationErrorInfo errorInfo = validator.validate(Utils.repeat("p", 256), null); + ValidationErrorInfo errorInfo = validator.validate(repeat("p", 256), null); Assert.assertNotNull(errorInfo); Assert.assertTrue(errorInfo.isError()); @@ -87,10 +85,18 @@ public void testInvalidAllSpacesInBucketingKey() { @Test public void testInvalidLongBucketingKey() { - ValidationErrorInfo errorInfo = validator.validate("key1", Utils.repeat("p", 256)); + ValidationErrorInfo errorInfo = validator.validate("key1", repeat("p", 256)); Assert.assertNotNull(errorInfo); Assert.assertTrue(errorInfo.isError()); Assert.assertEquals("bucketing key too long - must be " + ValidationConfig.getInstance().getMaximumKeyLength() + " characters or less", errorInfo.getErrorMessage()); } + + private String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } } diff --git a/main/src/test/java/io/split/android/client/events/PropertyValidatorTest.java b/tracker/src/test/java/io/split/android/client/validators/PropertyValidatorTest.java similarity index 57% rename from main/src/test/java/io/split/android/client/events/PropertyValidatorTest.java rename to tracker/src/test/java/io/split/android/client/validators/PropertyValidatorTest.java index 0841e2d0d..13927be24 100644 --- a/main/src/test/java/io/split/android/client/events/PropertyValidatorTest.java +++ b/tracker/src/test/java/io/split/android/client/validators/PropertyValidatorTest.java @@ -1,4 +1,6 @@ -package io.split.android.client.events; +package io.split.android.client.validators; + +import static org.mockito.Mockito.mock; import org.junit.Assert; import org.junit.Before; @@ -7,15 +9,12 @@ import java.util.HashMap; import java.util.Map; -import io.split.android.client.PropertyValidatorImpl; -import io.split.android.client.dtos.Split; -import io.split.android.client.utils.Utils; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationConfig; +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerPropertyValidator; public class PropertyValidatorTest { - private final PropertyValidator processor = new PropertyValidatorImpl(); + private final TrackerPropertyValidator processor = new PropertyValidatorImpl(mock(TrackerLogger.class)); private final static long MAX_BYTES = ValidationConfig.getInstance().getMaximumEventPropertyBytes(); @Before @@ -28,24 +27,33 @@ public void sizeInBytesValidation() { int maxCount = (int) (MAX_BYTES / 1024); int count = 1; while (count <= maxCount) { - properties.put("key" + count, Utils.repeat("a", 1021)); // 1025 bytes + properties.put("key" + count, repeat("a", 1021)); // 1025 bytes count++; } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertFalse(result.isValid()); } + private String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } + @Test public void invalidPropertyType() { Map properties = new HashMap<>(); for (int i = 0; i < 10; i++) { properties.put("key" + i, "the value"); } + // Add invalid property types (objects that are not Number, Boolean, or String) for (int i = 0; i < 10; i++) { - properties.put("key" + i, new Split()); + properties.put("key" + i, new Object()); } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertTrue(result.isValid()); Assert.assertEquals(10, result.getProperties().size()); @@ -60,7 +68,7 @@ public void nullValues() { for (int i = 10; i < 20; i++) { properties.put("key" + i + 10, null); } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertTrue(result.isValid()); Assert.assertEquals(20, result.getProperties().size()); @@ -72,13 +80,13 @@ public void totalBytes() { for (int i = 0; i < 10; i++) { properties.put("k" + i, "10 bytes"); } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertTrue(result.isValid()); Assert.assertEquals(100, result.getSizeInBytes()); } - private PropertyValidator.Result validate(Map properties) { - return processor.validate(properties, "test"); + private TrackerPropertyValidator.TrackerPropertyResult validate(Map properties) { + return processor.validate(properties, 0, "test"); } }