From 9cd356321937cc5ce060ed70a0ccc61bcd6b21ce Mon Sep 17 00:00:00 2001 From: Rodrigo Varela Date: Fri, 17 Jan 2025 13:56:25 +1100 Subject: [PATCH 1/2] - 3 states of connectivity implementation and usage for trusted node with reconnection on connectivity recovered --- .../ClientApplicationBootstrapFacade.kt | 2 +- .../client/websocket/WebSocketClient.kt | 86 +++++++++++++++---- .../websocket/WebSocketClientProvider.kt | 8 +- .../domain/service/TrustedNodeService.kt | 19 +++- .../bisq/mobile/client/ClientMainPresenter.kt | 34 ++++++-- .../presentation/di/PresentationModule.kt | 2 +- .../selected/InterruptedTradePresenter.kt | 9 +- .../selected/TradeDetailsHeaderPresenter.kt | 6 +- .../selected/TradeFlowPresenter.kt | 1 + 9 files changed, 127 insertions(+), 40 deletions(-) diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/bootstrap/ClientApplicationBootstrapFacade.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/bootstrap/ClientApplicationBootstrapFacade.kt index 7fee1938..ae261059 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/bootstrap/ClientApplicationBootstrapFacade.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/bootstrap/ClientApplicationBootstrapFacade.kt @@ -34,7 +34,7 @@ class ClientApplicationBootstrapFacade( // } else { setProgress(0.5f) setState("Connecting to Trusted Node..") - if (!trustedNodeService.isConnected()) { + if (!trustedNodeService.isConnected) { try { trustedNodeService.connect() setState("bootstrap.connectedToTrustedNode".i18n()) diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClient.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClient.kt index 31f9b43d..cdd854fd 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClient.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClient.kt @@ -10,6 +10,9 @@ import io.ktor.websocket.close import io.ktor.websocket.readText import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -36,32 +39,57 @@ class WebSocketClient( val port: Int ) : Logging { + companion object { + const val DELAY_TO_RECONNECT = 3000L + } + private val webSocketUrl: String = "ws://$host:$port/websocket" private var session: DefaultClientWebSocketSession? = null - var isConnected = false private val webSocketEventObservers = ConcurrentMap() private val requestResponseHandlers = mutableMapOf() private var connectionReady = CompletableDeferred() private val requestResponseHandlersMutex = Mutex() - suspend fun connect() { + private val backgroundScope = CoroutineScope(BackgroundDispatcher) + + enum class WebSockectClientStatus { + DISCONNECTED, + CONNECTING, + CONNECTED + } + + val _connected = MutableStateFlow(WebSockectClientStatus.DISCONNECTED) + val connected: StateFlow = _connected + + fun isConnected(): Boolean = connected.value == WebSockectClientStatus.CONNECTED + + suspend fun connect(isTest: Boolean = false) { log.i("Connecting to websocket at: $webSocketUrl") - if (!isConnected) { + if (connected.value != WebSockectClientStatus.CONNECTED) { try { + _connected.value = WebSockectClientStatus.CONNECTING session = httpClient.webSocketSession { url(webSocketUrl) } if (session?.isActive == true) { - isConnected = true + _connected.value = WebSockectClientStatus.CONNECTED CoroutineScope(BackgroundDispatcher).launch { startListening() } connectionReady.complete(true) + if (!isTest) { + log.d { "Websocket connected" } + } } } catch (e: Exception) { log.e("Connecting websocket failed", e) - throw e + _connected.value = WebSockectClientStatus.DISCONNECTED + if (isTest) { + throw e + } else { + reconnect() + } } } } - suspend fun disconnect() { + suspend fun disconnect(isTest: Boolean = false) { requestResponseHandlersMutex.withLock { requestResponseHandlers.values.forEach { it.dispose() } requestResponseHandlers.clear() @@ -69,7 +97,19 @@ class WebSocketClient( session?.close() session = null - isConnected = false + _connected.value = WebSockectClientStatus.DISCONNECTED + if (!isTest) { + log.d { "WS disconnected" } + } + } + + private fun reconnect() { + backgroundScope.launch { + log.d { "Launching reconnect" } + disconnect() + delay(DELAY_TO_RECONNECT) // Delay before reconnecting + connect() // Try reconnecting recursively + } } // Blocking request until we get the associated response @@ -133,21 +173,29 @@ class WebSocketClient( private suspend fun startListening() { session?.let { session -> - for (frame in session.incoming) { - if (frame is Frame.Text) { - val message = frame.readText() - //todo add input validation - log.d { "Received raw text $message" } - val webSocketMessage: WebSocketMessage = - json.decodeFromString(WebSocketMessage.serializer(), message) - log.i { "Received webSocketMessage $webSocketMessage" } - if (webSocketMessage is WebSocketResponse) { - onWebSocketResponse(webSocketMessage) - } else if (webSocketMessage is WebSocketEvent) { - onWebSocketEvent(webSocketMessage) + try { + for (frame in session.incoming) { + if (frame is Frame.Text) { + val message = frame.readText() + //todo add input validation + log.d { "Received raw text $message" } + val webSocketMessage: WebSocketMessage = + json.decodeFromString(WebSocketMessage.serializer(), message) + log.i { "Received webSocketMessage $webSocketMessage" } + if (webSocketMessage is WebSocketResponse) { + onWebSocketResponse(webSocketMessage) + } else if (webSocketMessage is WebSocketEvent) { + onWebSocketEvent(webSocketMessage) + } } } + } catch (e: Exception) { + log.e(e) { "Exception ocurred whilst listening for WS messages - triggering reconnect" } + } finally { + log.d { "Not listining for WS messages anymore" } + reconnect() } + } } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClientProvider.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClientProvider.kt index e2c34da7..7b01da70 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClientProvider.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClientProvider.kt @@ -51,7 +51,7 @@ class WebSocketClientProvider( } // only update if there was actually a change if (currentClient == null || currentClient!!.host != host || currentClient!!.port != port) { - if (currentClient?.isConnected == true) { + if (currentClient?.isConnected() == true) { currentClient?.disconnect() } log.d { "Websocket client updated with url $host:$port" } @@ -70,13 +70,13 @@ class WebSocketClientProvider( val url = "ws://$host:$port" return try { // if connection is refused, catch will execute returning false - client.connect() - return client.isConnected + client.connect(true) + return client.isConnected() } catch (e: Exception) { log.e("Error testing connection to $url: ${e.message}") false } finally { - client.disconnect() // Ensure the client is closed to free resources + client.disconnect(true) // Ensure the client is closed to free resources } } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/TrustedNodeService.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/TrustedNodeService.kt index db287cc3..e1fb958e 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/TrustedNodeService.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/service/TrustedNodeService.kt @@ -1,6 +1,7 @@ package network.bisq.mobile.domain.service import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import network.bisq.mobile.client.websocket.WebSocketClientProvider import network.bisq.mobile.domain.data.BackgroundDispatcher import network.bisq.mobile.domain.utils.Logging @@ -12,14 +13,16 @@ import network.bisq.mobile.domain.utils.Logging class TrustedNodeService(private val webSocketClientProvider: WebSocketClientProvider) : Logging { private val backgroundScope = CoroutineScope(BackgroundDispatcher) - // TODO websocketClient.isConnected should be observable so that we emit - // events when disconnected and UI can react - fun isConnected() = webSocketClientProvider.get().isConnected + var isConnected: Boolean = false + var observingConnectivity = false /** * Connects to the trusted node, throws an exception if connection fails */ suspend fun connect() { + if (!observingConnectivity) { + observeConnectivity() + } runCatching { webSocketClientProvider.get().connect() }.onSuccess { @@ -33,4 +36,14 @@ class TrustedNodeService(private val webSocketClientProvider: WebSocketClientPro suspend fun disconnect() { // TODO } + + private fun observeConnectivity() { + backgroundScope.launch { + webSocketClientProvider.get().connected.collect { + log.d { "connectivity status changed - connected = $it" } + isConnected = webSocketClientProvider.get().isConnected() + } + } + observingConnectivity = true + } } \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt index 7ce9aad4..a4602bfc 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt @@ -1,5 +1,7 @@ package network.bisq.mobile.client +import kotlinx.coroutines.launch +import network.bisq.mobile.client.websocket.WebSocketClientProvider import network.bisq.mobile.domain.UrlLauncher import network.bisq.mobile.domain.service.bootstrap.ApplicationBootstrapFacade import network.bisq.mobile.domain.service.controller.NotificationServiceController @@ -11,6 +13,7 @@ import network.bisq.mobile.presentation.MainPresenter class ClientMainPresenter( notificationServiceController: NotificationServiceController, + private val webSocketClientProvider: WebSocketClientProvider, private val applicationBootstrapFacade: ApplicationBootstrapFacade, private val offersServiceFacade: OffersServiceFacade, private val marketPriceServiceFacade: MarketPriceServiceFacade, @@ -21,7 +24,31 @@ class ClientMainPresenter( override fun onViewAttached() { super.onViewAttached() + activateServices() + listenForConnectivity() + } + override fun onViewUnattaching() { + // For Tor we might want to leave it running while in background to avoid delay of re-connect + // when going into foreground again. + // coroutineScope.launch { webSocketClient.disconnect() } + deactivateServices() + super.onViewUnattaching() + } + + private fun listenForConnectivity() { + backgroundScope.launch { + webSocketClientProvider.get().connected.collect { + if (webSocketClientProvider.get().isConnected()) { + log.d { "connectivity status changed to $it - reconnecting services" } + deactivateServices() + activateServices() + } + } + } + } + + private fun activateServices() { runCatching { applicationBootstrapFacade.activate() offersServiceFacade.activate() @@ -35,16 +62,11 @@ class ClientMainPresenter( } } - override fun onViewUnattaching() { - // For Tor we might want to leave it running while in background to avoid delay of re-connect - // when going into foreground again. - // coroutineScope.launch { webSocketClient.disconnect() } - + private fun deactivateServices() { applicationBootstrapFacade.deactivate() offersServiceFacade.deactivate() marketPriceServiceFacade.deactivate() tradesServiceFacade.deactivate() settingsServiceFacade.deactivate() - super.onViewUnattaching() } } \ No newline at end of file diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt index edb46315..0f27abd6 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.kt @@ -43,7 +43,7 @@ import org.koin.dsl.bind import org.koin.dsl.module val presentationModule = module { - single { ClientMainPresenter(get(), get(), get(), get(), get(), get(), get()) } bind AppPresenter::class + single { ClientMainPresenter(get(), get(), get(), get(), get(), get(), get(), get()) } bind AppPresenter::class single { TopBarPresenter(get(), get()) } bind ITopBarPresenter::class diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/InterruptedTradePresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/InterruptedTradePresenter.kt index 0cdf53ef..6d0d1aba 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/InterruptedTradePresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/InterruptedTradePresenter.kt @@ -35,6 +35,7 @@ class InterruptedTradePresenter( var reportToMediatorButtonVisible: Boolean = false override fun onViewAttached() { + super.onViewAttached() require(tradesServiceFacade.selectedTrade.value != null) val openTradeItemModel = tradesServiceFacade.selectedTrade.value!! presenterScope.launch { @@ -46,6 +47,7 @@ class InterruptedTradePresenter( override fun onViewUnattaching() { reset() + super.onViewUnattaching() } private fun tradeStateChanged(state: BisqEasyTradeStateEnum?) { @@ -138,9 +140,10 @@ class InterruptedTradePresenter( fun onCloseTrade() { backgroundScope.launch { - require(selectedTrade.value != null) - tradesServiceFacade.closeTrade() - navigateToTab(Routes.TabOpenTradeList) + if (selectedTrade.value != null) { + tradesServiceFacade.closeTrade() + } + navigateBack() } } diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeDetailsHeaderPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeDetailsHeaderPresenter.kt index 2403f1f5..d6eb9011 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeDetailsHeaderPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeDetailsHeaderPresenter.kt @@ -171,9 +171,9 @@ class TradeDetailsHeaderPresenter( } } - fun closeWorkflow() { - // doing a shark navigateBack causes white broken UI screen - navigateToTab(Routes.TabOpenTradeList) + private fun closeWorkflow() { +// Do not navigate, close button on the same screen does it +// navigateBack() } private fun reset() { diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeFlowPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeFlowPresenter.kt index 6300f1c5..53a903e4 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeFlowPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/open_trades/selected/TradeFlowPresenter.kt @@ -148,6 +148,7 @@ class TradeFlowPresenter( _tradePhaseState.value = TradePhaseState.INIT isSeller = false isMainChain = false + super.onViewUnattaching() } private fun tradeStateChanged(state: BisqEasyTradeStateEnum?) { From c4f537c4e44556e9f00f26154bf3dcb85c76d3e0 Mon Sep 17 00:00:00 2001 From: Rodrigo Varela Date: Fri, 17 Jan 2025 14:36:39 +1100 Subject: [PATCH 2/2] - reconnect services on reconnection for a resilient experience --- .../network/bisq/mobile/client/ClientMainPresenter.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt index a4602bfc..dc59abb1 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/client/ClientMainPresenter.kt @@ -1,5 +1,6 @@ package network.bisq.mobile.client +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import network.bisq.mobile.client.websocket.WebSocketClientProvider import network.bisq.mobile.domain.UrlLauncher @@ -41,13 +42,17 @@ class ClientMainPresenter( webSocketClientProvider.get().connected.collect { if (webSocketClientProvider.get().isConnected()) { log.d { "connectivity status changed to $it - reconnecting services" } - deactivateServices() - activateServices() + reactiveServices() } } } } + private fun reactiveServices() { + deactivateServices() + activateServices() + } + private fun activateServices() { runCatching { applicationBootstrapFacade.activate()