Skip to content

Commit

Permalink
[PBE-4237] Handle WebSocket connection failure (#1119)
Browse files Browse the repository at this point in the history
* If the banned field is missing, set it to false and don't crash

* Add inline docs for connect invocation param

* Handle connect continuation exception

* Improve KDocs for StreamVideoBuilder and build().

* Small code & comment improvements

* Emit ConnectException, collect in ClientState and set connection as Failed

* Remove test code

* Refactor error handling in PersistentSocket

* Add @see to build method

* Change `two clients is not allowed` test expected exception type

---------

Co-authored-by: Aleksandar Apostolov <[email protected]>
  • Loading branch information
liviu-timar and aleksandar-apostolov authored Jun 25, 2024
1 parent a801a4a commit 3323c2a
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 81 deletions.
8 changes: 4 additions & 4 deletions stream-video-android-core/api/stream-video-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ public final class io/getstream/video/android/core/ConnectionState$Disconnected

public final class io/getstream/video/android/core/ConnectionState$Failed : io/getstream/video/android/core/ConnectionState {
public fun <init> (Ljava/lang/Error;)V
public final fun getError ()Ljava/lang/Error;
}

public final class io/getstream/video/android/core/ConnectionState$Loading : io/getstream/video/android/core/ConnectionState {
Expand Down Expand Up @@ -824,7 +825,6 @@ public final class io/getstream/video/android/core/StreamVideoBuilder {
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;I)V
public synthetic fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun build ()Lio/getstream/video/android/core/StreamVideo;
public final fun getScope ()Lkotlinx/coroutines/CoroutineScope;
}

public abstract interface class io/getstream/video/android/core/StreamVideoConfig {
Expand Down Expand Up @@ -4295,7 +4295,7 @@ public final class io/getstream/video/android/core/socket/ErrorResponse$Companio
}

public class io/getstream/video/android/core/socket/PersistentSocket : okhttp3/WebSocketListener {
public field connected Lkotlinx/coroutines/CancellableContinuation;
public field connectContinuation Lkotlinx/coroutines/CancellableContinuation;
public fun <init> (Ljava/lang/String;Lokhttp3/OkHttpClient;Lio/getstream/video/android/core/internal/network/NetworkStateProvider;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Ljava/lang/String;Lokhttp3/OkHttpClient;Lio/getstream/video/android/core/internal/network/NetworkStateProvider;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
protected final fun ackHealthMonitor ()V
Expand All @@ -4304,7 +4304,7 @@ public class io/getstream/video/android/core/socket/PersistentSocket : okhttp3/W
public fun connect (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun connect$default (Lio/getstream/video/android/core/socket/PersistentSocket;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun disconnect (Lio/getstream/video/android/core/socket/PersistentSocket$DisconnectReason;)V
public final fun getConnected ()Lkotlinx/coroutines/CancellableContinuation;
public final fun getConnectContinuation ()Lkotlinx/coroutines/CancellableContinuation;
public final fun getConnectionId ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getConnectionState ()Lkotlinx/coroutines/flow/StateFlow;
protected final fun getDestroyed ()Z
Expand All @@ -4319,7 +4319,7 @@ public class io/getstream/video/android/core/socket/PersistentSocket : okhttp3/W
public fun onOpen (Lokhttp3/WebSocket;Lokhttp3/Response;)V
public final fun reconnect (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun reconnect$default (Lio/getstream/video/android/core/socket/PersistentSocket;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun setConnected (Lkotlinx/coroutines/CancellableContinuation;)V
public final fun setConnectContinuation (Lkotlinx/coroutines/CancellableContinuation;)V
protected final fun setConnectedStateAndContinue (Lorg/openapitools/client/models/VideoEvent;)V
protected final fun setDestroyed (Z)V
public final fun setReconnectTimeout (J)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import org.openapitools.client.models.CallCreatedEvent
import org.openapitools.client.models.CallRingEvent
import org.openapitools.client.models.ConnectedEvent
import org.openapitools.client.models.VideoEvent
import java.net.ConnectException

@Stable
public sealed interface ConnectionState {
Expand All @@ -36,7 +37,7 @@ public sealed interface ConnectionState {
public data object Connected : ConnectionState
public data object Reconnecting : ConnectionState
public data object Disconnected : ConnectionState
public class Failed(error: Error) : ConnectionState
public class Failed(val error: Error) : ConnectionState
}

@Stable
Expand All @@ -57,11 +58,12 @@ class ClientState(client: StreamVideo) {
private val _user: MutableStateFlow<User?> = MutableStateFlow(client.user)
public val user: StateFlow<User?> = _user

/**
* connectionState shows if we've established a connection with the coordinator
*/
private val _connection: MutableStateFlow<ConnectionState> =
MutableStateFlow(ConnectionState.PreConnect)

/**
* Shows the Coordinator connection state
*/
public val connection: StateFlow<ConnectionState> = _connection

/**
Expand Down Expand Up @@ -102,6 +104,12 @@ class ClientState(client: StreamVideo) {
}
}

internal fun handleError(error: Throwable) {
if (error is ConnectException) {
_connection.value = ConnectionState.Failed(error = Error(error))
}
}

fun setActiveCall(call: Call) {
removeRingingCall()
maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import io.getstream.video.android.model.UserToken
import io.getstream.video.android.model.UserType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.lang.RuntimeException
import java.net.ConnectException

/**
* The [StreamVideoBuilder] is used to create a new instance of the [StreamVideo] client. This is the
Expand All @@ -50,28 +52,33 @@ import kotlinx.coroutines.launch
* geo = GEO.GlobalEdgeNetwork,
* user = user,
* token = token,
* loggingLevel = LoggingLevel.BODY
* )
* loggingLevel = LoggingLevel.BODY,
* // ...
* ).build()
*```
*
* @property context Android [Context] to be used for initializing Android resources.
* @property apiKey Your Stream API Key, you can find it in the dashboard.
* @property geo Your GEO routing policy, supports geofencing for privacy concerns.
* @property user The user object, can be a regular user, guest user or anonymous.
* @property token The token for this user generated using your API secret on your server.
* @property tokenProvider If a token is expired, the token provider makes a request to your backend for a new token.
* @property apiKey Your Stream API Key. You can find it in the dashboard.
* @property geo Your GEO routing policy. Supports geofencing for privacy concerns.
* @property user The user object. Can be an authenticated user, guest user or anonymous.
* @property token The token for this user, generated using your API secret on your server.
* @property tokenProvider Used to make a request to your backend for a new token when the token has expired.
* @property loggingLevel Represents and wraps the HTTP logging level for our API service.
* @property notificationConfig The configurations for handling push notification.
* @property ringNotification Overwrite the default notification logic for incoming calls.
* @property connectionTimeoutInMs Connection timeout in seconds.
* @property ensureSingleInstance Verify that only 1 version of the video client exists, prevents integration mistakes.
* @property ensureSingleInstance Verify that only 1 version of the video client exists. Prevents integration mistakes.
* @property videoDomain URL overwrite to allow for testing against a local instance of video.
* @property runForegroundServiceForCalls If set to true, when there is an active call the SDK will run a foreground service to keep the process alive. (default: true)
* @property localSfuAddress Local SFU address (IP:port) to be used for testing. Leave null if not needed.
* @property sounds Overwrite the default SDK sounds. See [Sounds].
* @property crashOnMissingPermission if [permissionCheck] returns false there will be an exception.
* @property permissionCheck used to check for system permission based on call capabilities. See [StreamPermissionCheck].
* @property audioUsage used to signal to the system how to treat the audio tracks (voip or media).
* @property crashOnMissingPermission If [permissionCheck] returns false there will be an exception.
* @property permissionCheck Used to check for system permission based on call capabilities. See [StreamPermissionCheck].
* @property audioUsage Used to signal to the system how to treat the audio tracks (voip or media).
*
* @see build
* @see ClientState.connection
*
*/
public class StreamVideoBuilder @JvmOverloads constructor(
context: Context,
Expand All @@ -94,21 +101,31 @@ public class StreamVideoBuilder @JvmOverloads constructor(
private val audioUsage: Int = defaultAudioUsage,
) {
private val context: Context = context.applicationContext

val scope = CoroutineScope(DispatcherProvider.IO)

private val scope = CoroutineScope(DispatcherProvider.IO)

/**
* Builds the [StreamVideo] client.
*
* @return The [StreamVideo] client.
*
* @throws RuntimeException If an instance of the client already exists and [ensureSingleInstance] is set to true.
* @throws IllegalArgumentException If [apiKey] is blank.
* @throws IllegalArgumentException If [user] type is [UserType.Authenticated] and the [user] id is blank.
* @throws IllegalArgumentException If [user] type is [UserType.Authenticated] and both [token] and [tokenProvider] are empty.
* @throws ConnectException If the WebSocket connection fails.
*/
public fun build(): StreamVideo {
val lifecycle = ProcessLifecycleOwner.get().lifecycle

val existingInstance = StreamVideo.instanceOrNull()
if (existingInstance != null && ensureSingleInstance) {
throw IllegalArgumentException(
throw RuntimeException(
"Creating 2 instance of the video client will cause bugs with call.state. Before creating a new client, please remove the old one. You can remove the old client using StreamVideo.removeClient()",
)
}

if (apiKey.isBlank()) {
throw IllegalArgumentException("The API key can not be empty")
throw IllegalArgumentException("The API key cannot be blank")
}

if (token.isBlank() && tokenProvider == null && user.type == UserType.Authenticated) {
Expand All @@ -117,21 +134,21 @@ public class StreamVideoBuilder @JvmOverloads constructor(
)
}

if (user.type == UserType.Authenticated && user.id.isEmpty()) {
if (user.type == UserType.Authenticated && user.id.isBlank()) {
throw IllegalArgumentException(
"Please specify the user id for authenticated users",
"The user ID cannot be empty for authenticated users",
)
}

if (user.role.isNullOrBlank()) {
user = user.copy(role = "user")
}

/** initialize Stream internal loggers. */
// Initialize Stream internal loggers
StreamLog.install(AndroidStreamLogger())
StreamLog.setValidator { priority, _ -> priority.level >= loggingLevel.priority.level }

/** android JSR-310 backport backport. */
// Android JSR-310 backport backport
AndroidThreeTen.init(context)

// This connection module class exposes the connections to the various retrofit APIs.
Expand All @@ -148,7 +165,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(

val deviceTokenStorage = DeviceTokenStorage(context)

// install the StreamNotificationManager to configure push notifications.
// Install the StreamNotificationManager to configure push notifications.
val streamNotificationManager = StreamNotificationManager.install(
context = context,
scope = scope,
Expand All @@ -157,7 +174,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
deviceTokenStorage = deviceTokenStorage,
)

// create the client
// Create the client
val client = StreamVideoImpl(
context = context,
_scope = scope,
Expand All @@ -183,25 +200,30 @@ public class StreamVideoBuilder @JvmOverloads constructor(
connectionModule.updateAuthType("anonymous")
}

// establish a ws connection with the coordinator (we don't support this for anonymous users)
// Establish a WS connection with the coordinator (we don't support this for anonymous users)
if (user.type != UserType.Anonymous) {
scope.launch {
val result = client.connectAsync().await()
result.onSuccess {
streamLog { "connection succeed! (duration: ${result.getOrNull()})" }
}.onError {
streamLog { it.message }
try {
val result = client.connectAsync().await()
result.onSuccess {
streamLog { "Connection succeeded! (duration: ${result.getOrNull()})" }
}.onError {
streamLog { it.message }
}
} catch (e: Exception) {
// If the connect continuation was resumed with an exception, we catch it here.
streamLog { e.message.orEmpty() }
}
}
}

// see which location is best to connect to
// See which location is best to connect to
scope.launch {
val location = client.loadLocationAsync().await()
streamLog { "location initialized: ${location.getOrNull()}" }
}

// installs Stream Video instance
// Installs Stream Video instance
StreamVideo.install(client)

// Needs to be started after the client is initialised because the VideoPushDelegate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import org.openapitools.client.models.UserRequest
import org.openapitools.client.models.VideoEvent
import org.openapitools.client.models.WSCallEvent
import retrofit2.HttpException
import java.net.ConnectException
import java.util.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.resumeWithException
Expand Down Expand Up @@ -356,8 +357,12 @@ internal class StreamVideoImpl internal constructor(
}
scope.launch {
connectionModule.coordinatorSocket.errors.collect { throwable ->
(throwable as? ErrorResponse)?.let {
if (it.code == VideoErrorCode.TOKEN_EXPIRED.code) refreshToken(it)
if (throwable is ConnectException) {
state.handleError(throwable)
} else {
(throwable as? ErrorResponse)?.let {
if (it.code == VideoErrorCode.TOKEN_EXPIRED.code) refreshToken(it)
}
}
}
}
Expand Down
Loading

0 comments on commit 3323c2a

Please sign in to comment.