diff --git a/build.gradle b/build.gradle index 91abd934d..93f80cf53 100644 --- a/build.gradle +++ b/build.gradle @@ -145,7 +145,7 @@ dependencies { return candidates.find { findProject(it) != null } } - ['main', 'logger', 'executor', 'events', 'events-domain', 'api', 'http-api', 'http', 'fallback', 'backoff', 'tracker', 'submitter'].each { moduleName -> + ['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) diff --git a/main/build.gradle b/main/build.gradle index 8029dd92e..6d1432030 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -60,8 +60,10 @@ dependencies { api clientModuleProject('submitter') // Internal module dependencies - implementation clientModuleProject('http') - implementation clientModuleProject('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/main/java/io/split/android/client/SplitFactoryHelper.java b/main/src/main/java/io/split/android/client/SplitFactoryHelper.java index 48fbf8f7b..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; @@ -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; @@ -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 e37792172..b010fa6ee 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -24,7 +24,7 @@ import io.split.android.client.main.BuildConfig; import io.split.android.client.api.Key; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.events.EventsManagerCoordinator; import io.split.android.client.factory.FactoryMonitor; import io.split.android.client.factory.FactoryMonitorImpl; diff --git a/main/src/main/java/io/split/android/client/service/sseclient/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/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/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/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 8fe8012c4..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,8 +22,8 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; -import io.split.android.client.common.CompressionType; -import io.split.android.client.common.CompressionUtilProvider; +import io.split.android.client.streaming.support.CompressionType; +import io.split.android.client.streaming.support.CompressionUtilProvider; import io.split.android.client.service.executor.SplitTaskExecutionInfo; import io.split.android.client.service.executor.SplitTaskExecutor; import io.split.android.client.service.executor.SplitTaskFactory; @@ -38,7 +38,7 @@ import io.split.android.client.service.synchronizer.Synchronizer; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.client.utils.CompressionUtil; +import io.split.android.client.streaming.support.CompressionUtil; import io.split.android.fake.SplitTaskExecutorStub; public class SplitUpdateWorkerTest { diff --git a/main/src/test/java/io/split/android/client/service/sseclient/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/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/settings.gradle b/settings.gradle index 659c104d3..a435328d4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,6 +8,8 @@ include ':fallback' include ':main' include ':events' include ':events-domain' +include ':streaming' +include ':streaming-support' include ':executor' include ':backoff' include ':tracker' diff --git a/sonar-project.properties b/sonar-project.properties index 185535a4c..684e89b56 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,10 +4,10 @@ sonar.projectName=android-client # Path to source directories (multi-module) # 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 +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: api, events-domain, main, events, logger, http-api, http +# Include binary paths for all modules: api, events-domain, main, events, logger, http-api, http, streaming sonar.java.binaries=\ api/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ events-domain/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,\ @@ -16,6 +16,7 @@ sonar.java.binaries=\ 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,\ @@ -51,6 +52,10 @@ sonar.java.libraries=\ 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,\ diff --git a/streaming-support/.gitignore b/streaming-support/.gitignore new file mode 100644 index 000000000..6009265cd --- /dev/null +++ b/streaming-support/.gitignore @@ -0,0 +1,6 @@ +/build +.gradle +*.iml +.DS_Store +.classpath +.settings diff --git a/streaming-support/build.gradle b/streaming-support/build.gradle new file mode 100644 index 000000000..5f80fbe30 --- /dev/null +++ b/streaming-support/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.streaming.support' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + api project(':logger') + implementation libs.gson + + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/streaming-support/consumer-rules.pro b/streaming-support/consumer-rules.pro new file mode 100644 index 000000000..fb164d666 --- /dev/null +++ b/streaming-support/consumer-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/streaming-support/proguard-rules.pro b/streaming-support/proguard-rules.pro new file mode 100644 index 000000000..fb164d666 --- /dev/null +++ b/streaming-support/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/main/src/main/java/io/split/android/client/common/CompressionType.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionType.java similarity index 77% rename from main/src/main/java/io/split/android/client/common/CompressionType.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionType.java index 6d8bcf7f3..45c0976ec 100644 --- a/main/src/main/java/io/split/android/client/common/CompressionType.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionType.java @@ -1,4 +1,4 @@ -package io.split.android.client.common; +package io.split.android.client.streaming.support; import com.google.gson.annotations.SerializedName; diff --git a/main/src/main/java/io/split/android/client/utils/CompressionUtil.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtil.java similarity index 61% rename from main/src/main/java/io/split/android/client/utils/CompressionUtil.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtil.java index e5e67de9b..5476a8a51 100644 --- a/main/src/main/java/io/split/android/client/utils/CompressionUtil.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtil.java @@ -1,4 +1,4 @@ -package io.split.android.client.utils; +package io.split.android.client.streaming.support; public interface CompressionUtil { byte[] decompress(byte[] compressed); diff --git a/main/src/main/java/io/split/android/client/common/CompressionUtilProvider.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtilProvider.java similarity index 81% rename from main/src/main/java/io/split/android/client/common/CompressionUtilProvider.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtilProvider.java index d6b8721c6..07d764595 100644 --- a/main/src/main/java/io/split/android/client/common/CompressionUtilProvider.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/CompressionUtilProvider.java @@ -1,19 +1,13 @@ -package io.split.android.client.common; - -import androidx.annotation.Nullable; +package io.split.android.client.streaming.support; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import io.split.android.client.utils.CompressionUtil; -import io.split.android.client.utils.Gzip; import io.split.android.client.utils.logger.Logger; -import io.split.android.client.utils.Zlib; public class CompressionUtilProvider { Map mCompressionUtils = new ConcurrentHashMap<>(); - @Nullable public CompressionUtil get(CompressionType type) { CompressionUtil util = mCompressionUtils.get(type); return (util != null ? util : create(type)); @@ -21,7 +15,6 @@ public CompressionUtil get(CompressionType type) { // Using a method instead of a factory to avoid // a complex architecture. - @Nullable private CompressionUtil create(CompressionType type) { switch (type) { case NONE: diff --git a/main/src/main/java/io/split/android/client/utils/Gzip.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/Gzip.java similarity index 88% rename from main/src/main/java/io/split/android/client/utils/Gzip.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/Gzip.java index 12881c672..2843d4a5f 100644 --- a/main/src/main/java/io/split/android/client/utils/Gzip.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/Gzip.java @@ -1,4 +1,4 @@ -package io.split.android.client.utils; +package io.split.android.client.streaming.support; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -6,11 +6,12 @@ import java.io.IOException; import java.util.zip.GZIPInputStream; -import io.split.android.client.service.ServiceConstants; import io.split.android.client.utils.logger.Logger; public class Gzip implements CompressionUtil { + private static final int BUFFER_SIZE = 256 * 1024; // 256KB buffer + @Override public byte[] decompress(byte[] input) { if (input == null || input.length == 0) { @@ -21,7 +22,7 @@ public byte[] decompress(byte[] input) { GZIPInputStream gzipIn = null; try { gzipIn = new GZIPInputStream(in); - byte[] buffer = new byte[ServiceConstants.MY_SEGMENT_V2_DATA_SIZE]; + byte[] buffer = new byte[BUFFER_SIZE]; int byteCount; while ((byteCount = gzipIn.read(buffer)) >= 0) { out.write(buffer, 0, byteCount); diff --git a/main/src/main/java/io/split/android/client/utils/Zlib.java b/streaming-support/src/main/java/io/split/android/client/streaming/support/Zlib.java similarity index 82% rename from main/src/main/java/io/split/android/client/utils/Zlib.java rename to streaming-support/src/main/java/io/split/android/client/streaming/support/Zlib.java index efe50e914..03dfe123e 100644 --- a/main/src/main/java/io/split/android/client/utils/Zlib.java +++ b/streaming-support/src/main/java/io/split/android/client/streaming/support/Zlib.java @@ -1,13 +1,14 @@ -package io.split.android.client.utils; +package io.split.android.client.streaming.support; import java.util.Arrays; import java.util.zip.Inflater; -import io.split.android.client.service.ServiceConstants; import io.split.android.client.utils.logger.Logger; public class Zlib implements CompressionUtil { + private static final int BUFFER_SIZE = 256 * 1024; // 256KB buffer + @Override public byte[] decompress(byte[] input) { if (input == null || input.length == 0) { @@ -16,7 +17,7 @@ public byte[] decompress(byte[] input) { try { Inflater inflater = new Inflater(); inflater.setInput(input); - byte[] result = new byte[ServiceConstants.MY_SEGMENT_V2_DATA_SIZE]; + byte[] result = new byte[BUFFER_SIZE]; int resultLength = inflater.inflate(result); inflater.end(); return Arrays.copyOfRange(result, 0, resultLength); diff --git a/streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java b/streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java new file mode 100644 index 000000000..fa3ca33f1 --- /dev/null +++ b/streaming-support/src/test/java/io/split/android/client/streaming/support/GzipTest.java @@ -0,0 +1,92 @@ +package io.split.android.client.streaming.support; + +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.GZIPOutputStream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class GzipTest { + + private Gzip gzip; + + @Before + public void setUp() { + gzip = new Gzip(); + } + + @Test + public void decompress_validGzipData_returnsDecompressedBytes() throws IOException { + // Arrange + byte[] original = "Hello, World! This is a test message for gzip compression.".getBytes(); + byte[] compressed = compressWithGzip(original); + + // Act + byte[] decompressed = gzip.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + @Test + public void decompress_emptyArray_returnsNull() { + // Act + byte[] result = gzip.decompress(new byte[0]); + + // Assert + assertNull(result); + } + + @Test + public void decompress_nullInput_returnsNull() { + // Act + byte[] result = gzip.decompress(null); + + // Assert + assertNull(result); + } + + @Test + public void decompress_invalidGzipData_returnsNull() { + // Arrange + byte[] invalidData = "This is not gzip compressed data".getBytes(); + + // Act + byte[] result = gzip.decompress(invalidData); + + // Assert + assertNull(result); + } + + @Test + public void decompress_largeData_decompressesSuccessfully() throws IOException { + // Arrange + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10000; i++) { + sb.append("Line ").append(i).append(": Some test data\n"); + } + byte[] original = sb.toString().getBytes(); + byte[] compressed = compressWithGzip(original); + + // Act + byte[] decompressed = gzip.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + private byte[] compressWithGzip(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + GZIPOutputStream gzipOut = new GZIPOutputStream(out); + gzipOut.write(data); + gzipOut.close(); + return out.toByteArray(); + } +} diff --git a/streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java b/streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java new file mode 100644 index 000000000..e1f96d162 --- /dev/null +++ b/streaming-support/src/test/java/io/split/android/client/streaming/support/ZlibTest.java @@ -0,0 +1,99 @@ +package io.split.android.client.streaming.support; + +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.util.zip.Deflater; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class ZlibTest { + + private Zlib zlib; + + @Before + public void setUp() { + zlib = new Zlib(); + } + + @Test + public void decompress_validZlibData_returnsDecompressedBytes() { + // Arrange + byte[] original = "Hello, World! This is a test message for zlib compression.".getBytes(); + byte[] compressed = compressWithZlib(original); + + // Act + byte[] decompressed = zlib.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + @Test + public void decompress_emptyArray_returnsNull() { + // Act + byte[] result = zlib.decompress(new byte[0]); + + // Assert + assertNull(result); + } + + @Test + public void decompress_nullInput_returnsNull() { + // Act + byte[] result = zlib.decompress(null); + + // Assert + assertNull(result); + } + + @Test + public void decompress_invalidZlibData_returnsNull() { + // Arrange + byte[] invalidData = "This is not zlib compressed data".getBytes(); + + // Act + byte[] result = zlib.decompress(invalidData); + + // Assert + assertNull(result); + } + + @Test + public void decompress_largeData_decompressesSuccessfully() { + // Arrange + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10000; i++) { + sb.append("Line ").append(i).append(": Some test data\n"); + } + byte[] original = sb.toString().getBytes(); + byte[] compressed = compressWithZlib(original); + + // Act + byte[] decompressed = zlib.decompress(compressed); + + // Assert + assertNotNull(decompressed); + assertArrayEquals(original, decompressed); + } + + private byte[] compressWithZlib(byte[] data) { + Deflater deflater = new Deflater(); + deflater.setInput(data); + deflater.finish(); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length); + byte[] buffer = new byte[1024]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + outputStream.write(buffer, 0, count); + } + deflater.end(); + + return outputStream.toByteArray(); + } +} 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..990fdb1ae --- /dev/null +++ b/streaming/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'com.android.library' +} + +apply from: "$rootDir/gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.streaming' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + compileOnly libs.jetbrainsAnnotations + implementation libs.annotation + + // 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))); + } +}