diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/ConditionalExponentialBackoffStrategy.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/ConditionalExponentialBackoffStrategy.kt new file mode 100644 index 000000000..2353efb61 --- /dev/null +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/ConditionalExponentialBackoffStrategy.kt @@ -0,0 +1,23 @@ +package com.walletconnect.android.internal.common + +import com.tinder.scarlet.retry.BackoffStrategy +import kotlin.math.pow + +class ConditionalExponentialBackoffStrategy( + private val initialDurationMillis: Long, + private val maxDurationMillis: Long +) : BackoffStrategy { + init { + require(initialDurationMillis > 0) { "initialDurationMillis, $initialDurationMillis, must be positive" } + require(maxDurationMillis > 0) { "maxDurationMillis, $maxDurationMillis, must be positive" } + } + + override var shouldBackoff: Boolean = false + + fun shouldBackoff(shouldBackoff: Boolean) { + this.shouldBackoff = shouldBackoff + } + + override fun backoffDurationMillisAt(retryCount: Int): Long = + maxDurationMillis.toDouble().coerceAtMost(initialDurationMillis.toDouble() * 2.0.pow(retryCount.toDouble())).toLong() +} \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/DefaultConnectionLifecycle.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/DefaultConnectionLifecycle.kt new file mode 100644 index 000000000..d8b4c86b6 --- /dev/null +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/DefaultConnectionLifecycle.kt @@ -0,0 +1,83 @@ +@file:JvmSynthetic + +package com.walletconnect.android.internal.common.connection + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import com.tinder.scarlet.Lifecycle +import com.tinder.scarlet.ShutdownReason +import com.tinder.scarlet.lifecycle.LifecycleRegistry +import com.walletconnect.foundation.network.ConnectionLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +internal class DefaultConnectionLifecycle( + application: Application, + private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry() +) : Lifecycle by lifecycleRegistry, ConnectionLifecycle { + private val job = SupervisorJob() + private var scope = CoroutineScope(job + Dispatchers.Default) + + private val _onResume = MutableStateFlow(null) + override val onResume: StateFlow = _onResume.asStateFlow() + + init { + application.registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks()) + } + + override fun reconnect() { + lifecycleRegistry.onNext(Lifecycle.State.Stopped.WithReason()) + lifecycleRegistry.onNext(Lifecycle.State.Started) + } + + private inner class ActivityLifecycleCallbacks : Application.ActivityLifecycleCallbacks { + var isResumed: Boolean = false + var job: Job? = null + + override fun onActivityPaused(activity: Activity) { + isResumed = false + + job = scope.launch { + delay(TimeUnit.SECONDS.toMillis(30)) + if (!isResumed) { + lifecycleRegistry.onNext(Lifecycle.State.Stopped.WithReason(ShutdownReason(1000, "App is paused"))) + job = null + _onResume.value = false + } + } + } + + override fun onActivityResumed(activity: Activity) { + isResumed = true + + if (job?.isActive == true) { + job?.cancel() + job = null + } + + + scope.launch { + _onResume.value = true + } + } + + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityDestroyed(activity: Activity) {} + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + } +} \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/ManualConnectionLifecycle.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/ManualConnectionLifecycle.kt index 395a04df7..d331c965b 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/ManualConnectionLifecycle.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/connection/ManualConnectionLifecycle.kt @@ -4,10 +4,13 @@ package com.walletconnect.android.internal.common.connection import com.tinder.scarlet.Lifecycle import com.tinder.scarlet.lifecycle.LifecycleRegistry +import com.walletconnect.foundation.network.ConnectionLifecycle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow internal class ManualConnectionLifecycle( private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(), -) : Lifecycle by lifecycleRegistry { +) : Lifecycle by lifecycleRegistry, ConnectionLifecycle { fun connect() { lifecycleRegistry.onNext(Lifecycle.State.Started) } @@ -16,7 +19,10 @@ internal class ManualConnectionLifecycle( lifecycleRegistry.onNext(Lifecycle.State.Stopped.WithReason()) } - fun restart() { + override val onResume: StateFlow + get() = MutableStateFlow(null) + + override fun reconnect() { lifecycleRegistry.onNext(Lifecycle.State.Stopped.WithReason()) lifecycleRegistry.onNext(Lifecycle.State.Started) } diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/AndroidCommonDITags.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/AndroidCommonDITags.kt index 2fcb45460..fdf61faf6 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/AndroidCommonDITags.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/AndroidCommonDITags.kt @@ -10,7 +10,7 @@ enum class AndroidCommonDITags { SCARLET, MSG_ADAPTER, MANUAL_CONNECTION_LIFECYCLE, - AUTOMATIC_CONNECTION_LIFECYCLE, + DEFAULT_CONNECTION_LIFECYCLE, LOGGER, CONNECTIVITY_STATE, PUSH_RETROFIT, diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreJsonRpcModule.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreJsonRpcModule.kt index 15ad468fe..688922b84 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreJsonRpcModule.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreJsonRpcModule.kt @@ -17,6 +17,7 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import kotlin.reflect.KClass + @JvmSynthetic fun coreJsonRpcModule() = module { @@ -27,6 +28,7 @@ fun coreJsonRpcModule() = module { jsonRpcHistory = get(), pushMessageStorage = get(), logger = get(named(AndroidCommonDITags.LOGGER)), + backoffStrategy = get() ) } diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreNetworkModule.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreNetworkModule.kt index ba56b0fda..b72ff5fc5 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreNetworkModule.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CoreNetworkModule.kt @@ -6,12 +6,12 @@ import com.pandulapeter.beagle.logOkHttp.BeagleOkHttpLogger import com.squareup.moshi.Moshi import com.tinder.scarlet.Lifecycle import com.tinder.scarlet.Scarlet -import com.tinder.scarlet.lifecycle.android.AndroidLifecycle import com.tinder.scarlet.messageadapter.moshi.MoshiMessageAdapter -import com.tinder.scarlet.retry.ExponentialBackoffStrategy import com.tinder.scarlet.websocket.okhttp.newWebSocketFactory import com.walletconnect.android.BuildConfig +import com.walletconnect.android.internal.common.ConditionalExponentialBackoffStrategy import com.walletconnect.android.internal.common.connection.ConnectivityState +import com.walletconnect.android.internal.common.connection.DefaultConnectionLifecycle import com.walletconnect.android.internal.common.connection.ManualConnectionLifecycle import com.walletconnect.android.internal.common.jwt.clientid.GenerateJwtStoreClientIdUseCase import com.walletconnect.android.relay.ConnectionType @@ -109,17 +109,17 @@ fun coreAndroidNetworkModule(serverUrl: String, connectionType: ConnectionType, ManualConnectionLifecycle() } - single(named(AndroidCommonDITags.AUTOMATIC_CONNECTION_LIFECYCLE)) { - AndroidLifecycle.ofApplicationForeground(androidApplication()) + single(named(AndroidCommonDITags.DEFAULT_CONNECTION_LIFECYCLE)) { + DefaultConnectionLifecycle(androidApplication()) } - single { ExponentialBackoffStrategy(INIT_BACKOFF_MILLIS, TimeUnit.SECONDS.toMillis(MAX_BACKOFF_SEC)) } + single { ConditionalExponentialBackoffStrategy(INIT_BACKOFF_MILLIS, TimeUnit.SECONDS.toMillis(MAX_BACKOFF_SEC)) } single { FlowStreamAdapter.Factory() } single(named(AndroidCommonDITags.SCARLET)) { Scarlet.Builder() - .backoffStrategy(get()) + .backoffStrategy((get())) .webSocketFactory(get(named(AndroidCommonDITags.OK_HTTP)).newWebSocketFactory(get(named(AndroidCommonDITags.RELAY_URL)))) .lifecycle(getLifecycle(connectionType)) .addMessageAdapterFactory(get(named(AndroidCommonDITags.MSG_ADAPTER))) @@ -136,9 +136,9 @@ fun coreAndroidNetworkModule(serverUrl: String, connectionType: ConnectionType, } } -private fun Scope.getLifecycle(connectionType: ConnectionType) = +private fun Scope.getLifecycle(connectionType: ConnectionType): Lifecycle = if (connectionType == ConnectionType.MANUAL) { get(named(AndroidCommonDITags.MANUAL_CONNECTION_LIFECYCLE)) } else { - get(named(AndroidCommonDITags.AUTOMATIC_CONNECTION_LIFECYCLE)) + get(named(AndroidCommonDITags.DEFAULT_CONNECTION_LIFECYCLE)) } \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CorePairingModule.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CorePairingModule.kt index 0c3fad1d6..70a73b8d8 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CorePairingModule.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/di/CorePairingModule.kt @@ -15,8 +15,11 @@ fun corePairingModule(pairing: PairingInterface, pairingController: PairingContr pairingRepository = get(), jsonRpcInteractor = get(), logger = get(named(AndroidCommonDITags.LOGGER)), + insertTelemetryEventUseCase = get(), insertEventUseCase = get(), sendBatchEventUseCase = get(), + clientId = get(named(AndroidCommonDITags.CLIENT_ID)), + userAgent = get(named(AndroidCommonDITags.USER_AGENT)) ) } single { pairing } diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/json_rpc/domain/relay/RelayJsonRpcInteractor.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/json_rpc/domain/relay/RelayJsonRpcInteractor.kt index 8210cdeac..eb0da508c 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/json_rpc/domain/relay/RelayJsonRpcInteractor.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/json_rpc/domain/relay/RelayJsonRpcInteractor.kt @@ -1,12 +1,11 @@ package com.walletconnect.android.internal.common.json_rpc.domain.relay +import com.walletconnect.android.internal.common.ConditionalExponentialBackoffStrategy import com.walletconnect.android.internal.common.JsonRpcResponse import com.walletconnect.android.internal.common.crypto.codec.Codec import com.walletconnect.android.internal.common.crypto.sha256 import com.walletconnect.android.internal.common.exception.NoConnectivityException import com.walletconnect.android.internal.common.exception.NoInternetConnectionException -import com.walletconnect.android.internal.common.exception.NoRelayConnectionException -import com.walletconnect.android.internal.common.exception.Uncategorized import com.walletconnect.android.internal.common.json_rpc.data.JsonRpcSerializer import com.walletconnect.android.internal.common.json_rpc.model.toRelay import com.walletconnect.android.internal.common.json_rpc.model.toWCRequest @@ -28,6 +27,7 @@ import com.walletconnect.android.internal.common.scope import com.walletconnect.android.internal.common.storage.push_messages.PushMessagesRepository import com.walletconnect.android.internal.common.storage.rpc.JsonRpcHistory import com.walletconnect.android.internal.common.wcKoinApp +import com.walletconnect.android.internal.utils.ObservableMap import com.walletconnect.android.relay.RelayConnectionInterface import com.walletconnect.android.relay.WSSConnectionState import com.walletconnect.foundation.common.model.SubscriptionId @@ -35,6 +35,7 @@ import com.walletconnect.foundation.common.model.Topic import com.walletconnect.foundation.network.model.Relay import com.walletconnect.foundation.util.Logger import com.walletconnect.utils.Empty +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -61,6 +62,7 @@ internal class RelayJsonRpcInteractor( private val jsonRpcHistory: JsonRpcHistory, private val pushMessageStorage: PushMessagesRepository, private val logger: Logger, + private val backoffStrategy: ConditionalExponentialBackoffStrategy ) : RelayJsonRpcInteractorInterface { private val serializer: JsonRpcSerializer get() = wcKoinApp.koin.get() @@ -74,28 +76,17 @@ internal class RelayJsonRpcInteractor( override val internalErrors: SharedFlow = _internalErrors.asSharedFlow() override val wssConnectionState: StateFlow get() = relay.wssConnectionState - private val subscriptions: MutableMap = mutableMapOf() + private var subscriptions = ObservableMap { newMap -> if (newMap.isEmpty()) backoffStrategy.shouldBackoff(false) } + override val onResubscribe: Flow get() = relay.onResubscribe init { manageSubscriptions() } - override fun checkConnectionWorking() { + override fun checkNetworkConnectivity() { if (relay.isNetworkAvailable.value != null && relay.isNetworkAvailable.value == false) { throw NoInternetConnectionException("Connection error: Please check your Internet connection") } - - if (relay.wssConnectionState.value is WSSConnectionState.Disconnected) { - val message = when (relay.wssConnectionState.value) { - is WSSConnectionState.Disconnected.ConnectionClosed -> - (relay.wssConnectionState.value as WSSConnectionState.Disconnected.ConnectionClosed).message ?: "WSS connection closed" - - is WSSConnectionState.Disconnected.ConnectionFailed -> (relay.wssConnectionState.value as WSSConnectionState.Disconnected.ConnectionFailed).throwable.message - - else -> "WSS connection closed" - } - throw NoRelayConnectionException("Connection error: No Relay connection: $message") - } } override fun publishJsonRpcRequest( @@ -108,18 +99,13 @@ internal class RelayJsonRpcInteractor( onFailure: (Throwable) -> Unit, ) { try { - checkConnectionWorking() + checkNetworkConnectivity() } catch (e: NoConnectivityException) { return onFailure(e) } - val requestJson = try { - serializer.serialize(payload) ?: return onFailure(IllegalStateException("JsonRpcInteractor: Unknown result params")) - } catch (e: Exception) { - return onFailure(e) - } - try { + val requestJson = serializer.serialize(payload) ?: throw IllegalStateException("RelayJsonRpcInteractor: Unknown Request Params") if (jsonRpcHistory.setRequest(payload.id, topic, payload.method, requestJson, TransportType.RELAY)) { val encryptedRequest = chaChaPolyCodec.encrypt(topic, requestJson, envelopeType, participants) val encryptedRequestString = Base64.toBase64String(encryptedRequest) @@ -136,7 +122,7 @@ internal class RelayJsonRpcInteractor( } } catch (e: Exception) { logger.error("JsonRpcInteractor: Cannot send the request, exception: $e") - return onFailure(e) + onFailure(Throwable("Publish Request Error: $e")) } } @@ -150,16 +136,15 @@ internal class RelayJsonRpcInteractor( envelopeType: EnvelopeType, ) { try { - checkConnectionWorking() + checkNetworkConnectivity() } catch (e: NoConnectivityException) { return onFailure(e) } try { - val responseJson = serializer.serialize(response) ?: return onFailure(IllegalStateException("JsonRpcInteractor: Unknown result params")) + val responseJson = serializer.serialize(response) ?: throw IllegalStateException("RelayJsonRpcInteractor: Unknown Response Params") val encryptedResponse = chaChaPolyCodec.encrypt(topic, responseJson, envelopeType, participants) val encryptedResponseString = Base64.toBase64String(encryptedResponse) - relay.publish(topic.value, encryptedResponseString, params.toRelay()) { result -> result.fold( onSuccess = { @@ -174,8 +159,94 @@ internal class RelayJsonRpcInteractor( } } catch (e: Exception) { logger.error("JsonRpcInteractor: Cannot send the response, exception: $e") + onFailure(Throwable("Publish Response Error: $e")) + } + } + + override fun subscribe(topic: Topic, onSuccess: (Topic) -> Unit, onFailure: (Throwable) -> Unit) { + try { + checkNetworkConnectivity() + } catch (e: NoConnectivityException) { return onFailure(e) } + + try { + backoffStrategy.shouldBackoff(true) + relay.subscribe(topic.value) { result -> + result.fold( + onSuccess = { acknowledgement -> + subscriptions[topic.value] = acknowledgement.result + onSuccess(topic) + }, + onFailure = { error -> + logger.error("Subscribe to topic error: $topic error: $error") + onFailure(Throwable("Subscribe error: ${error.message}")) + } + ) + } + } catch (e: Exception) { + logger.error("Subscribe to topic error: $topic error: $e") + onFailure(Throwable("Subscribe error: ${e.message}")) + } + } + + override fun batchSubscribe(topics: List, onSuccess: (List) -> Unit, onFailure: (Throwable) -> Unit) { + try { + checkNetworkConnectivity() + } catch (e: NoConnectivityException) { + return onFailure(e) + } + + if (topics.isNotEmpty()) { + backoffStrategy.shouldBackoff(true) + try { + relay.batchSubscribe(topics) { result -> + result.fold( + onSuccess = { acknowledgement -> + subscriptions.plusAssign(topics.zip(acknowledgement.result).toMap()) + onSuccess(topics) + }, + onFailure = { error -> + logger.error("Batch subscribe to topics error: $topics error: $error") + onFailure(Throwable("Batch subscribe error: ${error.message}")) + } + ) + } + } catch (e: Exception) { + logger.error("Batch subscribe to topics error: $topics error: $e") + onFailure(Throwable("Batch subscribe error: ${e.message}")) + } + } + } + + override fun unsubscribe(topic: Topic, onSuccess: () -> Unit, onFailure: (Throwable) -> Unit) { + try { + checkNetworkConnectivity() + } catch (e: NoConnectivityException) { + return onFailure(e) + } + + if (subscriptions.contains(topic.value)) { + val subscriptionId = SubscriptionId(subscriptions[topic.value].toString()) + relay.unsubscribe(topic.value, subscriptionId.id) { result -> + result.fold( + onSuccess = { + scope.launch { + supervisorScope { + jsonRpcHistory.deleteRecordsByTopic(topic) + subscriptions.remove(topic.value) + pushMessageStorage.deletePushMessagesByTopic(topic.value) + onSuccess() + } + } + }, + onFailure = { error -> + logger.error("Unsubscribe to topic: $topic error: $error") + onFailure(Throwable("Unsubscribe error: ${error.message}")) + } + ) + } + } } override fun respondWithParams( @@ -280,82 +351,6 @@ internal class RelayJsonRpcInteractor( } } - override fun subscribe(topic: Topic, onSuccess: (Topic) -> Unit, onFailure: (Throwable) -> Unit) { - try { - checkConnectionWorking() - } catch (e: NoConnectivityException) { - return onFailure(e) - } - - relay.subscribe(topic.value) { result -> - result.fold( - onSuccess = { acknowledgement -> - subscriptions[topic.value] = acknowledgement.result - onSuccess(topic) - }, - onFailure = { error -> - logger.error("Subscribe to topic error: $topic error: $error") - onFailure(Throwable("Subscribe error: ${error.message}")) - } - ) - } - } - - override fun batchSubscribe(topics: List, onSuccess: (List) -> Unit, onFailure: (Throwable) -> Unit) { - try { - checkConnectionWorking() - } catch (e: NoConnectivityException) { - return onFailure(e) - } - - if (topics.isNotEmpty()) { - relay.batchSubscribe(topics) { result -> - result.fold( - onSuccess = { acknowledgement -> - subscriptions.plusAssign(topics.zip(acknowledgement.result).toMap()) - onSuccess(topics) - }, - onFailure = { error -> - logger.error("Batch subscribe to topics error: $topics error: $error") - onFailure(Throwable("Batch subscribe error: ${error.message}")) - } - ) - } - } - } - - override fun unsubscribe(topic: Topic, onSuccess: () -> Unit, onFailure: (Throwable) -> Unit) { - try { - checkConnectionWorking() - } catch (e: NoConnectivityException) { - return onFailure(e) - } - - if (subscriptions.contains(topic.value)) { - val subscriptionId = SubscriptionId(subscriptions[topic.value].toString()) - relay.unsubscribe(topic.value, subscriptionId.id) { result -> - result.fold( - onSuccess = { - scope.launch { - supervisorScope { - jsonRpcHistory.deleteRecordsByTopic(topic) - subscriptions.remove(topic.value) - pushMessageStorage.deletePushMessagesByTopic(topic.value) - onSuccess() - } - } - }, - onFailure = { error -> - logger.error("Unsubscribe to topic: $topic error: $error") - onFailure(Throwable("Unsubscribe error: ${error.message}")) - } - ) - } - } else { - onFailure(NoSuchElementException(Uncategorized.NoMatchingTopic("Session", topic.value).message)) - } - } - private fun manageSubscriptions() { scope.launch { relay.subscriptionRequest.map { relayRequest -> diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/model/type/RelayJsonRpcInteractorInterface.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/model/type/RelayJsonRpcInteractorInterface.kt index 017090b2c..7f535b97c 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/model/type/RelayJsonRpcInteractorInterface.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/model/type/RelayJsonRpcInteractorInterface.kt @@ -7,11 +7,13 @@ import com.walletconnect.android.internal.common.model.Participants import com.walletconnect.android.internal.common.model.WCRequest import com.walletconnect.android.relay.WSSConnectionState import com.walletconnect.foundation.common.model.Topic +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface RelayJsonRpcInteractorInterface : JsonRpcInteractorInterface { val wssConnectionState: StateFlow - fun checkConnectionWorking() + val onResubscribe: Flow + fun checkNetworkConnectivity() fun subscribe(topic: Topic, onSuccess: (Topic) -> Unit = {}, onFailure: (Throwable) -> Unit = {}) diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/storage/events/EventsRepository.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/storage/events/EventsRepository.kt index 3463f721f..6d824430f 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/internal/common/storage/events/EventsRepository.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/common/storage/events/EventsRepository.kt @@ -38,7 +38,8 @@ class EventsRepository( this.props.properties?.trace, this.props.properties?.correlationId, this.props.properties?.clientId, - this.props.properties?.direction + this.props.properties?.direction, + this.props.properties?.userAgent ) } } @@ -85,7 +86,8 @@ class EventsRepository( trace = trace, clientId = client_id, correlationId = correlation_id, - direction = direction + direction = direction, + userAgent = user_agent ) ) ) diff --git a/core/android/src/main/kotlin/com/walletconnect/android/internal/utils/ObservableMap.kt b/core/android/src/main/kotlin/com/walletconnect/android/internal/utils/ObservableMap.kt new file mode 100644 index 000000000..c492a723c --- /dev/null +++ b/core/android/src/main/kotlin/com/walletconnect/android/internal/utils/ObservableMap.kt @@ -0,0 +1,19 @@ +package com.walletconnect.android.internal.utils + +class ObservableMap( + private val map: MutableMap = mutableMapOf(), + private val onChange: (Map) -> Unit +) : MutableMap by map { + + override fun put(key: K, value: V): V? { + return map.put(key, value).also { onChange(map) } + } + + override fun remove(key: K): V? { + return map.remove(key).also { onChange(map) } + } + + override fun putAll(from: Map) { + return map.putAll(from).also { onChange(map) } + } +} \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/pairing/client/PairingProtocol.kt b/core/android/src/main/kotlin/com/walletconnect/android/pairing/client/PairingProtocol.kt index 6888983b2..9f53e079b 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/pairing/client/PairingProtocol.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/pairing/client/PairingProtocol.kt @@ -10,16 +10,9 @@ import com.walletconnect.android.pairing.engine.domain.PairingEngine import com.walletconnect.android.pairing.engine.model.EngineDO import com.walletconnect.android.pairing.model.mapper.toCore import com.walletconnect.android.pulse.domain.InsertTelemetryEventUseCase -import com.walletconnect.android.pulse.model.EventType -import com.walletconnect.android.pulse.model.properties.Props import com.walletconnect.android.relay.RelayConnectionInterface -import com.walletconnect.android.relay.WSSConnectionState import com.walletconnect.foundation.util.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import org.koin.core.KoinApplication internal class PairingProtocol(private val koinApp: KoinApplication = wcKoinApp) : PairingInterface { @@ -75,24 +68,14 @@ internal class PairingProtocol(private val koinApp: KoinApplication = wcKoinApp) onError: (Core.Model.Error) -> Unit, ) { checkEngineInitialization() - - scope.launch(Dispatchers.IO) { - awaitConnection( - { - try { - pairingEngine.pair( - uri = pair.uri, - onSuccess = { onSuccess(pair) }, - onFailure = { error -> onError(Core.Model.Error(error)) } - ) - } catch (e: Exception) { - onError(Core.Model.Error(e)) - } - }, - { throwable -> - logger.error(throwable) - onError(Core.Model.Error(Throwable("Pairing error: ${throwable.message}"))) - }) + try { + pairingEngine.pair( + uri = pair.uri, + onSuccess = { onSuccess(pair) }, + onFailure = { error -> onError(Core.Model.Error(Throwable("Pairing error: ${error.message}"))) } + ) + } catch (e: Exception) { + onError(Core.Model.Error(e)) } } @@ -149,31 +132,6 @@ internal class PairingProtocol(private val koinApp: KoinApplication = wcKoinApp) } } - private suspend fun awaitConnection(onConnection: () -> Unit, errorLambda: (Throwable) -> Unit = {}) { - try { - withTimeout(60000) { - while (true) { - if (relayClient.isNetworkAvailable.value != null) { - if (relayClient.isNetworkAvailable.value == true) { - if (relayClient.wssConnectionState.value is WSSConnectionState.Connected) { - onConnection() - return@withTimeout - } - } else { - insertEventUseCase(Props(type = EventType.Error.NO_INTERNET_CONNECTION)) - errorLambda(Throwable("No internet connection")) - return@withTimeout - } - } - delay(100) - } - } - } catch (e: Exception) { - insertEventUseCase(Props(type = EventType.Error.NO_WSS_CONNECTION)) - errorLambda(Throwable("Failed to connect: ${e.message}")) - } - } - @Throws(IllegalStateException::class) private fun checkEngineInitialization() { check(::pairingEngine.isInitialized) { diff --git a/core/android/src/main/kotlin/com/walletconnect/android/pairing/engine/domain/PairingEngine.kt b/core/android/src/main/kotlin/com/walletconnect/android/pairing/engine/domain/PairingEngine.kt index d6b3c3c51..aec626150 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/pairing/engine/domain/PairingEngine.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/pairing/engine/domain/PairingEngine.kt @@ -39,13 +39,13 @@ import com.walletconnect.android.pairing.model.PairingParams import com.walletconnect.android.pairing.model.PairingRpc import com.walletconnect.android.pairing.model.mapper.toCore import com.walletconnect.android.pairing.model.pairingExpiry +import com.walletconnect.android.pulse.domain.InsertEventUseCase import com.walletconnect.android.pulse.domain.InsertTelemetryEventUseCase import com.walletconnect.android.pulse.domain.SendBatchEventUseCase import com.walletconnect.android.pulse.model.EventType import com.walletconnect.android.pulse.model.Trace import com.walletconnect.android.pulse.model.properties.Properties import com.walletconnect.android.pulse.model.properties.Props -import com.walletconnect.android.relay.WSSConnectionState import com.walletconnect.foundation.common.model.Topic import com.walletconnect.foundation.common.model.Ttl import com.walletconnect.foundation.util.Logger @@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -84,8 +83,11 @@ internal class PairingEngine( private val crypto: KeyManagementRepository, private val jsonRpcInteractor: RelayJsonRpcInteractorInterface, private val pairingRepository: PairingStorageRepositoryInterface, - private val insertEventUseCase: InsertTelemetryEventUseCase, - private val sendBatchEventUseCase: SendBatchEventUseCase + private val insertTelemetryEventUseCase: InsertTelemetryEventUseCase, + private val insertEventUseCase: InsertEventUseCase, + private val sendBatchEventUseCase: SendBatchEventUseCase, + private val clientId: String, + private val userAgent: String ) { private var jsonRpcRequestsJob: Job? = null private val setOfRegisteredMethods: MutableSet = mutableSetOf() @@ -160,7 +162,7 @@ internal class PairingEngine( val trace: MutableList = mutableListOf() trace.add(Trace.Pairing.PAIRING_STARTED).also { logger.log("Pairing started") } val walletConnectUri: WalletConnectUri = Validator.validateWCUri(uri) ?: run { - scope.launch { supervisorScope { insertEventUseCase(Props(type = EventType.Error.MALFORMED_PAIRING_URI, properties = Properties(trace = trace))) } } + scope.launch { supervisorScope { insertTelemetryEventUseCase(Props(type = EventType.Error.MALFORMED_PAIRING_URI, properties = Properties(trace = trace))) } } return onFailure(MalformedWalletConnectUri(MALFORMED_PAIRING_URI_MESSAGE)) } trace.add(Trace.Pairing.PAIRING_URI_VALIDATION_SUCCESS) @@ -169,7 +171,7 @@ internal class PairingEngine( val symmetricKey = walletConnectUri.symKey try { if (walletConnectUri.expiry?.isExpired() == true) { - scope.launch { supervisorScope { insertEventUseCase(Props(type = EventType.Error.PAIRING_URI_EXPIRED, properties = Properties(trace = trace, topic = pairingTopic.value))) } } + scope.launch { supervisorScope { insertTelemetryEventUseCase(Props(type = EventType.Error.PAIRING_URI_EXPIRED, properties = Properties(trace = trace, topic = pairingTopic.value))) } } .also { logger.error("Pairing URI expired: $pairingTopic") } return onFailure(ExpiredPairingURIException("Pairing URI expired: $pairingTopic")) } @@ -178,7 +180,7 @@ internal class PairingEngine( val localPairing = pairingRepository.getPairingOrNullByTopic(pairingTopic) trace.add(Trace.Pairing.EXISTING_PAIRING) if (!localPairing!!.isNotExpired()) { - scope.launch { supervisorScope { insertEventUseCase(Props(type = EventType.Error.PAIRING_EXPIRED, properties = Properties(trace = trace, topic = pairingTopic.value))) } } + scope.launch { supervisorScope { insertTelemetryEventUseCase(Props(type = EventType.Error.PAIRING_EXPIRED, properties = Properties(trace = trace, topic = pairingTopic.value))) } } .also { logger.error("Pairing expired: $pairingTopic") } return onFailure(ExpiredPairingException("Pairing expired: ${pairingTopic.value}")) } @@ -202,7 +204,7 @@ internal class PairingEngine( }, onFailure = { error -> scope.launch { supervisorScope { - insertEventUseCase(Props(type = EventType.Error.PAIRING_SUBSCRIPTION_FAILURE, properties = Properties(trace = trace, topic = pairingTopic.value))) + insertTelemetryEventUseCase(Props(type = EventType.Error.PAIRING_SUBSCRIPTION_FAILURE, properties = Properties(trace = trace, topic = pairingTopic.value))) } }.also { logger.error("Subscribe pairing topic error: $pairingTopic, error: $error") } onFailure(error) @@ -211,9 +213,18 @@ internal class PairingEngine( } catch (e: Exception) { logger.error("Subscribe pairing topic error: $pairingTopic, error: $e") if (e is NoRelayConnectionException) - scope.launch { supervisorScope { insertEventUseCase(Props(type = EventType.Error.NO_WSS_CONNECTION, properties = Properties(trace = trace, topic = pairingTopic.value))) } } + scope.launch { supervisorScope { insertTelemetryEventUseCase(Props(type = EventType.Error.NO_WSS_CONNECTION, properties = Properties(trace = trace, topic = pairingTopic.value))) } } if (e is NoInternetConnectionException) - scope.launch { supervisorScope { insertEventUseCase(Props(type = EventType.Error.NO_INTERNET_CONNECTION, properties = Properties(trace = trace, topic = pairingTopic.value))) } } + scope.launch { + supervisorScope { + insertTelemetryEventUseCase( + Props( + type = EventType.Error.NO_INTERNET_CONNECTION, + properties = Properties(trace = trace, topic = pairingTopic.value) + ) + ) + } + } runCatching { crypto.removeKeys(pairingTopic.value) }.onFailure { logger.error("Remove keys error: $pairingTopic, error: $it") } jsonRpcInteractor.unsubscribe(pairingTopic) onFailure(e) @@ -288,6 +299,7 @@ internal class PairingEngine( scope.launch { supervisorScope { try { + insertEventUseCase(Props(event = EventType.INIT, properties = Properties(clientId = clientId, userAgent = userAgent))) sendBatchEventUseCase() } catch (e: Exception) { logger.error("Error when sending events: $e") @@ -297,8 +309,7 @@ internal class PairingEngine( } private fun resubscribeToPairingTopics() { - jsonRpcInteractor.wssConnectionState - .filterIsInstance() + jsonRpcInteractor.onResubscribe .onEach { supervisorScope { launch(Dispatchers.IO) { diff --git a/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/EventType.kt b/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/EventType.kt index 41e0c9a87..1ec63f6f5 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/EventType.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/EventType.kt @@ -3,9 +3,13 @@ package com.walletconnect.android.pulse.model object EventType { @get:JvmSynthetic const val ERROR: String = "ERROR" + @get:JvmSynthetic const val SUCCESS: String = "SUCCESS" + @get:JvmSynthetic + const val INIT: String = "INIT" + @get:JvmSynthetic const val TRACK: String = "TRACE" diff --git a/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Properties.kt b/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Properties.kt index 0284da3d4..fceb0c53e 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Properties.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Properties.kt @@ -27,4 +27,6 @@ data class Properties( val clientId: String? = null, @Json(name = "direction") val direction: String? = null, + @Json(name = "user_agent") + val userAgent: String? = null, ) \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Props.kt b/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Props.kt index 0bfb09d8e..d7d006144 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Props.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/pulse/model/properties/Props.kt @@ -2,12 +2,13 @@ package com.walletconnect.android.pulse.model.properties import com.squareup.moshi.Json import com.walletconnect.android.pulse.model.EventType +import com.walletconnect.utils.Empty data class Props( @Json(name = "event") val event: String = EventType.ERROR, @Json(name = "type") - val type: String, + val type: String = String.Empty, @Json(name = "properties") val properties: Properties? = null ) \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/relay/ConnectionState.kt b/core/android/src/main/kotlin/com/walletconnect/android/relay/ConnectionState.kt index 6583cd536..14d84e097 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/relay/ConnectionState.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/relay/ConnectionState.kt @@ -3,10 +3,10 @@ package com.walletconnect.android.relay sealed class WSSConnectionState { - object Connected : WSSConnectionState() + data object Connected : WSSConnectionState() sealed class Disconnected : WSSConnectionState() { data class ConnectionFailed(val throwable: Throwable) : Disconnected() data class ConnectionClosed(val message: String? = null) : Disconnected() } -} +} \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/walletconnect/android/relay/NetworkClientTimeout.kt b/core/android/src/main/kotlin/com/walletconnect/android/relay/NetworkClientTimeout.kt index a500ff355..7ee752147 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/relay/NetworkClientTimeout.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/relay/NetworkClientTimeout.kt @@ -20,7 +20,7 @@ data class NetworkClientTimeout( companion object { - private const val MIN_TIMEOUT_LIMIT_AS_MILLIS = 10_000L + private const val MIN_TIMEOUT_LIMIT_AS_MILLIS = 15_000L private const val MAX_TIMEOUT_LIMIT_AS_MILLIS = 60_000L fun getDefaultTimeout() = NetworkClientTimeout( diff --git a/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayClient.kt b/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayClient.kt index dd39a8e98..e9d556325 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayClient.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayClient.kt @@ -4,6 +4,7 @@ package com.walletconnect.android.relay import com.walletconnect.android.Core import com.walletconnect.android.internal.common.connection.ConnectivityState +import com.walletconnect.android.internal.common.connection.DefaultConnectionLifecycle import com.walletconnect.android.internal.common.connection.ManualConnectionLifecycle import com.walletconnect.android.internal.common.di.AndroidCommonDITags import com.walletconnect.android.internal.common.exception.WRONG_CONNECTION_TYPE @@ -12,12 +13,15 @@ import com.walletconnect.android.internal.common.wcKoinApp import com.walletconnect.android.utils.toWalletConnectException import com.walletconnect.foundation.network.BaseRelayClient import com.walletconnect.foundation.network.model.Relay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import org.koin.core.KoinApplication @@ -25,17 +29,25 @@ import org.koin.core.qualifier.named class RelayClient(private val koinApp: KoinApplication = wcKoinApp) : BaseRelayClient(), RelayConnectionInterface { private val manualConnection: ManualConnectionLifecycle by lazy { koinApp.koin.get(named(AndroidCommonDITags.MANUAL_CONNECTION_LIFECYCLE)) } + private val defaultConnection: DefaultConnectionLifecycle by lazy { koinApp.koin.get(named(AndroidCommonDITags.DEFAULT_CONNECTION_LIFECYCLE)) } private val networkState: ConnectivityState by lazy { koinApp.koin.get(named(AndroidCommonDITags.CONNECTIVITY_STATE)) } override val isNetworkAvailable: StateFlow by lazy { networkState.isAvailable } private val _wssConnectionState: MutableStateFlow = MutableStateFlow(WSSConnectionState.Disconnected.ConnectionClosed()) override val wssConnectionState: StateFlow = _wssConnectionState private lateinit var connectionType: ConnectionType + override val onResubscribe: Flow + get() = merge( + connectionLifecycle.onResume.filter { isResumed -> isResumed != null && isResumed }, + wssConnectionState.filterIsInstance(WSSConnectionState.Connected::class) + ) @JvmSynthetic fun initialize(connectionType: ConnectionType, onError: (Throwable) -> Unit) { this.connectionType = connectionType logger = koinApp.koin.get(named(AndroidCommonDITags.LOGGER)) relayService = koinApp.koin.get(named(AndroidCommonDITags.RELAY_SERVICE)) + connectionLifecycle = if (connectionType == ConnectionType.MANUAL) manualConnection else defaultConnection + collectConnectionInitializationErrors { error -> onError(error) } monitorConnectionState() observeResults() @@ -45,13 +57,13 @@ class RelayClient(private val koinApp: KoinApplication = wcKoinApp) : BaseRelayC scope.launch { supervisorScope { eventsFlow - .takeWhile { event -> + .first { event -> if (event is Relay.Model.Event.OnConnectionFailed) { onError(event.throwable.toWalletConnectException) } event !is Relay.Model.Event.OnConnectionOpened<*> - }.collect() + } } } } @@ -68,7 +80,7 @@ class RelayClient(private val koinApp: KoinApplication = wcKoinApp) : BaseRelayC _wssConnectionState.value = WSSConnectionState.Connected event is Relay.Model.Event.OnConnectionFailed && _wssConnectionState.value is WSSConnectionState.Connected -> - _wssConnectionState.value = WSSConnectionState.Disconnected.ConnectionFailed(event.throwable) + _wssConnectionState.value = WSSConnectionState.Disconnected.ConnectionFailed(event.throwable.toWalletConnectException) event is Relay.Model.Event.OnConnectionClosed && _wssConnectionState.value is WSSConnectionState.Connected -> _wssConnectionState.value = WSSConnectionState.Disconnected.ConnectionClosed("Connection closed: ${event.shutdownReason.reason} ${event.shutdownReason.code}") @@ -93,7 +105,7 @@ class RelayClient(private val koinApp: KoinApplication = wcKoinApp) : BaseRelayC try { when (connectionType) { ConnectionType.AUTOMATIC -> onError(Core.Model.Error(IllegalStateException(WRONG_CONNECTION_TYPE))) - ConnectionType.MANUAL -> manualConnection.restart() + ConnectionType.MANUAL -> manualConnection.reconnect() } } catch (e: Exception) { onError(Core.Model.Error(e)) diff --git a/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayConnectionInterface.kt b/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayConnectionInterface.kt index c3f13d6b9..0a6606c63 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayConnectionInterface.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/relay/RelayConnectionInterface.kt @@ -2,11 +2,13 @@ package com.walletconnect.android.relay import com.walletconnect.android.Core import com.walletconnect.foundation.network.RelayInterface +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface RelayConnectionInterface : RelayInterface { val wssConnectionState: StateFlow val isNetworkAvailable: StateFlow + val onResubscribe: Flow @Deprecated("This has become deprecate in favor of the onError returning Core.Model.Error", ReplaceWith("this.connect(onErrorModel)")) fun connect(onErrorModel: (Core.Model.Error) -> Unit = {}, onError: (String) -> Unit) diff --git a/core/android/src/main/kotlin/com/walletconnect/android/utils/Extensions.kt b/core/android/src/main/kotlin/com/walletconnect/android/utils/Extensions.kt index 2dc2c5f9a..d87b71e43 100644 --- a/core/android/src/main/kotlin/com/walletconnect/android/utils/Extensions.kt +++ b/core/android/src/main/kotlin/com/walletconnect/android/utils/Extensions.kt @@ -41,12 +41,12 @@ internal val Throwable.toWalletConnectException: WalletConnectException UnableToConnectToWebsocketException("${this.message}. It's possible that JWT has expired. Try initializing the CoreClient again.") this.message?.contains(HttpURLConnection.HTTP_NOT_FOUND.toString()) == true -> - ProjectIdDoesNotExistException(this.message) + ProjectIdDoesNotExistException("Project ID doesn't exist: ${this.message}") this.message?.contains(HttpURLConnection.HTTP_FORBIDDEN.toString()) == true -> - InvalidProjectIdException(this.message) + InvalidProjectIdException("Invalid project ID: ${this.message}") - else -> GenericException("Error while connecting, please check your Internet connection or contact support: $this") + else -> GenericException("Error while connecting, please check your Internet connection or contact support: ${this.message}") } @get:JvmSynthetic diff --git a/core/android/src/main/sqldelight/com/walletconnect/android/sdk/storage/data/dao/Event.sq b/core/android/src/main/sqldelight/com/walletconnect/android/sdk/storage/data/dao/Event.sq index 9e573c2a4..b94f1a632 100644 --- a/core/android/src/main/sqldelight/com/walletconnect/android/sdk/storage/data/dao/Event.sq +++ b/core/android/src/main/sqldelight/com/walletconnect/android/sdk/storage/data/dao/Event.sq @@ -11,15 +11,16 @@ CREATE TABLE EventDao( trace TEXT AS List, correlation_id INTEGER, client_id TEXT, - direction TEXT + direction TEXT, + user_agent TEXT ); insertOrAbort: -INSERT OR ABORT INTO EventDao(event_id, bundle_id, timestamp, event_name, type, topic, trace, correlation_id, client_id, direction) -VALUES (?,?,?,?,?,?, ?, ?, ?, ?); +INSERT OR ABORT INTO EventDao(event_id, bundle_id, timestamp, event_name, type, topic, trace, correlation_id, client_id, direction, user_agent) +VALUES (?,?,?,?,?,?, ?, ?, ?, ?, ?); getAllEventsWithLimitAndOffset: -SELECT event_id, bundle_id, timestamp, event_name, type, topic, trace, correlation_id, client_id, direction +SELECT event_id, bundle_id, timestamp, event_name, type, topic, trace, correlation_id, client_id, direction, user_agent FROM EventDao ed LIMIT ? OFFSET ?; diff --git a/core/android/src/main/sqldelight/databases/12.db b/core/android/src/main/sqldelight/databases/12.db new file mode 100644 index 000000000..21a315fd6 Binary files /dev/null and b/core/android/src/main/sqldelight/databases/12.db differ diff --git a/core/android/src/main/sqldelight/migration/11.sqm b/core/android/src/main/sqldelight/migration/11.sqm new file mode 100644 index 000000000..769f4a610 --- /dev/null +++ b/core/android/src/main/sqldelight/migration/11.sqm @@ -0,0 +1,3 @@ +-- migration from 11.db to 12.db + +ALTER TABLE EventDao ADD COLUMN user_agent TEXT; \ No newline at end of file diff --git a/core/android/src/test/kotlin/com/walletconnect/android/internal/EventsRepositoryTest.kt b/core/android/src/test/kotlin/com/walletconnect/android/internal/EventsRepositoryTest.kt index e84b3f985..e509ac1bd 100644 --- a/core/android/src/test/kotlin/com/walletconnect/android/internal/EventsRepositoryTest.kt +++ b/core/android/src/test/kotlin/com/walletconnect/android/internal/EventsRepositoryTest.kt @@ -46,7 +46,7 @@ class EventsRepositoryTest { @Test fun `insertOrAbort should insert event when telemetry is enabled`() = runTest(testDispatcher) { val props = Props(event = "testEvent", type = "testType") - every { eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } just Runs + every { eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } just Runs repository.insertOrAbortTelemetry(props) @@ -61,7 +61,8 @@ class EventsRepositoryTest { trace = null, correlation_id = any(), client_id = any(), - direction = any() + direction = any(), + user_agent = any() ) } } @@ -69,7 +70,7 @@ class EventsRepositoryTest { @Test fun `insertOrAbort should insert event `() = runTest(testDispatcher) { val props = Props(event = "testEvent", type = "testType") - every { eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } just Runs + every { eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } just Runs repository.insertOrAbort(props) @@ -84,7 +85,8 @@ class EventsRepositoryTest { trace = null, correlation_id = any(), client_id = any(), - direction = any() + direction = any(), + user_agent = any() ) } } @@ -97,14 +99,14 @@ class EventsRepositoryTest { repository.insertOrAbortTelemetry(props) verify(exactly = 0) { - eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } } @Test fun `insertOrAbort should throw SQLiteException when insertion fails`() = runTest(testDispatcher) { val props = Props(event = "testEvent", type = "testType") - every { eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } throws SQLiteException() + every { eventQueries.insertOrAbort(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } throws SQLiteException() assertFailsWith { repository.insertOrAbortTelemetry(props) diff --git a/core/android/src/test/kotlin/com/walletconnect/android/internal/RelayClientTests.kt b/core/android/src/test/kotlin/com/walletconnect/android/internal/RelayClientTests.kt index c978a77fc..a594ec549 100644 --- a/core/android/src/test/kotlin/com/walletconnect/android/internal/RelayClientTests.kt +++ b/core/android/src/test/kotlin/com/walletconnect/android/internal/RelayClientTests.kt @@ -2,6 +2,8 @@ package com.walletconnect.android.internal import com.tinder.scarlet.WebSocket import com.walletconnect.android.internal.common.connection.ConnectivityState +import com.walletconnect.android.internal.common.connection.DefaultConnectionLifecycle +import com.walletconnect.android.internal.common.connection.ManualConnectionLifecycle import com.walletconnect.android.internal.common.di.AndroidCommonDITags import com.walletconnect.android.internal.common.scope import com.walletconnect.android.relay.ConnectionType @@ -34,6 +36,8 @@ import org.koin.dsl.module class RelayClientTests { private lateinit var relayClient: RelayClient private val mockRelayService = mockk(relaxed = true) + private val defaultConnectionLifecycleMock = mockk(relaxed = true) + private val manualConnectionLifecycleMock = mockk(relaxed = true) private val mockLogger = mockk(relaxed = true) private val mockNetworkState = mockk(relaxed = true) private val testDispatcher = StandardTestDispatcher() @@ -47,6 +51,8 @@ class RelayClientTests { single(named(AndroidCommonDITags.RELAY_SERVICE)) { mockRelayService } single(named(AndroidCommonDITags.LOGGER)) { mockLogger } single(named(AndroidCommonDITags.CONNECTIVITY_STATE)) { mockNetworkState } + single(named(AndroidCommonDITags.MANUAL_CONNECTION_LIFECYCLE)) { manualConnectionLifecycleMock } + single(named(AndroidCommonDITags.DEFAULT_CONNECTION_LIFECYCLE)) { defaultConnectionLifecycleMock } }) } @@ -73,7 +79,7 @@ class RelayClientTests { relayClient.initialize(ConnectionType.MANUAL) { error -> assertEquals( - "Error while connecting, please check your Internet connection or contact support: java.lang.Throwable: Network failure", + "Error while connecting, please check your Internet connection or contact support: Network failure", error.message ) scope.coroutineContext.cancelChildren() diff --git a/core/android/src/test/kotlin/com/walletconnect/android/internal/domain/RelayerInteractorTest.kt b/core/android/src/test/kotlin/com/walletconnect/android/internal/domain/RelayerInteractorTest.kt index 58eef91ea..ea4a6d2db 100644 --- a/core/android/src/test/kotlin/com/walletconnect/android/internal/domain/RelayerInteractorTest.kt +++ b/core/android/src/test/kotlin/com/walletconnect/android/internal/domain/RelayerInteractorTest.kt @@ -1,5 +1,6 @@ package com.walletconnect.android.internal.domain +import com.walletconnect.android.internal.common.ConditionalExponentialBackoffStrategy import com.walletconnect.android.internal.common.JsonRpcResponse import com.walletconnect.android.internal.common.crypto.codec.Codec import com.walletconnect.android.internal.common.exception.WalletConnectException @@ -40,6 +41,8 @@ internal class RelayerInteractorTest { every { subscriptionRequest } returns flow { } } + private val backoffStrategy: ConditionalExponentialBackoffStrategy = mockk() + private val jsonRpcHistory: JsonRpcHistory = mockk { every { setRequest(any(), any(), any(), any(), any()) } returns true every { updateRequestWithResponse(any(), any()) } returns mockk() @@ -70,8 +73,9 @@ internal class RelayerInteractorTest { } private val sut = - spyk(RelayJsonRpcInteractor(relay, codec, jsonRpcHistory, pushMessagesRepository, logger), recordPrivateCalls = true) { - every { checkConnectionWorking() } answers { } + spyk(RelayJsonRpcInteractor(relay, codec, jsonRpcHistory, pushMessagesRepository, logger, backoffStrategy), recordPrivateCalls = true) { + every { checkNetworkConnectivity() } answers { } + every { relay.onResubscribe } returns flow { } } private val topicVO = Topic("mockkTopic") @@ -86,7 +90,7 @@ internal class RelayerInteractorTest { every { topic } returns topicVO } - val peerError: Error = mockk { + private val peerError: Error = mockk { every { message } returns "message" every { code } returns -1 } diff --git a/foundation/src/main/kotlin/com/walletconnect/foundation/network/BaseRelayClient.kt b/foundation/src/main/kotlin/com/walletconnect/foundation/network/BaseRelayClient.kt index f8289b3af..6d73522e4 100644 --- a/foundation/src/main/kotlin/com/walletconnect/foundation/network/BaseRelayClient.kt +++ b/foundation/src/main/kotlin/com/walletconnect/foundation/network/BaseRelayClient.kt @@ -1,5 +1,6 @@ package com.walletconnect.foundation.network +import com.tinder.scarlet.WebSocket import com.walletconnect.foundation.common.model.SubscriptionId import com.walletconnect.foundation.common.model.Topic import com.walletconnect.foundation.common.model.Ttl @@ -12,11 +13,13 @@ import com.walletconnect.foundation.network.model.RelayDTO import com.walletconnect.foundation.util.Logger import com.walletconnect.foundation.util.scope import com.walletconnect.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 @@ -24,22 +27,35 @@ 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 { @@ -73,10 +89,15 @@ abstract class BaseRelayClient : RelayInterface { override val eventsFlow: SharedFlow by lazy { relayService .observeWebSocketEvent() - .map { event -> - if (isLoggingEnabled) { - println("Event: $event") + .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) @@ -96,12 +117,16 @@ abstract class BaseRelayClient : RelayInterface { id: Long?, onResult: (Result) -> Unit, ) { - 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) + 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) { @@ -131,14 +156,17 @@ abstract class BaseRelayClient : RelayInterface { @ExperimentalCoroutinesApi override fun subscribe(topic: String, id: Long?, onResult: (Result) -> Unit) { - 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) + 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) { @@ -172,18 +200,26 @@ abstract class BaseRelayClient : RelayInterface { @ExperimentalCoroutinesApi override fun batchSubscribe(topics: List, id: Long?, onResult: (Result) -> Unit) { - val batchSubscribeRequest = RelayDTO.BatchSubscribe.Request(id = id ?: generateClientToServerId(), params = RelayDTO.BatchSubscribe.Request.Params(topics)) - - observeBatchSubscribeResult(batchSubscribeRequest.id, onResult) - relayService.batchSubscribeRequest(batchSubscribeRequest) + 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, onResult: (Result) -> Unit) { + 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) { @@ -210,13 +246,18 @@ abstract class BaseRelayClient : RelayInterface { id: Long?, onResult: (Result) -> Unit, ) { - val unsubscribeRequest = RelayDTO.Unsubscribe.Request( - id = id ?: generateClientToServerId(), - params = RelayDTO.Unsubscribe.Request.Params(Topic(topic), SubscriptionId(subscriptionId)) - ) + 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) + observeUnsubscribeResult(unsubscribeRequest.id, onResult) + relayService.unsubscribeRequest(unsubscribeRequest) + }, + onFailure = { onResult(Result.failure(it)) } + ) } private fun observeUnsubscribeResult(id: Long, onResult: (Result) -> Unit) { @@ -244,6 +285,96 @@ abstract class BaseRelayClient : RelayInterface { } } + 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) @@ -255,8 +386,15 @@ abstract class BaseRelayClient : RelayInterface { } } + 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 } } \ No newline at end of file diff --git a/foundation/src/main/kotlin/com/walletconnect/foundation/network/ConnectionLifecycle.kt b/foundation/src/main/kotlin/com/walletconnect/foundation/network/ConnectionLifecycle.kt new file mode 100644 index 000000000..bdd927e93 --- /dev/null +++ b/foundation/src/main/kotlin/com/walletconnect/foundation/network/ConnectionLifecycle.kt @@ -0,0 +1,8 @@ +package com.walletconnect.foundation.network + +import kotlinx.coroutines.flow.StateFlow + +interface ConnectionLifecycle { + val onResume: StateFlow + fun reconnect() +} \ No newline at end of file diff --git a/foundation/src/test/kotlin/com/walletconnect/foundation/BaseRelayClientTest.kt b/foundation/src/test/kotlin/com/walletconnect/foundation/BaseRelayClientTest.kt index 9bc21c2f1..24e577221 100644 --- a/foundation/src/test/kotlin/com/walletconnect/foundation/BaseRelayClientTest.kt +++ b/foundation/src/test/kotlin/com/walletconnect/foundation/BaseRelayClientTest.kt @@ -1,19 +1,28 @@ package com.walletconnect.foundation +import com.tinder.scarlet.WebSocket import com.walletconnect.foundation.common.model.SubscriptionId import com.walletconnect.foundation.network.BaseRelayClient +import com.walletconnect.foundation.network.ConnectionLifecycle +import com.walletconnect.foundation.network.ConnectionState import com.walletconnect.foundation.network.data.service.RelayService import com.walletconnect.foundation.network.model.Relay import com.walletconnect.foundation.network.model.RelayDTO import com.walletconnect.foundation.util.Logger import com.walletconnect.foundation.util.scope +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -32,214 +41,248 @@ import org.junit.Test @ExperimentalCoroutinesApi class BaseRelayClientTest { - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - private lateinit var client: BaseRelayClient - private val relayServiceMock = mockk(relaxed = true) - private val loggerMock = mockk(relaxed = true) - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - - client = object : BaseRelayClient() { - init { - this.relayService = relayServiceMock - this.logger = loggerMock - scope = testScope - } - } - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `test publish success`() = testScope.runTest { - val topic = "testTopic" - val message = "testMessage" - val params = Relay.Model.IrnParams(1, 60, true) - val ack = RelayDTO.Publish.Result.Acknowledgement(123L, result = true) - - coEvery { relayServiceMock.publishRequest(any()) } returns Unit - coEvery { relayServiceMock.observePublishAcknowledgement() } returns flowOf(ack) - - client.observeResults() - client.publish(topic, message, params, 123L) { result -> - result.fold( - onSuccess = { - assertEquals(123L, it.id) - }, - onFailure = { fail(it.message) } - ) - } - - coVerify { relayServiceMock.publishRequest(any()) } - } - - @Test - fun `test publish error due to time out`() = testScope.runTest { - val topic = "testTopic" - val message = "testMessage" - val params = Relay.Model.IrnParams(1, 60, true) - - coEvery { relayServiceMock.publishRequest(any()) } returns Unit - coEvery { relayServiceMock.observePublishAcknowledgement() } returns flow { delay(10000L) } - - withContext(Dispatchers.Default.limitedParallelism(1)) { - client.publish(topic, message, params) { result -> - result.fold( - onSuccess = { - fail("Should not be successful") - }, - onFailure = { - assertTrue(result.exceptionOrNull() is TimeoutCancellationException) - } - ) - } - } - - advanceUntilIdle() - - coVerify { relayServiceMock.publishRequest(any()) } - } - - @Test - fun `test subscribe success`() = testScope.runTest { - val topic = "testTopic" - val expectedId = 123L - val relayDto = RelayDTO.Subscribe.Result.Acknowledgement(id = expectedId, result = SubscriptionId("testId")) - - coEvery { relayServiceMock.subscribeRequest(any()) } returns Unit - coEvery { relayServiceMock.observeSubscribeAcknowledgement() } returns flowOf(relayDto) - - client.observeResults() - client.subscribe(topic, expectedId) { result -> - result.fold( - onSuccess = { - assertEquals(expectedId, result.getOrNull()?.id) - }, - onFailure = { fail(it.message) } - ) - } - - coVerify { relayServiceMock.subscribeRequest(any()) } - } - - @Test - fun `test subscribe failure due to timeout`() = testScope.runTest() { - val topic = "testTopic" - - coEvery { relayServiceMock.subscribeRequest(any()) } returns Unit - coEvery { relayServiceMock.observeSubscribeAcknowledgement() } returns flow { delay(10000L) } - - client.subscribe(topic) { result -> - result.fold( - onSuccess = { - fail("Should not be successful") - }, - onFailure = { - assertTrue(result.exceptionOrNull() is TimeoutCancellationException) - } - ) - } - - testScheduler.apply { advanceTimeBy(5000); runCurrent() } - - coVerify { relayServiceMock.subscribeRequest(any()) } - } - - @Test - fun `test batch subscribe success`() = testScope.runTest { - val topics = listOf("testTopic") - val expectedId = 123L - val relayDto = RelayDTO.BatchSubscribe.Result.Acknowledgement(id = expectedId, result = listOf("testId")) - - coEvery { relayServiceMock.batchSubscribeRequest(any()) } returns Unit - coEvery { relayServiceMock.observeBatchSubscribeAcknowledgement() } returns flowOf(relayDto) - - client.observeResults() - client.batchSubscribe(topics, expectedId) { result -> - result.fold( - onSuccess = { - assertEquals(expectedId, result.getOrNull()?.id) - }, - onFailure = { fail(it.message) } - ) - } - - coVerify { relayServiceMock.batchSubscribeRequest(any()) } - } - - @Test - fun `test batch subscribe failure due to timeout`() = testScope.runTest { - val topics = listOf("testTopic") - - coEvery { relayServiceMock.batchSubscribeRequest(any()) } returns Unit - coEvery { relayServiceMock.observeBatchSubscribeAcknowledgement() } returns flow { delay(10000L) } - - client.batchSubscribe(topics) { result -> - result.fold( - onSuccess = { - fail("Should not be successful") - }, - onFailure = { - assertTrue(result.exceptionOrNull() is TimeoutCancellationException) - } - ) - - } - - testScheduler.apply { advanceTimeBy(5000); runCurrent() } - - coVerify { relayServiceMock.batchSubscribeRequest(any()) } - } - - @Test - fun `test unsubscribe success`() = testScope.runTest { - val topic = "testTopic" - val expectedId = 123L - val relayDto = RelayDTO.Unsubscribe.Result.Acknowledgement(id = expectedId, result = true) - - coEvery { relayServiceMock.unsubscribeRequest(any()) } returns Unit - coEvery { relayServiceMock.observeUnsubscribeAcknowledgement() } returns flowOf(relayDto) - - client.observeResults() - client.unsubscribe(topic, "subsId", expectedId) { result -> - result.fold( - onSuccess = { - assertEquals(expectedId, result.getOrNull()?.id) - }, - onFailure = { fail(it.message) } - ) - } - - coVerify { relayServiceMock.unsubscribeRequest(any()) } - } - - @Test - fun `test unsubscribe failure`() = testScope.runTest { - val topic = "testTopic" - - coEvery { relayServiceMock.subscribeRequest(any()) } returns Unit - coEvery { relayServiceMock.observeSubscribeAcknowledgement() } returns flow { delay(10000L) } - - client.subscribe(topic) { result -> - result.fold( - onSuccess = { - fail("Should not be successful") - }, - onFailure = { - assertTrue(result.exceptionOrNull() is TimeoutCancellationException) - } - ) - } - - testScheduler.apply { advanceTimeBy(5000); runCurrent() } - - coVerify { relayServiceMock.subscribeRequest(any()) } - } - + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private lateinit var client: BaseRelayClient + private val relayServiceMock = mockk(relaxed = true) + private val connectionLifecycleMock = mockk(relaxed = true) + private val loggerMock = mockk(relaxed = true) + private val mockConnectionState = MutableStateFlow(ConnectionState.Idle) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + client = spyk(object : BaseRelayClient() { + init { + this.relayService = relayServiceMock + this.logger = loggerMock + scope = testScope + this.connectionLifecycle = connectionLifecycleMock + } + }, recordPrivateCalls = true) + client.connectionState = mockConnectionState + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `publish invokes relayService publishRequest successfully`() = testScope.runTest { + val id = 123L + + val topic = "testTopic" + val message = "testMessage" + val params = Relay.Model.IrnParams(1, 60, true) + val ack = RelayDTO.Publish.Result.Acknowledgement(123L, result = true) + + val publishRequestSlot = slot() + coEvery { relayServiceMock.publishRequest(capture(publishRequestSlot)) } just Runs + coEvery { relayServiceMock.observePublishAcknowledgement() } returns flowOf(ack) + every { connectionLifecycleMock.reconnect() } just Runs + coEvery { relayServiceMock.observeWebSocketEvent() } returns flowOf(WebSocket.Event.OnConnectionOpened("Open")) + + client.connectionState.value = ConnectionState.Open + client.publish(topic, message, params, id) + + coVerify { relayServiceMock.publishRequest(any()) } + assertEquals(id, publishRequestSlot.captured.id) + } + + @Test + fun `test publish success`() = testScope.runTest { + val topic = "testTopic" + val message = "testMessage" + val params = Relay.Model.IrnParams(1, 60, true) + val ack = RelayDTO.Publish.Result.Acknowledgement(123L, result = true) + + coEvery { relayServiceMock.publishRequest(any()) } returns Unit + coEvery { relayServiceMock.observePublishAcknowledgement() } returns flowOf(ack) + coEvery { relayServiceMock.observeWebSocketEvent() } returns flowOf(WebSocket.Event.OnConnectionOpened("Open")) + + client.observeResults() + client.connectionState.value = ConnectionState.Open + client.publish(topic, message, params, 123L) { result -> + result.fold( + onSuccess = { + assertEquals(123L, it.id) + }, + onFailure = { fail(it.message) } + ) + } + + coVerify { relayServiceMock.publishRequest(any()) } + } + + + @Test + fun `test publish error due to time out`() = testScope.runTest() { + val topic = "testTopic" + val message = "testMessage" + val params = Relay.Model.IrnParams(1, 60, true) + + coEvery { relayServiceMock.publishRequest(any()) } returns Unit + coEvery { relayServiceMock.observePublishAcknowledgement() } returns flow { delay(15000L) } + + withContext(Dispatchers.Default.limitedParallelism(1)) { + client.connectionState.value = ConnectionState.Open + client.publish(topic, message, params) { result -> + result.fold( + onSuccess = { + fail("Should not be successful") + }, + onFailure = { + assertTrue(result.exceptionOrNull() is TimeoutCancellationException) + } + ) + } + } + + advanceUntilIdle() + + coVerify { relayServiceMock.publishRequest(any()) } + } + + @Test + fun `test subscribe success`() = testScope.runTest { + val topic = "testTopic" + val expectedId = 123L + val relayDto = RelayDTO.Subscribe.Result.Acknowledgement(id = expectedId, result = SubscriptionId("testId")) + + coEvery { relayServiceMock.subscribeRequest(any()) } returns Unit + coEvery { relayServiceMock.observeSubscribeAcknowledgement() } returns flowOf(relayDto) + + client.observeResults() + client.connectionState.value = ConnectionState.Open + client.subscribe(topic, expectedId) { result -> + result.fold( + onSuccess = { + assertEquals(expectedId, result.getOrNull()?.id) + }, + onFailure = { fail(it.message) } + ) + } + + coVerify { relayServiceMock.subscribeRequest(any()) } + } + + @Test + fun `test subscribe failure due to timeout`() = testScope.runTest() { + val topic = "testTopic" + + coEvery { relayServiceMock.subscribeRequest(any()) } returns Unit + coEvery { relayServiceMock.observeSubscribeAcknowledgement() } returns flow { delay(10000L) } + + client.connectionState.value = ConnectionState.Open + client.subscribe(topic) { result -> + result.fold( + onSuccess = { + fail("Should not be successful") + }, + onFailure = { + assertTrue(result.exceptionOrNull() is TimeoutCancellationException) + } + ) + } + + testScheduler.apply { advanceTimeBy(5000); runCurrent() } + + coVerify { relayServiceMock.subscribeRequest(any()) } + } + + @Test + fun `test batch subscribe success`() = testScope.runTest { + val topics = listOf("testTopic") + val expectedId = 123L + val relayDto = RelayDTO.BatchSubscribe.Result.Acknowledgement(id = expectedId, result = listOf("testId")) + + coEvery { relayServiceMock.batchSubscribeRequest(any()) } returns Unit + coEvery { relayServiceMock.observeBatchSubscribeAcknowledgement() } returns flowOf(relayDto) + + client.observeResults() + client.connectionState.value = ConnectionState.Open + client.batchSubscribe(topics, expectedId) { result -> + result.fold( + onSuccess = { + assertEquals(expectedId, result.getOrNull()?.id) + }, + onFailure = { fail(it.message) } + ) + } + + coVerify { relayServiceMock.batchSubscribeRequest(any()) } + } + + @Test + fun `test batch subscribe failure due to timeout`() = testScope.runTest { + val topics = listOf("testTopic") + + coEvery { relayServiceMock.batchSubscribeRequest(any()) } returns Unit + coEvery { relayServiceMock.observeBatchSubscribeAcknowledgement() } returns flow { delay(10000L) } + + client.batchSubscribe(topics) { result -> + result.fold( + onSuccess = { + fail("Should not be successful") + }, + onFailure = { + assertTrue(result.exceptionOrNull() is TimeoutCancellationException) + } + ) + + } + + testScheduler.apply { advanceTimeBy(5000); runCurrent() } + + coVerify { relayServiceMock.batchSubscribeRequest(any()) } + } + + @Test + fun `test unsubscribe success`() = testScope.runTest { + val topic = "testTopic" + val expectedId = 123L + val relayDto = RelayDTO.Unsubscribe.Result.Acknowledgement(id = expectedId, result = true) + + coEvery { relayServiceMock.unsubscribeRequest(any()) } returns Unit + coEvery { relayServiceMock.observeUnsubscribeAcknowledgement() } returns flowOf(relayDto) + + client.observeResults() + client.connectionState.value = ConnectionState.Open + client.unsubscribe(topic, "subsId", expectedId) { result -> + result.fold( + onSuccess = { + assertEquals(expectedId, result.getOrNull()?.id) + }, + onFailure = { fail(it.message) } + ) + } + + coVerify { relayServiceMock.unsubscribeRequest(any()) } + } + + @Test + fun `test unsubscribe failure`() = testScope.runTest { + val topic = "testTopic" + + coEvery { relayServiceMock.subscribeRequest(any()) } returns Unit + coEvery { relayServiceMock.observeSubscribeAcknowledgement() } returns flow { delay(10000L) } + + client.connectionState.value = ConnectionState.Open + client.subscribe(topic) { result -> + result.fold( + onSuccess = { + fail("Should not be successful") + }, + onFailure = { + assertTrue(result.exceptionOrNull() is TimeoutCancellationException) + } + ) + } + + testScheduler.apply { advanceTimeBy(5000); runCurrent() } + + coVerify { relayServiceMock.subscribeRequest(any()) } + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9b92764b1..d002a0a2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ sqlDelight = "2.0.2" dokka = "1.9.20" moshi = "1.15.1" googleService = "4.4.1" -scarlet = "1.0.1" +scarlet = "1.0.2" koin = "3.5.6" retrofit = "2.11.0" okhttp = "4.12.0" diff --git a/protocol/notify/src/main/kotlin/com/walletconnect/notify/engine/NotifyEngine.kt b/protocol/notify/src/main/kotlin/com/walletconnect/notify/engine/NotifyEngine.kt index ec22f9324..0a0405891 100644 --- a/protocol/notify/src/main/kotlin/com/walletconnect/notify/engine/NotifyEngine.kt +++ b/protocol/notify/src/main/kotlin/com/walletconnect/notify/engine/NotifyEngine.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach @@ -101,9 +100,7 @@ internal class NotifyEngine( } suspend fun setup() { - jsonRpcInteractor.wssConnectionState - .onEach { state -> handleWSSState(state) } - .filterIsInstance() + jsonRpcInteractor.onResubscribe .onEach { supervisorScope { launch(Dispatchers.IO) { diff --git a/protocol/sign/src/main/kotlin/com/walletconnect/sign/di/CallsModule.kt b/protocol/sign/src/main/kotlin/com/walletconnect/sign/di/CallsModule.kt index 454e431f0..d6237dfec 100644 --- a/protocol/sign/src/main/kotlin/com/walletconnect/sign/di/CallsModule.kt +++ b/protocol/sign/src/main/kotlin/com/walletconnect/sign/di/CallsModule.kt @@ -136,7 +136,8 @@ internal fun callsModule() = module { verifyContextStorageRepository = get(), proposalStorageRepository = get(), jsonRpcInteractor = get(), - logger = get(named(AndroidCommonDITags.LOGGER)) + logger = get(named(AndroidCommonDITags.LOGGER)), + pairingController = get() ) } diff --git a/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/domain/SignEngine.kt b/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/domain/SignEngine.kt index f117e1b05..bd9366ded 100644 --- a/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/domain/SignEngine.kt +++ b/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/domain/SignEngine.kt @@ -83,7 +83,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge @@ -216,22 +215,23 @@ internal class SignEngine( } private fun handleRelayRequestsAndResponses() { - jsonRpcInteractor.wssConnectionState - .filterIsInstance() + jsonRpcInteractor.onResubscribe .onEach { - supervisorScope { - launch(Dispatchers.IO) { - resubscribeToSession() - resubscribeToPendingAuthenticateTopics() + scope.launch { + supervisorScope { + launch(Dispatchers.IO) { + resubscribeToSession() + resubscribeToPendingAuthenticateTopics() + } } - } - if (jsonRpcRequestsJob == null) { - jsonRpcRequestsJob = collectJsonRpcRequests() - } + if (jsonRpcRequestsJob == null) { + jsonRpcRequestsJob = collectJsonRpcRequests() + } - if (jsonRpcResponsesJob == null) { - jsonRpcResponsesJob = collectJsonRpcResponses() + if (jsonRpcResponsesJob == null) { + jsonRpcResponsesJob = collectJsonRpcResponses() + } } }.launchIn(scope) } @@ -325,9 +325,7 @@ internal class SignEngine( listOfExpiredSession .map { session -> session.topic } .onEach { sessionTopic -> - runCatching { - crypto.removeKeys(sessionTopic.value) - }.onFailure { logger.error(it) } + runCatching { crypto.removeKeys(sessionTopic.value) }.onFailure { logger.error(it) } sessionStorageRepository.deleteSession(sessionTopic) } diff --git a/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/use_case/calls/RejectSessionUseCase.kt b/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/use_case/calls/RejectSessionUseCase.kt index 8517eb6dd..16466d5e3 100644 --- a/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/use_case/calls/RejectSessionUseCase.kt +++ b/protocol/sign/src/main/kotlin/com/walletconnect/sign/engine/use_case/calls/RejectSessionUseCase.kt @@ -1,5 +1,6 @@ package com.walletconnect.sign.engine.use_case.calls +import com.walletconnect.android.Core import com.walletconnect.android.internal.common.model.IrnParams import com.walletconnect.android.internal.common.model.Tags import com.walletconnect.android.internal.common.model.type.RelayJsonRpcInteractorInterface @@ -7,6 +8,7 @@ import com.walletconnect.android.internal.common.scope import com.walletconnect.android.internal.common.storage.verify.VerifyContextStorageRepository import com.walletconnect.android.internal.utils.CoreValidator.isExpired import com.walletconnect.android.internal.utils.fiveMinutesInSeconds +import com.walletconnect.android.pairing.handler.PairingControllerInterface import com.walletconnect.foundation.common.model.Ttl import com.walletconnect.foundation.util.Logger import com.walletconnect.sign.common.exceptions.PeerError @@ -20,6 +22,7 @@ internal class RejectSessionUseCase( private val verifyContextStorageRepository: VerifyContextStorageRepository, private val jsonRpcInteractor: RelayJsonRpcInteractorInterface, private val proposalStorageRepository: ProposalStorageRepository, + private val pairingController: PairingControllerInterface, private val logger: Logger ) : RejectSessionUseCaseInterface { @@ -42,6 +45,7 @@ internal class RejectSessionUseCase( scope.launch { proposalStorageRepository.deleteProposal(proposerPublicKey) verifyContextStorageRepository.delete(proposal.requestId) + pairingController.deleteAndUnsubscribePairing(Core.Params.Delete(proposal.pairingTopic.value)) } onSuccess() }, diff --git a/protocol/sign/src/test/kotlin/com/walletconnect/sign/engine/use_case/calls/ApproveSessionAuthenticateUseCaseTest.kt b/protocol/sign/src/test/kotlin/com/walletconnect/sign/engine/use_case/calls/ApproveSessionAuthenticateUseCaseTest.kt index 58b18c6da..091d511f0 100644 --- a/protocol/sign/src/test/kotlin/com/walletconnect/sign/engine/use_case/calls/ApproveSessionAuthenticateUseCaseTest.kt +++ b/protocol/sign/src/test/kotlin/com/walletconnect/sign/engine/use_case/calls/ApproveSessionAuthenticateUseCaseTest.kt @@ -168,7 +168,7 @@ class ApproveSessionAuthenticateUseCaseTest { assert(throwable is MissingSessionAuthenticateRequest) }) - coVerify { insertEventUseCase(any()) } + coVerify { insertTelemetryEventUseCase(any()) } } @Test @@ -213,7 +213,7 @@ class ApproveSessionAuthenticateUseCaseTest { assert(throwable is RequestExpiredException) }) - coVerify { insertEventUseCase(any()) } + coVerify { insertTelemetryEventUseCase(any()) } } @Test diff --git a/sample/dapp/src/main/kotlin/com/walletconnect/sample/dapp/ui/routes/host/DappSampleHost.kt b/sample/dapp/src/main/kotlin/com/walletconnect/sample/dapp/ui/routes/host/DappSampleHost.kt index 2b1074a2b..48eb12e5f 100644 --- a/sample/dapp/src/main/kotlin/com/walletconnect/sample/dapp/ui/routes/host/DappSampleHost.kt +++ b/sample/dapp/src/main/kotlin/com/walletconnect/sample/dapp/ui/routes/host/DappSampleHost.kt @@ -87,20 +87,29 @@ fun DappSampleHost() { @Composable private fun NoConnectionIndicator() { - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF3496ff)) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text(text = "No internet connection", color = Color.White) - Spacer(modifier = Modifier.width(4.dp)) - Image( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_offline), - contentDescription = null, - modifier = Modifier.size(24.dp), - colorFilter = ColorFilter.tint(color = Color.White) - ) + var shouldShow by remember { mutableStateOf(true) } + + LaunchedEffect(key1 = Unit) { + delay(3000) + shouldShow = false + } + + if (shouldShow) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF3496ff)) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(text = "No internet connection", color = Color.White) + Spacer(modifier = Modifier.width(4.dp)) + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_offline), + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(color = Color.White) + ) + } } } @@ -109,7 +118,7 @@ private fun RestoredConnectionIndicator() { var shouldShow by remember { mutableStateOf(true) } LaunchedEffect(key1 = Unit) { - delay(2000) + delay(3000) shouldShow = false } if (shouldShow) { diff --git a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/Web3WalletApplication.kt b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/Web3WalletApplication.kt index 27f84ec39..97e343ea3 100644 --- a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/Web3WalletApplication.kt +++ b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/Web3WalletApplication.kt @@ -74,13 +74,14 @@ class Web3WalletApplication : Application() { metaData = appMetaData, onError = { error -> Firebase.crashlytics.recordException(error.throwable) - println(error.throwable.stackTraceToString()) + println("Init error: ${error.throwable.stackTraceToString()}") scope.launch { connectionStateFlow.emit(ConnectionState.Error(error.throwable.message ?: "")) } } ) + mixPanel = MixpanelAPI.getInstance(this, CommonBuildConfig.MIX_PANEL, true).apply { identify(CoreClient.Push.clientId) people.set("\$name", EthAccountDelegate.ethAddress) diff --git a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/domain/WCDelegate.kt b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/domain/WCDelegate.kt index 2b6d3b75d..57d248966 100644 --- a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/domain/WCDelegate.kt +++ b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/domain/WCDelegate.kt @@ -43,7 +43,6 @@ object WCDelegate : Web3Wallet.WalletDelegate, CoreClient.CoreDelegate { } override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { - state.isAvailable scope.launch { _connectionState.emit(state) } diff --git a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/Web3WalletViewModel.kt b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/Web3WalletViewModel.kt index 5e8b4ccb6..4687327d2 100644 --- a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/Web3WalletViewModel.kt +++ b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/Web3WalletViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import com.walletconnect.android.Core +import com.walletconnect.android.internal.common.exception.InvalidProjectIdException +import com.walletconnect.android.internal.common.exception.ProjectIdDoesNotExistException import com.walletconnect.sample.wallet.domain.ISSUER import com.walletconnect.sample.wallet.domain.WCDelegate import com.walletconnect.sample.wallet.ui.state.ConnectionState @@ -68,7 +70,17 @@ class Web3WalletViewModel : ViewModel() { val connectionState = if (it.isAvailable) { ConnectionState.Ok } else { - ConnectionState.Error("No Internet connection, please check your internet connection and try again") + val message = when (it.reason) { + is Wallet.Model.ConnectionState.Reason.ConnectionFailed -> { + if ((it.reason as Wallet.Model.ConnectionState.Reason.ConnectionFailed).throwable is ProjectIdDoesNotExistException || + (it.reason as Wallet.Model.ConnectionState.Reason.ConnectionFailed).throwable is InvalidProjectIdException + ) "Invalid Project Id" else "Connection failed" + } + + else -> "Connection closed" + } + + ConnectionState.Error(message) } connectivityStateFlow.value = connectionState }.launchIn(viewModelScope) diff --git a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/routes/host/WalletSampleHost.kt b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/routes/host/WalletSampleHost.kt index d5d18566c..3962ee6e1 100644 --- a/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/routes/host/WalletSampleHost.kt +++ b/sample/wallet/src/main/kotlin/com/walletconnect/sample/wallet/ui/routes/host/WalletSampleHost.kt @@ -108,7 +108,7 @@ fun WalletSampleHost( ) if (connectionState is ConnectionState.Error) { - ErrorBanner() + ErrorBanner((connectionState as ConnectionState.Error).message) } else if (connectionState is ConnectionState.Ok) { RestoredConnectionBanner() } @@ -185,11 +185,11 @@ private fun BoxScope.Loader(initMessage: String, updateMessage: String) { } @Composable -private fun ErrorBanner() { +private fun ErrorBanner(message: String) { var shouldShow by remember { mutableStateOf(true) } LaunchedEffect(key1 = Unit) { - delay(2000) + delay(5000) shouldShow = false } @@ -208,7 +208,7 @@ private fun ErrorBanner() { colorFilter = ColorFilter.tint(color = Color.White) ) Spacer(modifier = Modifier.width(4.dp)) - Text(text = "Network connection lost", color = Color.White) + Text(text = "Network connection lost: $message", color = Color.White) } } }