All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.reown.foundation.network.BaseRelayClient.kt Maven / Gradle / Ivy

There is a newer version: 1.1.0
Show newest version
package com.reown.foundation.network

import com.tinder.scarlet.WebSocket
import com.reown.foundation.common.model.SubscriptionId
import com.reown.foundation.common.model.Topic
import com.reown.foundation.common.model.Ttl
import com.reown.foundation.common.toRelay
import com.reown.foundation.common.toRelayEvent
import com.reown.foundation.di.foundationCommonModule
import com.reown.foundation.network.data.service.RelayService
import com.reown.foundation.network.model.Relay
import com.reown.foundation.network.model.RelayDTO
import com.reown.foundation.util.Logger
import com.reown.foundation.util.scope
import com.reown.util.generateClientToServerId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeout
import org.koin.core.KoinApplication

sealed class ConnectionState {
    data object Open : ConnectionState()
    data class Closed(val throwable: Throwable) : ConnectionState()
    data object Idle : ConnectionState()
}

@OptIn(ExperimentalCoroutinesApi::class)
abstract class BaseRelayClient : RelayInterface {
    private var foundationKoinApp: KoinApplication = KoinApplication.init()
    lateinit var relayService: RelayService
    lateinit var connectionLifecycle: ConnectionLifecycle
    protected var logger: Logger
    private val resultState: MutableSharedFlow = MutableSharedFlow()
    internal var connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Idle)
    private var unAckedTopics: MutableList = mutableListOf()
    private var isConnecting: Boolean = false
    private var retryCount: Int = 0
    override var isLoggingEnabled: Boolean = false

    init {
        foundationKoinApp.run { modules(foundationCommonModule()) }
        logger = foundationKoinApp.koin.get()
    }

    fun observeResults() {
        scope.launch {
            merge(
                relayService.observePublishAcknowledgement(),
                relayService.observePublishError(),
                relayService.observeBatchSubscribeAcknowledgement(),
                relayService.observeBatchSubscribeError(),
                relayService.observeSubscribeAcknowledgement(),
                relayService.observeSubscribeError(),
                relayService.observeUnsubscribeAcknowledgement(),
                relayService.observeUnsubscribeError()
            )
                .catch { exception -> logger.error(exception) }
                .collect { result ->
                    if (isLoggingEnabled) {
                        println("Result: $result; timestamp: ${System.currentTimeMillis()}")
                    }

                    resultState.emit(result)
                }
        }
    }

    override val eventsFlow: SharedFlow by lazy {
        relayService
            .observeWebSocketEvent()
            .onEach { event ->
                if (event is WebSocket.Event.OnConnectionOpened<*>) {
                    connectionState.value = ConnectionState.Open
                } else if (event is WebSocket.Event.OnConnectionClosed || event is WebSocket.Event.OnConnectionFailed) {
                    connectionState.value = ConnectionState.Closed(getError(event))
                }
            }
            .map { event ->
                logger.log("Event: $event")
                event.toRelayEvent()
            }
            .shareIn(scope, SharingStarted.Lazily, REPLAY)
    }

    override val subscriptionRequest: Flow by lazy {
        relayService.observeSubscriptionRequest()
            .map { request -> request.toRelay() }
            .onEach { relayRequest -> supervisorScope { publishSubscriptionAcknowledgement(relayRequest.id) } }
    }

    @ExperimentalCoroutinesApi
    override fun publish(
        topic: String,
        message: String,
        params: Relay.Model.IrnParams,
        id: Long?,
        onResult: (Result) -> Unit,
    ) {
        connectAndCallRelay(
            onConnected = {
                val (tag, ttl, prompt) = params
                val publishParams = RelayDTO.Publish.Request.Params(Topic(topic), message, Ttl(ttl), tag, prompt)
                val publishRequest = RelayDTO.Publish.Request(id = id ?: generateClientToServerId(), params = publishParams)
                observePublishResult(publishRequest.id, onResult)
                relayService.publishRequest(publishRequest)
            },
            onFailure = { onResult(Result.failure(it)) }
        )
    }

    private fun observePublishResult(id: Long, onResult: (Result) -> Unit) {
        scope.launch {
            try {
                withTimeout(RESULT_TIMEOUT) {
                    resultState
                        .filterIsInstance()
                        .filter { relayResult -> relayResult.id == id }
                        .first { publishResult ->
                            when (publishResult) {
                                is RelayDTO.Publish.Result.Acknowledgement -> onResult(Result.success(publishResult.toRelay()))
                                is RelayDTO.Publish.Result.JsonRpcError -> onResult(Result.failure(Throwable(publishResult.error.errorMessage)))
                            }
                            true
                        }
                }
            } catch (e: TimeoutCancellationException) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            } catch (e: Exception) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            }
        }
    }

    @ExperimentalCoroutinesApi
    override fun subscribe(topic: String, id: Long?, onResult: (Result) -> Unit) {
        connectAndCallRelay(
            onConnected = {
                val subscribeRequest = RelayDTO.Subscribe.Request(id = id ?: generateClientToServerId(), params = RelayDTO.Subscribe.Request.Params(Topic(topic)))
                if (isLoggingEnabled) {
                    logger.log("Sending SubscribeRequest: $subscribeRequest;  timestamp: ${System.currentTimeMillis()}")
                }
                observeSubscribeResult(subscribeRequest.id, onResult)
                relayService.subscribeRequest(subscribeRequest)
            },
            onFailure = { onResult(Result.failure(it)) }
        )
    }

    private fun observeSubscribeResult(id: Long, onResult: (Result) -> Unit) {
        scope.launch {
            try {
                withTimeout(RESULT_TIMEOUT) {
                    if (isLoggingEnabled) println("ObserveSubscribeResult: $id; timestamp: ${System.currentTimeMillis()}")
                    resultState
                        .onEach { relayResult -> if (isLoggingEnabled) logger.log("SubscribeResult 1: $relayResult") }
                        .filterIsInstance()
                        .onEach { relayResult -> if (isLoggingEnabled) logger.log("SubscribeResult 2: $relayResult") }
                        .filter { relayResult -> relayResult.id == id }
                        .first { subscribeResult ->
                            if (isLoggingEnabled) println("SubscribeResult 3: $subscribeResult")
                            when (subscribeResult) {
                                is RelayDTO.Subscribe.Result.Acknowledgement -> onResult(Result.success(subscribeResult.toRelay()))
                                is RelayDTO.Subscribe.Result.JsonRpcError -> onResult(Result.failure(Throwable(subscribeResult.error.errorMessage)))
                            }
                            true
                        }
                }
            } catch (e: TimeoutCancellationException) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            } catch (e: Exception) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            }
        }
    }

    @ExperimentalCoroutinesApi
    override fun batchSubscribe(topics: List, id: Long?, onResult: (Result) -> Unit) {
        connectAndCallRelay(
            onConnected = {
                if (!unAckedTopics.containsAll(topics)) {
                    unAckedTopics.addAll(topics)
                    val batchSubscribeRequest = RelayDTO.BatchSubscribe.Request(id = id ?: generateClientToServerId(), params = RelayDTO.BatchSubscribe.Request.Params(topics))
                    observeBatchSubscribeResult(batchSubscribeRequest.id, topics, onResult)
                    relayService.batchSubscribeRequest(batchSubscribeRequest)
                }
            },
            onFailure = { onResult(Result.failure(it)) }
        )
    }

    private fun observeBatchSubscribeResult(id: Long, topics: List, onResult: (Result) -> Unit) {
        scope.launch {
            try {
                withTimeout(RESULT_TIMEOUT) {
                    resultState
                        .filterIsInstance()
                        .onEach { if (unAckedTopics.isNotEmpty()) unAckedTopics.removeAll(topics) }
                        .filter { relayResult -> relayResult.id == id }
                        .first { batchSubscribeResult ->
                            when (batchSubscribeResult) {
                                is RelayDTO.BatchSubscribe.Result.Acknowledgement -> onResult(Result.success(batchSubscribeResult.toRelay()))
                                is RelayDTO.BatchSubscribe.Result.JsonRpcError -> onResult(Result.failure(Throwable(batchSubscribeResult.error.errorMessage)))
                            }
                            true
                        }
                }
            } catch (e: TimeoutCancellationException) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            } catch (e: Exception) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            }
        }
    }

    @ExperimentalCoroutinesApi
    override fun unsubscribe(
        topic: String,
        subscriptionId: String,
        id: Long?,
        onResult: (Result) -> Unit,
    ) {
        connectAndCallRelay(
            onConnected = {
                val unsubscribeRequest = RelayDTO.Unsubscribe.Request(
                    id = id ?: generateClientToServerId(),
                    params = RelayDTO.Unsubscribe.Request.Params(Topic(topic), SubscriptionId(subscriptionId))
                )

                observeUnsubscribeResult(unsubscribeRequest.id, onResult)
                relayService.unsubscribeRequest(unsubscribeRequest)
            },
            onFailure = { onResult(Result.failure(it)) }
        )
    }

    private fun observeUnsubscribeResult(id: Long, onResult: (Result) -> Unit) {
        scope.launch {
            try {
                withTimeout(RESULT_TIMEOUT) {
                    resultState
                        .filterIsInstance()
                        .filter { relayResult -> relayResult.id == id }
                        .first { unsubscribeResult ->
                            when (unsubscribeResult) {
                                is RelayDTO.Unsubscribe.Result.Acknowledgement -> onResult(Result.success(unsubscribeResult.toRelay()))
                                is RelayDTO.Unsubscribe.Result.JsonRpcError -> onResult(Result.failure(Throwable(unsubscribeResult.error.errorMessage)))
                            }
                            true
                        }
                }
            } catch (e: TimeoutCancellationException) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            } catch (e: Exception) {
                onResult(Result.failure(e))
                cancelJobIfActive()
            }
        }
    }

    private fun connectAndCallRelay(onConnected: () -> Unit, onFailure: (Throwable) -> Unit) {
        when {
            shouldConnect() -> connect(onConnected, onFailure)
            isConnecting -> awaitConnection(onConnected, onFailure)
            connectionState.value == ConnectionState.Open -> onConnected()
        }
    }

    private fun shouldConnect() = !isConnecting && (connectionState.value is ConnectionState.Closed || connectionState.value is ConnectionState.Idle)
    private fun connect(onConnected: () -> Unit, onFailure: (Throwable) -> Unit) {
        isConnecting = true
        connectionLifecycle.reconnect()
        awaitConnectionWithRetry(
            onConnected = {
                reset()
                onConnected()
            },
            onFailure = { error ->
                reset()
                onFailure(error)
            }
        )
    }

    private fun awaitConnectionWithRetry(onConnected: () -> Unit, onFailure: (Throwable) -> Unit = {}) {
        scope.launch {
            try {
                withTimeout(CONNECTION_TIMEOUT) {
                    connectionState
                        .filter { state -> state != ConnectionState.Idle }
                        .take(4)
                        .onEach { state -> handleRetries(state, onFailure) }
                        .filter { state -> state == ConnectionState.Open }
                        .firstOrNull {
                            onConnected()
                            true
                        }
                }
            } catch (e: TimeoutCancellationException) {
                onFailure(e)
                cancelJobIfActive()
            } catch (e: Exception) {
                if (e !is CancellationException) {
                    onFailure(e)
                }
            }
        }
    }

    private fun awaitConnection(onConnected: () -> Unit, onFailure: (Throwable) -> Unit) {
        scope.launch {
            try {
                withTimeout(CONNECTION_TIMEOUT) {
                    connectionState
                        .filter { state -> state is ConnectionState.Open }
                        .firstOrNull {
                            onConnected()
                            true
                        }
                }
            } catch (e: TimeoutCancellationException) {
                onFailure(e)
                cancelJobIfActive()
            } catch (e: Exception) {
                if (e !is CancellationException) {
                    onFailure(e)
                }
                cancelJobIfActive()
            }
        }
    }

    private fun CoroutineScope.handleRetries(state: ConnectionState, onFailure: (Throwable) -> Unit) {
        if (state is ConnectionState.Closed) {
            if (retryCount == MAX_RETRIES) {
                onFailure(Throwable("Connectivity error, please check your Internet connection and try again"))
                cancelJobIfActive()
            } else {
                connectionLifecycle.reconnect()
                retryCount++
            }
        }
    }

    private fun getError(event: WebSocket.Event): Throwable = when (event) {
        is WebSocket.Event.OnConnectionClosed -> Throwable(event.shutdownReason.reason)
        is WebSocket.Event.OnConnectionFailed -> event.throwable
        else -> Throwable("Unknown")
    }

    private fun publishSubscriptionAcknowledgement(id: Long) {
        val publishRequest = RelayDTO.Subscription.Result.Acknowledgement(id = id, result = true)
        relayService.publishSubscriptionAcknowledgement(publishRequest)
    }

    private fun CoroutineScope.cancelJobIfActive() {
        if (this.coroutineContext.job.isActive) {
            this.coroutineContext.job.cancel()
        }
    }

    private fun reset() {
        isConnecting = false
        retryCount = 0
    }

    private companion object {
        const val REPLAY: Int = 1
        const val RESULT_TIMEOUT: Long = 60000
        const val CONNECTION_TIMEOUT: Long = 15000
        const val MAX_RETRIES: Int = 3
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy