From b90092567cb02afdf2356edf4df8bfa8bd18799c Mon Sep 17 00:00:00 2001 From: HenrikJannsen Date: Fri, 29 Nov 2024 09:02:55 +0700 Subject: [PATCH] Add websocket support --- .../offer/NodeOfferbookListItemService.kt | 1 + gradle/libs.versions.toml | 29 ++- shared/domain/build.gradle.kts | 5 + .../bisq/mobile/client/di/ClientModule.kt | 82 ++++++-- .../market/ClientMarketPriceServiceFacade.kt | 62 +++--- .../client/market/MarketPriceApiGateway.kt | 21 +- .../mobile/client/market/PriceFormatter.kt | 17 ++ .../offerbook/ClientOfferbookServiceFacade.kt | 10 +- .../market/ClientMarketListItemService.kt | 69 +++---- .../offer/ClientOfferbookListItemService.kt | 94 +++++++-- .../offerbook/offer/OfferbookApiGateway.kt | 30 ++- .../common/currency/Market.kt | 2 +- .../user/reputation/ReputationScore.kt | 2 +- .../client/service/ApiRequestService.kt | 37 ---- .../bisq/mobile/client/service/Polling.kt | 46 ----- .../user_profile/UserProfileApiGateway.kt | 13 +- .../websocket/RequestResponseHandler.kt | 64 ++++++ .../client/websocket/WebSocketClient.kt | 194 ++++++++++++++++++ .../websocket/messages/SubscriptionRequest.kt | 14 ++ .../messages/SubscriptionResponse.kt | 11 + .../websocket/messages/WebSocketEvent.kt | 16 ++ .../websocket/messages/WebSocketMessage.kt | 7 + .../websocket/messages/WebSocketRequest.kt | 8 + .../websocket/messages/WebSocketResponse.kt | 8 + .../messages/WebSocketRestApiRequest.kt | 12 ++ .../messages/WebSocketRestApiResponse.kt | 11 + .../rest_api_proxy/WebSocketRestApiClient.kt | 83 ++++++++ .../subscription/ModificationType.kt | 10 + .../client/websocket/subscription/Topic.kt | 13 ++ .../subscription/WebSocketEventObserver.kt | 13 ++ .../subscription/WebSocketEventPayload.kt | 31 +++ .../domain/data/model/MarketListItem.kt | 2 +- .../domain/data/model/MarketPriceItem.kt | 29 ++- .../mobile/domain/data/model/OfferListItem.kt | 24 +-- .../kotlin/network/bisq/mobile/utils/UUID.kt | 9 + .../bisq/mobile/domain/di/ClientModule.ios.kt | 16 ++ .../bisq/mobile/domain/di/DomainModule.ios.kt | 12 -- .../bisq/mobile/client/ClientMainPresenter.kt | 13 ++ .../presentation/di/PresentationModule.kt | 16 +- .../ui/uicases/GettingStartedPresenter.kt | 39 ++-- .../ui/uicases/GettingStartedScreen.kt | 30 +-- .../di/DependenciesProviderHelper.kt | 4 +- .../presentation/di/PresentationModule.ios.kt | 7 - 43 files changed, 905 insertions(+), 311 deletions(-) create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/PriceFormatter.kt delete mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/ApiRequestService.kt delete mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/Polling.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/RequestResponseHandler.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClient.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionRequest.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionResponse.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketEvent.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketMessage.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRequest.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketResponse.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiRequest.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiResponse.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/rest_api_proxy/WebSocketRestApiClient.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/ModificationType.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/Topic.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventObserver.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventPayload.kt create mode 100644 shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/UUID.kt create mode 100644 shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/ClientModule.ios.kt delete mode 100644 shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt diff --git a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/offer/NodeOfferbookListItemService.kt b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/offer/NodeOfferbookListItemService.kt index a7dbdf45..04f59701 100644 --- a/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/offer/NodeOfferbookListItemService.kt +++ b/androidNode/src/androidMain/kotlin/network/bisq/mobile/android/node/domain/offerbook/offer/NodeOfferbookListItemService.kt @@ -182,6 +182,7 @@ class NodeOfferbookListItemService(private val applicationService: AndroidApplic offerId, isMyMessage, direction, + market.quoteCurrencyCode, offerTitle, date, formattedDate, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bee6b3ed..3480ee08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,14 +20,16 @@ androidx-test-espresso= "3.6.1" androidx-lifecycle = "2.8.2" androidx-test-compose-ver = "1.6.8" androidx-multidex = "2.0.1" +atomicfu = "0.26.1" bignum-lib= "0.3.10" bisq-core = "2.1.2" coilCompose = "3.0.3" compose-plugin = "1.7.0" junit = "4.13.2" +kotlinReflect = "2.0.21" kotlinxDatetime = "0.4.0" -kotlinxSerializationCore = "1.7.3" -ktorClientCio = "3.0.1" +kotlinxSerialization = "1.7.3" +ktorClient = "3.0.1" mockio = "1.12.0" kotlin = "2.0.20" kotlinTestJunit = "2.0.20" @@ -36,7 +38,7 @@ kermit = "2.0.4" buildconfig = "5.5.0" navigationCompose = "2.7.0-alpha07" okio = "3.9.1" -orgJetbrainsKotlinPluginSerializationGradlePlugin = "2.0.21" +serializationPlugin = "2.0.21" protobuf = "0.9.4" protob = "4.28.2" ksp = "2.0.20-1.0.25" @@ -72,19 +74,24 @@ typesafe-config-lib = { strictly = '1.4.3' } multiplatform-settings = "1.2.0" [libraries] +atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } +jetbrains-kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit-v180 = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinTestJunit" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } -kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } -ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClientCio" } -ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClientCio" } -ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCio" } -ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktorClientCio" } -ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktorClientCio" } -ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorClientCio" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClient" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClient" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } +ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktorClient" } +ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktorClient" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorClient" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktorClient" } + mock-io = { module = "io.mockk:mockk", version.ref = "mockio" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-multidex = { group = "androidx.multidex", name = "multidex", version.ref = "androidx-multidex" } @@ -113,7 +120,7 @@ logging-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit lombok = { module = 'org.projectlombok:lombok', version.ref = 'lombok-lib' } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } -jetbrains-serialization-gradle-plugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "orgJetbrainsKotlinPluginSerializationGradlePlugin" } +jetbrains-serialization-gradle-plugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "serializationPlugin" } typesafe-config = { module = 'com.typesafe:config', version.ref = 'typesafe-config-lib' } bouncycastle = { module = 'org.bouncycastle:bcprov-jdk18on', version.ref = 'bouncycastle-lib' } diff --git a/shared/domain/build.gradle.kts b/shared/domain/build.gradle.kts index f7ebdcf6..0dc23aff 100644 --- a/shared/domain/build.gradle.kts +++ b/shared/domain/build.gradle.kts @@ -51,9 +51,14 @@ kotlin { implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.jetbrains.serialization.gradle.plugin) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ktor.client.websockets) implementation(libs.multiplatform.settings) + implementation(libs.atomicfu) + implementation(libs.jetbrains.kotlin.reflect) + configurations.all { exclude(group = "org.slf4j", module = "slf4j-api") } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/di/ClientModule.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/di/ClientModule.kt index b84c1557..b85d4da7 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/di/ClientModule.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/di/ClientModule.kt @@ -3,16 +3,27 @@ package network.bisq.mobile.client.di import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.websocket.WebSockets import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual +import kotlinx.serialization.modules.polymorphic import network.bisq.mobile.android.node.main.bootstrap.ClientApplicationBootstrapFacade import network.bisq.mobile.client.market.ClientMarketPriceServiceFacade import network.bisq.mobile.client.market.MarketPriceApiGateway import network.bisq.mobile.client.offerbook.ClientOfferbookServiceFacade import network.bisq.mobile.client.offerbook.offer.OfferbookApiGateway -import network.bisq.mobile.client.service.ApiRequestService +import network.bisq.mobile.client.websocket.WebSocketClient +import network.bisq.mobile.client.websocket.rest_api_proxy.WebSocketRestApiClient +import network.bisq.mobile.client.websocket.messages.SubscriptionRequest +import network.bisq.mobile.client.websocket.messages.SubscriptionResponse +import network.bisq.mobile.client.websocket.messages.WebSocketEvent +import network.bisq.mobile.client.websocket.messages.WebSocketMessage +import network.bisq.mobile.client.websocket.messages.WebSocketRequest +import network.bisq.mobile.client.websocket.messages.WebSocketResponse +import network.bisq.mobile.client.websocket.messages.WebSocketRestApiRequest +import network.bisq.mobile.client.websocket.messages.WebSocketRestApiResponse import network.bisq.mobile.client.user_profile.ClientUserProfileServiceFacade import network.bisq.mobile.client.user_profile.UserProfileApiGateway import network.bisq.mobile.domain.data.repository.main.bootstrap.ApplicationBootstrapFacade @@ -23,39 +34,78 @@ import network.bisq.mobile.utils.ByteArrayAsBase64Serializer import org.koin.core.qualifier.named import org.koin.dsl.module - // networking and services dependencies val clientModule = module { + val json = Json { + serializersModule = SerializersModule { + contextual(ByteArrayAsBase64Serializer) + polymorphic(WebSocketMessage::class) { + subclass(WebSocketEvent::class, WebSocketEvent.serializer()) + polymorphic(WebSocketRequest::class) { + subclass(WebSocketRestApiRequest::class, WebSocketRestApiRequest.serializer()) + subclass(SubscriptionRequest::class, SubscriptionRequest.serializer()) + } + polymorphic(WebSocketResponse::class) { + subclass(WebSocketRestApiResponse::class, WebSocketRestApiResponse.serializer()) + subclass(SubscriptionResponse::class, SubscriptionResponse.serializer()) + } + } + } + classDiscriminator = "className" // Default is "type" but we prefer more specific + ignoreUnknownKeys = true + } + + single { json } single { HttpClient(CIO) { + install(WebSockets) install(ContentNegotiation) { - json(Json { - serializersModule = SerializersModule { - contextual(ByteArrayAsBase64Serializer) - } - ignoreUnknownKeys = true - }) + json(json) } } } - single { ClientApplicationBootstrapFacade() } - single(named("ApiBaseUrl")) { provideApiBaseUrl() } - single { ApiRequestService(get(), get(named("ApiBaseUrl"))) } + single(named("RestApiHost")) { provideRestApiHost() } + single(named("RestApiPort")) { 8090 } + single(named("WebsocketApiHost")) { provideWebsocketHost() } + single(named("WebsocketApiPort")) { 8090 } + + single { + WebSocketClient( + get(), + get(), + get(named("WebsocketApiHost")), + get(named("WebsocketApiPort")) + ) + } + // single { WebSocketHttpClient(get()) } + single { + WebSocketRestApiClient( + get(), + get(), + get(), + get(named("WebsocketApiHost")), + get(named("WebsocketApiPort")) + ) + } - single { MarketPriceApiGateway(get()) } - single { ClientMarketPriceServiceFacade(get()) } + single { MarketPriceApiGateway(get(), get()) } + single { ClientMarketPriceServiceFacade(get(), get()) } single { UserProfileApiGateway(get()) } single { ClientUserProfileServiceFacade(get(), get()) } - single { OfferbookApiGateway(get()) } - single { ClientOfferbookServiceFacade(get(), get()) } + single { OfferbookApiGateway(get(), get()) } + single { ClientOfferbookServiceFacade(get(), get(), get(), get()) } +} + +fun provideRestApiHost(): String { + return "10.0.2.2" // Default for Android emulator } -fun provideApiBaseUrl(): String { +fun provideWebsocketHost(): String { return "10.0.2.2" // Default for Android emulator } \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt index 529933bc..a400dd97 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/ClientMarketPriceServiceFacade.kt @@ -1,21 +1,24 @@ package network.bisq.mobile.client.market +import io.ktor.util.collections.ConcurrentMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import network.bisq.mobile.client.replicated_model.common.currency.Market -import network.bisq.mobile.client.service.Polling +import network.bisq.mobile.client.websocket.subscription.WebSocketEventPayload import network.bisq.mobile.domain.data.BackgroundDispatcher -import network.bisq.mobile.domain.data.model.MarketPriceItem import network.bisq.mobile.domain.data.model.MarketListItem +import network.bisq.mobile.domain.data.model.MarketPriceItem import network.bisq.mobile.domain.service.market_price.MarketPriceServiceFacade import network.bisq.mobile.utils.Logging class ClientMarketPriceServiceFacade( - private val apiGateway: MarketPriceApiGateway + private val apiGateway: MarketPriceApiGateway, + private val json: Json ) : MarketPriceServiceFacade, Logging { // Properties @@ -25,50 +28,45 @@ class ClientMarketPriceServiceFacade( // Misc private val coroutineScope = CoroutineScope(BackgroundDispatcher) private var job: Job? = null - private var polling = Polling(60000) { requestMarketPriceQuotes() } private var selectedMarket: Market = Market.USD // todo use persisted or user default - private val quotes: HashMap = HashMap() + private val quotes = ConcurrentMap() // Life cycle override fun activate() { - requestMarketPriceQuotes() - polling.start() + job = coroutineScope.launch { + val observer = apiGateway.subscribeMarketPrice() + observer?.webSocketEvent?.collect { webSocketEvent -> + if (webSocketEvent?.deferredPayload == null) { + return@collect + } + val webSocketEventPayload: WebSocketEventPayload> = + WebSocketEventPayload.from(json, webSocketEvent) + val marketPriceMap = webSocketEventPayload.payload + quotes.putAll(marketPriceMap) + upDateMarketPriceItem() + } + } } override fun deactivate() { cancelJob() - polling.stop() } // API override fun selectMarket(marketListItem: MarketListItem) { selectedMarket = marketListItem.market - _marketPriceItem.value = MarketPriceItem(marketListItem.market) - applyQuote() - } - - // Private - private fun requestMarketPriceQuotes() { - selectedMarket.let { - cancelJob() - job = coroutineScope.launch { - try { - val response: MarketPriceResponse = apiGateway.getQuotes() - quotes.putAll(response.quotes) - - applyQuote() - } catch (e: Exception) { - log.e("Error at API request", e) - } - } - } + upDateMarketPriceItem() } - private fun applyQuote() { - val code = _marketPriceItem.value.market.quoteCurrencyCode - quotes[code]?.let { - _marketPriceItem.value.setQuote(it) - log.i { "applyQuote: code=$code; quote =$it" } + private fun upDateMarketPriceItem() { + val quoteCurrencyCode: String = selectedMarket.quoteCurrencyCode + quotes[quoteCurrencyCode]?.let { quote -> + val marketPriceItem = MarketPriceItem(selectedMarket) + marketPriceItem.setQuote(quote) + val formattedPrice = formatMarketPrice(marketPriceItem.market, quote) + marketPriceItem.setFormattedPrice(formattedPrice) + _marketPriceItem.value = marketPriceItem + log.i { "upDateMarketPriceItem: code=$quoteCurrencyCode; quote =$quote; formattedPrice =$formattedPrice" } } } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/MarketPriceApiGateway.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/MarketPriceApiGateway.kt index 8bc49086..44e0f653 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/MarketPriceApiGateway.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/MarketPriceApiGateway.kt @@ -1,16 +1,27 @@ package network.bisq.mobile.client.market import kotlinx.serialization.Serializable -import network.bisq.mobile.client.service.ApiRequestService +import network.bisq.mobile.client.websocket.WebSocketClient +import network.bisq.mobile.client.websocket.rest_api_proxy.WebSocketRestApiClient +import network.bisq.mobile.client.websocket.subscription.WebSocketEventObserver +import network.bisq.mobile.client.websocket.subscription.Topic import network.bisq.mobile.utils.Logging -class MarketPriceApiGateway(private val apiRequestService: ApiRequestService) : Logging { +class MarketPriceApiGateway( + private val webSocketRestApiClient: WebSocketRestApiClient, + private val webSocketClient: WebSocketClient, +) : Logging { private val basePath = "market-price" suspend fun getQuotes(): MarketPriceResponse { - return apiRequestService.get("$basePath/quotes") + return webSocketRestApiClient.get("$basePath/quotes") } + + suspend fun subscribeMarketPrice(): WebSocketEventObserver? { + return webSocketClient.subscribe(Topic.MARKET_PRICE) + } + + @Serializable + data class MarketPriceResponse(val quotes: Map) } -@Serializable -class MarketPriceResponse(val quotes: Map) diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/PriceFormatter.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/PriceFormatter.kt new file mode 100644 index 00000000..2106b2d0 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/market/PriceFormatter.kt @@ -0,0 +1,17 @@ +package network.bisq.mobile.client.market + +import network.bisq.mobile.client.replicated_model.common.currency.Market +import kotlin.math.pow +import kotlin.math.round + +fun formatMarketPrice(market: Market, quote: Long): String { + val doubleValue: Double = quote.toDouble() / 10000 + val stringValue: String = doubleValue.roundTo(2).toString() + return stringValue + " " + market.marketCodes +} + +fun Double.roundTo(places: Int): Double { + require(places >= 0) { "Decimal places must be non-negative" } + val factor = 10.0.pow(places) + return round(this * factor) / factor +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/ClientOfferbookServiceFacade.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/ClientOfferbookServiceFacade.kt index eccfe1ad..3d83ded0 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/ClientOfferbookServiceFacade.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/ClientOfferbookServiceFacade.kt @@ -1,10 +1,12 @@ package network.bisq.mobile.client.offerbook import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.Json import network.bisq.mobile.client.offerbook.market.ClientMarketListItemService import network.bisq.mobile.client.offerbook.market.ClientSelectedOfferbookMarketService import network.bisq.mobile.client.offerbook.offer.ClientOfferbookListItemService import network.bisq.mobile.client.offerbook.offer.OfferbookApiGateway +import network.bisq.mobile.client.websocket.WebSocketClient import network.bisq.mobile.domain.data.model.MarketListItem import network.bisq.mobile.domain.data.model.OfferListItem import network.bisq.mobile.domain.data.model.OfferbookMarket @@ -14,7 +16,9 @@ import network.bisq.mobile.utils.Logging class ClientOfferbookServiceFacade( apiGateway: OfferbookApiGateway, - private val marketPriceServiceFacade: MarketPriceServiceFacade + webSocketClient: WebSocketClient, + private val marketPriceServiceFacade: MarketPriceServiceFacade, + private val json: Json ) : OfferbookServiceFacade, Logging { @@ -25,9 +29,9 @@ class ClientOfferbookServiceFacade( // Misc private val offerbookListItemService: ClientOfferbookListItemService = - ClientOfferbookListItemService(apiGateway) + ClientOfferbookListItemService(apiGateway, json) private val marketListItemService: ClientMarketListItemService = - ClientMarketListItemService(apiGateway) + ClientMarketListItemService(apiGateway, json) private val selectedOfferbookMarketService: ClientSelectedOfferbookMarketService = ClientSelectedOfferbookMarketService(marketPriceServiceFacade) diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt index f1d6c9b2..fe38c46e 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/market/ClientMarketListItemService.kt @@ -7,15 +7,18 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import network.bisq.mobile.client.offerbook.offer.OfferbookApiGateway import network.bisq.mobile.client.replicated_model.common.currency.Market -import network.bisq.mobile.client.service.Polling +import network.bisq.mobile.client.websocket.subscription.WebSocketEventPayload import network.bisq.mobile.domain.LifeCycleAware import network.bisq.mobile.domain.data.BackgroundDispatcher import network.bisq.mobile.domain.data.model.MarketListItem import network.bisq.mobile.utils.Logging -class ClientMarketListItemService(private val apiGateway: OfferbookApiGateway) : LifeCycleAware, - Logging { +class ClientMarketListItemService( + private val apiGateway: OfferbookApiGateway, + private val json: Json +) : LifeCycleAware, Logging { + // Properties private val _marketListItems: MutableList = mutableListOf() @@ -24,43 +27,36 @@ class ClientMarketListItemService(private val apiGateway: OfferbookApiGateway) : // Misc private val coroutineScope = CoroutineScope(BackgroundDispatcher) private var job: Job? = null - private var polling = Polling(1000) { updateNumOffers() } - private var marketListItemsRequested = false // Life cycle override fun activate() { - // As markets are rather static we apply the default markets immediately. - // Markets would only change if we get new markets added to the market price server, - // which happens rarely. - val defaultMarkets = Json.decodeFromString>(DEFAULT_MARKETS) - fillMarketListItems(defaultMarkets) - // NumOffers are at default value (0) + cancelJob() + job = coroutineScope.launch { + val markets = apiGateway.getMarkets() + fillMarketListItems(markets) + + val observer = apiGateway.subscribeNumOffers() + observer?.webSocketEvent?.collect { webSocketEvent -> + if (webSocketEvent?.deferredPayload == null) { + return@collect + } - if (marketListItemsRequested) { - job = coroutineScope.launch { - try { - // TODO we might combine that api call to avoid 2 separate calls. - val markets = apiGateway.getMarkets() - fillMarketListItems(markets) - requestAndApplyNumOffers() - marketListItemsRequested = true - } catch (e: Exception) { - log.e("Error at API request", e) + val webSocketEventPayload: WebSocketEventPayload> = + WebSocketEventPayload.from(json, webSocketEvent) + val numOffersByMarketCode = webSocketEventPayload.payload + marketListItems.map { marketListItem -> + val numOffers = + numOffersByMarketCode[marketListItem.market.quoteCurrencyCode] ?: 0 + marketListItem.setNumOffers(numOffers) + marketListItem } } } - polling.start() } override fun deactivate() { cancelJob() - polling.stop() - } - - // Private - private fun updateNumOffers() { - cancelJob() - job = coroutineScope.launch { requestAndApplyNumOffers() } + _marketListItems.clear() } private fun fillMarketListItems(markets: List) { @@ -71,26 +67,11 @@ class ClientMarketListItemService(private val apiGateway: OfferbookApiGateway) : marketDto.baseCurrencyName, marketDto.quoteCurrencyName, ) - MarketListItem(market) } _marketListItems.addAll(list) } - private suspend fun requestAndApplyNumOffers() { - try { - val numOffersByMarketCode = apiGateway.getNumOffersByMarketCode() - marketListItems.map { marketListItem -> - val numOffers = - numOffersByMarketCode[marketListItem.market.quoteCurrencyCode] ?: 0 - marketListItem.setNumOffers(numOffers) - marketListItem - } - } catch (e: Exception) { - log.e("Error at API request", e) - } - } - private fun cancelJob() { try { job?.cancel() diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt index c6a0aa8a..307bfebd 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/ClientOfferbookListItemService.kt @@ -1,20 +1,25 @@ package network.bisq.mobile.client.offerbook.offer +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import network.bisq.mobile.client.service.Polling +import kotlinx.serialization.json.Json +import network.bisq.mobile.client.websocket.subscription.ModificationType +import network.bisq.mobile.client.websocket.subscription.WebSocketEventPayload import network.bisq.mobile.domain.LifeCycleAware import network.bisq.mobile.domain.data.BackgroundDispatcher -import network.bisq.mobile.domain.data.model.OfferListItem import network.bisq.mobile.domain.data.model.MarketListItem +import network.bisq.mobile.domain.data.model.OfferListItem import network.bisq.mobile.utils.Logging - -class ClientOfferbookListItemService(private val apiGateway: OfferbookApiGateway) : +class ClientOfferbookListItemService( + private val apiGateway: OfferbookApiGateway, + private val json: Json +) : LifeCycleAware, Logging { @@ -24,42 +29,87 @@ class ClientOfferbookListItemService(private val apiGateway: OfferbookApiGateway // Misc private var job: Job? = null - private var polling = Polling(1000) { updateOffers() } private var selectedMarket: MarketListItem? = null private val coroutineScope = CoroutineScope(BackgroundDispatcher) + private var sequenceNumber = atomic(-1) + private var offerListItemsByMarket: MutableMap> = + mutableMapOf() // Life cycle override fun activate() { - polling.start() + } + + private fun subscribe() { + if (job == null) { + job = coroutineScope.launch { + sequenceNumber = atomic(-1) + // We subscribe for all markets + val observer = apiGateway.subscribeOffers() + observer?.webSocketEvent?.collect { webSocketEvent -> + if (webSocketEvent?.deferredPayload == null) { + return@collect + } + if (sequenceNumber.value >= webSocketEvent.sequenceNumber) { + log.w { + "Sequence number is larger or equal than the one we " + + "received from the backend. We ignore that event." + } + return@collect + } + + sequenceNumber.value = webSocketEvent.sequenceNumber + val webSocketEventPayload: WebSocketEventPayload> = + WebSocketEventPayload.from(json, webSocketEvent) + val payload: List = webSocketEventPayload.payload + if (webSocketEvent.modificationType == ModificationType.REPLACE || + webSocketEvent.modificationType == ModificationType.ADDED + ) { + payload.forEach { item -> + offerListItemsByMarket.getOrPut(item.quoteCurrencyCode) { mutableSetOf() } + .add(item) + } + } else if (webSocketEvent.modificationType == ModificationType.REMOVED) { + payload.forEach { item -> + offerListItemsByMarket[item.quoteCurrencyCode]?.let { set -> + set.remove(item) + if (set.isEmpty()) { + offerListItemsByMarket.remove(item.quoteCurrencyCode) + } + } + } + } + applyOffersToSelectedMarket() + } + } + } } override fun deactivate() { cancelJob() - polling.stop() } // API fun selectMarket(marketListItem: MarketListItem) { selectedMarket = marketListItem - updateOffers() - } + if (selectedMarket == null) { + return + } - private fun updateOffers() { - if (selectedMarket != null) { - cancelJob() - job = coroutineScope.launch { - try { - if (selectedMarket != null) { - _offerListItems.value = - apiGateway.getOffers(selectedMarket!!.market.quoteCurrencyCode) - } - } catch (e: Exception) { - log.e("Error at getOffers API request", e) - } - } + if (job == null) { + subscribe() + } else { + applyOffersToSelectedMarket() } } + private fun applyOffersToSelectedMarket() { + _offerListItems.value = + offerListItemsByMarket[getMarketCode()]?.toList() + ?: emptyList() + } + + private fun getMarketCode() = selectedMarket!!.market.quoteCurrencyCode + private fun cancelJob() { try { job?.cancel() diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/OfferbookApiGateway.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/OfferbookApiGateway.kt index f1f1e53e..6ed9677e 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/OfferbookApiGateway.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/offerbook/offer/OfferbookApiGateway.kt @@ -1,23 +1,43 @@ package network.bisq.mobile.client.offerbook.offer import network.bisq.mobile.client.replicated_model.common.currency.Market -import network.bisq.mobile.client.service.ApiRequestService +import network.bisq.mobile.client.websocket.WebSocketClient +import network.bisq.mobile.client.websocket.rest_api_proxy.WebSocketRestApiClient +import network.bisq.mobile.client.websocket.subscription.WebSocketEventObserver +import network.bisq.mobile.client.websocket.subscription.Topic import network.bisq.mobile.domain.data.model.OfferListItem import network.bisq.mobile.utils.Logging -class OfferbookApiGateway(private val apiRequestService: ApiRequestService) : Logging { +class OfferbookApiGateway( + private val webSocketRestApiClient: WebSocketRestApiClient, + private val webSocketClient: WebSocketClient, +) : Logging { private val basePath = "offerbook" + // Requests suspend fun getMarkets(): List { - return apiRequestService.get("$basePath/markets") + return webSocketRestApiClient.get("$basePath/markets") } suspend fun getNumOffersByMarketCode(): Map { - return apiRequestService.get("$basePath/markets/offers/count") + return webSocketRestApiClient.get("$basePath/markets/offers/count") } suspend fun getOffers(code: String): List { - return apiRequestService.get("$basePath/markets/$code/offers") + return webSocketRestApiClient.get("$basePath/markets/$code/offers") + } + + // Subscriptions + suspend fun subscribeNumOffers(): WebSocketEventObserver? { + return webSocketClient.subscribe(Topic.NUM_OFFERS) + } + + /** + * @param code The quote currency code for which we want to receive updates. + * If null or empty string we receive for all markets the offer updates. + */ + suspend fun subscribeOffers(code: String? = null): WebSocketEventObserver? { + return webSocketClient.subscribe(Topic.OFFERS, code) } } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/common/currency/Market.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/common/currency/Market.kt index 2e174df6..bc6e9260 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/common/currency/Market.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/common/currency/Market.kt @@ -19,7 +19,7 @@ package network.bisq.mobile.client.replicated_model.common.currency import kotlinx.serialization.Serializable @Serializable -class Market( +data class Market( val baseCurrencyCode: String, val quoteCurrencyCode: String, val baseCurrencyName: String, diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/reputation/ReputationScore.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/reputation/ReputationScore.kt index 6100b35a..071b38f7 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/reputation/ReputationScore.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/replicated_model/user/reputation/ReputationScore.kt @@ -19,7 +19,7 @@ package network.bisq.mobile.client.replicated_model.user.reputation import kotlinx.serialization.Serializable @Serializable -class ReputationScore( +data class ReputationScore( val totalScore: Long, val fiveSystemScore: Double, val ranking: Int diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/ApiRequestService.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/ApiRequestService.kt deleted file mode 100644 index 68e4e51b..00000000 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/ApiRequestService.kt +++ /dev/null @@ -1,37 +0,0 @@ -package network.bisq.mobile.client.service - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.contentType -import network.bisq.mobile.utils.Logging - -class ApiRequestService(val httpClient: HttpClient, host: String): Logging { - private var baseUrl = "http://$host:8082/api/v1/" - - init{ - log.i { "API base URL = $baseUrl" } - } - - fun endpoint(path: String) = baseUrl + path - - suspend inline fun get(path: String): T { - return httpClient.get(endpoint(path)).body() - } - - suspend inline fun get(path: String, paramName: String, paramValue: String): T { - return httpClient.get(endpoint(path)) { - parameter(paramName, paramValue) - }.body() - } - - suspend inline fun post(path: String, requestBody: Any): T { - return httpClient.post(endpoint(path)) { - contentType(io.ktor.http.ContentType.Application.Json) - setBody(requestBody) - }.body() - } -} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/Polling.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/Polling.kt deleted file mode 100644 index c14a0ec9..00000000 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/service/Polling.kt +++ /dev/null @@ -1,46 +0,0 @@ -package network.bisq.mobile.client.service - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import network.bisq.mobile.domain.data.BackgroundDispatcher -import network.bisq.mobile.utils.Logging - -class Polling(private val intervalMillis: Long, private val task: () -> Unit) : Logging { - private var job: Job? = null - private var isRunning = false - private val coroutineScope = CoroutineScope(BackgroundDispatcher) - - fun start() { - if (!isRunning) { - isRunning = true - job = coroutineScope.launch { - while (isRunning) { - task() - delay(intervalMillis) - } - } - } - } - - fun stop() { - isRunning = false - cancelJob() - } - - fun restart() { - stop() - start() - } - - private fun cancelJob() { - try { - job?.cancel() - job = null - } catch (e: CancellationException) { - log.e("Job cancel failed", e) - } - } -} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/UserProfileApiGateway.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/UserProfileApiGateway.kt index a2272da5..c55570db 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/UserProfileApiGateway.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/user_profile/UserProfileApiGateway.kt @@ -2,15 +2,14 @@ package network.bisq.mobile.client.user_profile import network.bisq.mobile.client.replicated_model.user.identity.PreparedData import network.bisq.mobile.client.replicated_model.user.profile.UserProfile -import network.bisq.mobile.client.service.ApiRequestService +import network.bisq.mobile.client.websocket.rest_api_proxy.WebSocketRestApiClient class UserProfileApiGateway( - private val apiRequestService: ApiRequestService + private val webSocketRestApiClient: WebSocketRestApiClient ) { private val basePath = "user-identities" suspend fun requestPreparedData(): PreparedData { - - return apiRequestService.get("$basePath/prepared-data") + return webSocketRestApiClient.get("$basePath/prepared-data") } suspend fun createAndPublishNewUserProfile( @@ -23,14 +22,14 @@ class UserProfileApiGateway( "", preparedData ) - return apiRequestService.post(basePath, createUserIdentityRequest) + return webSocketRestApiClient.post(basePath, createUserIdentityRequest) } suspend fun getUserIdentityIds(): List { - return apiRequestService.get("$basePath/ids") + return webSocketRestApiClient.get("$basePath/ids") } suspend fun getSelectedUserProfile(): UserProfile { - return apiRequestService.get("$basePath/selected/user-profile") + return webSocketRestApiClient.get("$basePath/selected/user-profile") } } \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/RequestResponseHandler.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/RequestResponseHandler.kt new file mode 100644 index 00000000..0823591e --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/RequestResponseHandler.kt @@ -0,0 +1,64 @@ +package network.bisq.mobile.client.websocket + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import network.bisq.mobile.client.websocket.messages.WebSocketMessage +import network.bisq.mobile.client.websocket.messages.WebSocketRequest +import network.bisq.mobile.client.websocket.messages.WebSocketResponse +import network.bisq.mobile.utils.Logging + +/** + * Handles request-response communication over a WebSocket connection. + * + * This class is designed to manage the lifecycle of a WebSocket request and its corresponding response + * using a unique request ID for correlation. It provides thread-safe mechanisms to send a request, + * await its response, and handle WebSocket responses asynchronously. The class also supports cleanup + * of ongoing requests if necessary. It is designed to be used only once per request ID. + * + * @param sendFunction A suspending function used to send WebSocket messages. + * It takes a [WebSocketMessage] as input and sends it over the WebSocket connection. + */ +class RequestResponseHandler(private val sendFunction: suspend (WebSocketMessage) -> Unit) : + Logging { + private var requestId: String? = null + private var deferredWebSocketResponse: CompletableDeferred? = null + private val mutex = Mutex() + + suspend fun request( + webSocketRequest: WebSocketRequest, + timeoutMillis: Long = 10_000 + ): WebSocketResponse? { + require(requestId == null) { "RequestResponseHandler is designed to be used only once per request ID" } + log.i { "Sending request with ID: ${webSocketRequest.requestId}" } + requestId = webSocketRequest.requestId + mutex.withLock { deferredWebSocketResponse = CompletableDeferred() } + + try { + sendFunction.invoke(webSocketRequest) + } catch (e: Exception) { + mutex.withLock { deferredWebSocketResponse?.completeExceptionally(e) } + throw e + } + + return withTimeout(timeoutMillis) { + deferredWebSocketResponse?.await() + } + } + + suspend fun onWebSocketResponse(webSocketResponse: WebSocketResponse) { + require(webSocketResponse.requestId == requestId) { "Request ID of response does not match our request ID" } + log.i { "Received response for request ID: ${webSocketResponse.requestId}" } + mutex.withLock { deferredWebSocketResponse?.complete(webSocketResponse) } + } + + suspend fun dispose() { + log.i { "Disposing request handler for ID: $requestId" } + mutex.withLock { + deferredWebSocketResponse?.cancel() + deferredWebSocketResponse = null + } + requestId = null + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..0b60dd46 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/WebSocketClient.kt @@ -0,0 +1,194 @@ +package network.bisq.mobile.client.websocket + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.url +import io.ktor.util.collections.ConcurrentMap +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonObject +import network.bisq.mobile.client.websocket.messages.SubscriptionRequest +import network.bisq.mobile.client.websocket.messages.SubscriptionResponse +import network.bisq.mobile.client.websocket.messages.WebSocketEvent +import network.bisq.mobile.client.websocket.messages.WebSocketMessage +import network.bisq.mobile.client.websocket.messages.WebSocketRequest +import network.bisq.mobile.client.websocket.messages.WebSocketResponse +import network.bisq.mobile.client.websocket.subscription.ModificationType +import network.bisq.mobile.client.websocket.subscription.Topic +import network.bisq.mobile.client.websocket.subscription.WebSocketEventObserver +import network.bisq.mobile.domain.data.BackgroundDispatcher +import network.bisq.mobile.utils.Logging +import network.bisq.mobile.utils.createUuid + +class WebSocketClient( + private val httpClient: HttpClient, + val json: Json, + host: String, + port: Int +) : Logging { + + private val webSocketUrl: String = "ws://$host:$port/websocket" + private var session: DefaultClientWebSocketSession? = null + private var isConnected = false + private val webSocketEventObservers = ConcurrentMap() + private val requestResponseHandlers = mutableMapOf() + private var connectionReady = CompletableDeferred() + private val requestResponseHandlersMutex = Mutex() + + suspend fun connect() { + log.i("Connecting to websocket at: $webSocketUrl") + if (!isConnected) { + try { + session = httpClient.webSocketSession { url(webSocketUrl) } + isConnected = true + CoroutineScope(BackgroundDispatcher).launch { startListening() } + connectionReady.complete(true) + } catch (e: Exception) { + log.e("Connecting websocket failed", e) + } + } + } + + suspend fun disconnect() { + try { + requestResponseHandlersMutex.withLock { + requestResponseHandlers.values.forEach { it.dispose() } + requestResponseHandlers.clear() + } + + session?.close() + session = null + isConnected = false + } catch (e: Exception) { + log.e("Disconnecting websocket failed", e) + } + } + + // Blocking request until we get the associated response + suspend fun sendRequestAndAwaitResponse(webSocketRequest: WebSocketRequest): WebSocketResponse? { + connectionReady.await() + + val requestId = webSocketRequest.requestId + val requestResponseHandler = RequestResponseHandler(this::send) + requestResponseHandlersMutex.withLock { + requestResponseHandlers[requestId] = requestResponseHandler + } + + try { + return requestResponseHandler.request(webSocketRequest) + } finally { + requestResponseHandlersMutex.withLock { + requestResponseHandlers.remove(requestId) + } + } + } + + + suspend fun subscribe(topic: Topic, parameter: String? = null): WebSocketEventObserver? { + val subscriberId = createUuid() + log.i { "Subscribe for topic $topic and subscriberId $subscriberId" } + val responseClassName = SubscriptionResponse::class.qualifiedName!! + val webSocketEventClassName = WebSocketEvent::class.qualifiedName!! + val subscriptionRequest = SubscriptionRequest( + responseClassName, + webSocketEventClassName, + subscriberId, + topic, + parameter + ) + val response: WebSocketResponse? = sendRequestAndAwaitResponse(subscriptionRequest) + if (response is SubscriptionResponse) { + log.i { + "Received SubscriptionResponse for topic $topic and subscriberId $subscriberId.\n" + + "SubscriptionResponse=$response" + } + val webSocketEventObserver = WebSocketEventObserver() + webSocketEventObservers[subscriberId] = webSocketEventObserver + val webSocketEvent = WebSocketEvent( + topic, + subscriberId, + response.payload, + ModificationType.REPLACE, + 0 + ) + webSocketEventObserver.setEvent(webSocketEvent) + return webSocketEventObserver + } else { + log.e { "Response not of expected type. response=$response" } + return null + } + } + + suspend fun unSubscribe(topic: Topic, requestId: String) { + //todo + } + + private suspend fun send(message: WebSocketMessage) { + connectionReady.await() + try { + log.i { "Send message $message" } + val jsonString: String = json.encodeToString(message) + log.i { "Send raw text $jsonString" } + if (session != null) { + session!!.send(Frame.Text(jsonString)) + } + } catch (e: Exception) { + log.e("Disconnecting websocket failed", e) + } + } + + private suspend fun startListening() { + session?.let { session -> + try { + for (frame in session.incoming) { + if (frame is Frame.Text) { + val message = frame.readText() + //todo add input validation + log.i { "Received raw text $message" } + val webSocketMessage: WebSocketMessage = + json.decodeFromString(ServerEventSerializer, message) + log.i { "Received webSocketMessage $webSocketMessage" } + if (webSocketMessage is WebSocketResponse) { + onWebSocketResponse(webSocketMessage) + } else if (webSocketMessage is WebSocketEvent) { + onWebSocketEvent(webSocketMessage) + } + } + } + } catch (e: Exception) { + log.e("Handling incoming message failed", e) + } + } + } + + private suspend fun onWebSocketResponse(response: WebSocketResponse) { + requestResponseHandlers[response.requestId]?.onWebSocketResponse(response) + } + + private fun onWebSocketEvent(event: WebSocketEvent) { + // We have the payload not serialized yet as we would not know the expected type. + // We delegate that at the caller who is aware of the expected type + webSocketEventObservers[event.subscriberId]?.setEvent(event) + } + + object ServerEventSerializer : + JsonTransformingSerializer(WebSocketMessage.serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement { + // Ignore deferredPayload at deserialization as we do not know that type at that + // moment + return JsonObject(element.jsonObject.filterKeys { it != "deferredPayload" }) + } + } +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionRequest.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionRequest.kt new file mode 100644 index 00000000..8c523d2b --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionRequest.kt @@ -0,0 +1,14 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable +import network.bisq.mobile.client.websocket.subscription.Topic + +@Serializable +data class SubscriptionRequest( + val responseClassName: String, + val webSocketEventClassName: String, + override val requestId: String, + val topic: Topic, + val parameter: String? = null +) : WebSocketRequest + diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionResponse.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionResponse.kt new file mode 100644 index 00000000..9f959e1e --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/SubscriptionResponse.kt @@ -0,0 +1,11 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable + +@Serializable +data class SubscriptionResponse( + override val requestId: String, + val payload: String? = null, + val errorMessage: String? = null +) : WebSocketResponse + diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketEvent.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketEvent.kt new file mode 100644 index 00000000..b88b1e00 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketEvent.kt @@ -0,0 +1,16 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import network.bisq.mobile.client.websocket.subscription.ModificationType +import network.bisq.mobile.client.websocket.subscription.Topic + +@Serializable +data class WebSocketEvent( + val topic: Topic, + val subscriberId: String, + @SerialName("payload") + val deferredPayload: String? = null, + val modificationType: ModificationType, + val sequenceNumber: Int +) : WebSocketMessage diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketMessage.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketMessage.kt new file mode 100644 index 00000000..deb65ace --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketMessage.kt @@ -0,0 +1,7 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface WebSocketMessage { +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRequest.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRequest.kt new file mode 100644 index 00000000..a8f0d0a4 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRequest.kt @@ -0,0 +1,8 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface WebSocketRequest : WebSocketMessage { + val requestId: String +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketResponse.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketResponse.kt new file mode 100644 index 00000000..a931c65f --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketResponse.kt @@ -0,0 +1,8 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface WebSocketResponse : WebSocketMessage { + val requestId: String +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiRequest.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiRequest.kt new file mode 100644 index 00000000..355ef9f2 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiRequest.kt @@ -0,0 +1,12 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketRestApiRequest( + val responseClassName: String, + override val requestId: String, + val method: String, + val path: String, + val body: String, +) : WebSocketRequest diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiResponse.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiResponse.kt new file mode 100644 index 00000000..9c6596b8 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/messages/WebSocketRestApiResponse.kt @@ -0,0 +1,11 @@ +package network.bisq.mobile.client.websocket.messages + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketRestApiResponse( + override val requestId: String, + val statusCode: Int, + val body: String +) : WebSocketResponse + diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/rest_api_proxy/WebSocketRestApiClient.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/rest_api_proxy/WebSocketRestApiClient.kt new file mode 100644 index 00000000..bdad3998 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/rest_api_proxy/WebSocketRestApiClient.kt @@ -0,0 +1,83 @@ +package network.bisq.mobile.client.websocket.rest_api_proxy + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import network.bisq.mobile.client.websocket.WebSocketClient +import network.bisq.mobile.client.websocket.messages.WebSocketRestApiRequest +import network.bisq.mobile.client.websocket.messages.WebSocketRestApiResponse +import network.bisq.mobile.utils.Logging +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class WebSocketRestApiClient( + val httpClient: HttpClient, + val webSocketClient: WebSocketClient, + val json: Json, + host: String, + port: Int +) : Logging { + val apiPath = "/api/v1/" + var restApiUrl = "http://$host:$port$apiPath" + + // POST request still not working, but issue is likely on the bisq2 side. + // So we use httpClient instead. + val useHttpClientForPost = true + + suspend inline fun get(path: String): T { + return request("GET", path) + } + + suspend inline fun post(path: String, requestBody: R): T { + if (useHttpClientForPost) { + val body = httpClient.post(restApiUrl + path) { + contentType(ContentType.Application.Json) + setBody(requestBody) + }.body() + return body + + } else { + try { + val bodyAsJson = json.encodeToString(requestBody) + val request = request("POST", path, bodyAsJson) + return request + } catch (e: Exception) { + log.e("Error at execute", e) + throw e + } + } + } + + @OptIn(ExperimentalUuidApi::class) + suspend inline fun request( + method: String, + path: String, + bodyAsJson: String = "", + ): T { + try { + val requestId = Uuid.random().toString() + val fullPath = apiPath + path + val responseClassName = WebSocketRestApiResponse::class.qualifiedName!! + val webSocketRestApiRequest = WebSocketRestApiRequest( + responseClassName, + requestId, + method, + fullPath, + bodyAsJson + ) + val response = webSocketClient.sendRequestAndAwaitResponse(webSocketRestApiRequest) + require(response is WebSocketRestApiResponse) { "Response not of expected type. response=$response" } + val body = response.body + val decodeFromString = json.decodeFromString(body) + return decodeFromString + } catch (e: Exception) { + log.e("Error at request", e) + throw e + } + } +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/ModificationType.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/ModificationType.kt new file mode 100644 index 00000000..1b5f6db1 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/ModificationType.kt @@ -0,0 +1,10 @@ +package network.bisq.mobile.client.websocket.subscription + +import kotlinx.serialization.Serializable + +@Serializable +enum class ModificationType{ + REPLACE, // For data which replace existing data + ADDED, // List of added items + REMOVED // List of removed items +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/Topic.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/Topic.kt new file mode 100644 index 00000000..16a6d249 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/Topic.kt @@ -0,0 +1,13 @@ +package network.bisq.mobile.client.websocket.subscription + +import kotlinx.serialization.Serializable +import network.bisq.mobile.domain.data.model.OfferListItem +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +@Serializable +enum class Topic(val typeOf: KType) { + MARKET_PRICE(typeOf>()), + NUM_OFFERS(typeOf>()), + OFFERS(typeOf>()) +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventObserver.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventObserver.kt new file mode 100644 index 00000000..4c4af430 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventObserver.kt @@ -0,0 +1,13 @@ +package network.bisq.mobile.client.websocket.subscription + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import network.bisq.mobile.client.websocket.messages.WebSocketEvent + +class WebSocketEventObserver { + private val _webSocketEvent = MutableStateFlow(null) + val webSocketEvent: StateFlow = _webSocketEvent + fun setEvent(value: WebSocketEvent) { + _webSocketEvent.value = value + } +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventPayload.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventPayload.kt new file mode 100644 index 00000000..ac279138 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/client/websocket/subscription/WebSocketEventPayload.kt @@ -0,0 +1,31 @@ +package network.bisq.mobile.client.websocket.subscription + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import network.bisq.mobile.client.websocket.messages.WebSocketEvent +import network.bisq.mobile.utils.getLogger + +data class WebSocketEventPayload(val payload: T) { + companion object { + inline fun from( + json: Json, + webSocketEvent: WebSocketEvent + ): WebSocketEventPayload { + val topic = webSocketEvent.topic + val deferredPayload = webSocketEvent.deferredPayload!! + try { + @Suppress("UNCHECKED_CAST") + val serializer: KSerializer = serializer(topic.typeOf) as KSerializer + val payload: T = json.decodeFromString(serializer, deferredPayload) + return WebSocketEventPayload(payload) + } catch (e: Exception) { + getLogger(WebSocketEventPayload::class.simpleName!!).e( + "Deserializing payloadJson failed. topic=$topic; payloadJson=$deferredPayload", + e + ) + throw e + } + } + } +} \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketListItem.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketListItem.kt index 37243fac..8e634787 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketListItem.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketListItem.kt @@ -25,7 +25,7 @@ import network.bisq.mobile.client.replicated_model.common.currency.Market * Provides data for offerbook market list items */ @Serializable -class MarketListItem(val market: Market) : BaseModel() { +data class MarketListItem(val market: Market) : BaseModel() { private val _numOffers = MutableStateFlow(0) val numOffers: StateFlow get() = _numOffers fun setNumOffers(value: Int) { diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketPriceItem.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketPriceItem.kt index 29c207db..9cf78b14 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketPriceItem.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/MarketPriceItem.kt @@ -7,7 +7,7 @@ import network.bisq.mobile.client.replicated_model.common.currency.Market /** * Provides market price data */ -data class MarketPriceItem(val market: Market) : BaseModel() { +class MarketPriceItem(val market: Market) : BaseModel() { private val _quote = MutableStateFlow(0L) val quote: StateFlow get() = _quote fun setQuote(value: Long) { @@ -20,6 +20,33 @@ data class MarketPriceItem(val market: Market) : BaseModel() { _formattedPrice.value = value } + override fun toString(): String { + return "MarketPriceItem(formattedPrice=${_formattedPrice.value}, " + + "quote=${_quote.value}, market=$market)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + if (!super.equals(other)) return false + + other as MarketPriceItem + + if (market != other.market) return false + if (_quote != other._quote) return false + if (_formattedPrice != other._formattedPrice) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + market.hashCode() + result = 31 * result + _quote.hashCode() + result = 31 * result + _formattedPrice.hashCode() + return result + } + companion object { val EMPTY: MarketPriceItem = MarketPriceItem(Market.EMPTY) } diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/OfferListItem.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/OfferListItem.kt index 630788ae..3bb1082c 100644 --- a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/OfferListItem.kt +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/domain/data/model/OfferListItem.kt @@ -3,7 +3,6 @@ package network.bisq.mobile.domain.data.model import kotlinx.serialization.Serializable import network.bisq.mobile.client.replicated_model.offer.Direction import network.bisq.mobile.client.replicated_model.user.reputation.ReputationScore -import network.bisq.mobile.domain.data.model.BaseModel /** * For displaying offer data in the offerbook list @@ -14,6 +13,7 @@ data class OfferListItem( val offerId: String, val isMyMessage: Boolean, val direction: Direction, + val quoteCurrencyCode: String, val offerTitle: String, val date: Long, val formattedDate: String, @@ -25,24 +25,4 @@ data class OfferListItem( val quoteSidePaymentMethods: List, val baseSidePaymentMethods: List, val supportedLanguageCodes: String -) : BaseModel() { - override fun toString(): String { - return "OfferItem(\n" + - "MessageId ID='${messageId}'\n" + - "Offer ID='${offerId}'\n" + - "offerTitle='${offerTitle}'\n" + - "isMyMessage='${isMyMessage}'\n" + - "direction='${direction}'\n" + - "date='$date'\n" + - "formattedDate='$formattedDate'\n" + - "nym='$nym'\n" + - "userName='$userName'\n" + - "reputationScore=$reputationScore\n" + - "formattedQuoteAmount='$formattedQuoteAmount'\n" + - "formattedPrice='$formattedPrice'\n" + - "quoteSidePaymentMethods=$quoteSidePaymentMethods\n" + - "baseSidePaymentMethods=$baseSidePaymentMethods\n" + - "supportedLanguageCodes='$supportedLanguageCodes'\n" + - ")" - } -} \ No newline at end of file +) : BaseModel() \ No newline at end of file diff --git a/shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/UUID.kt b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/UUID.kt new file mode 100644 index 00000000..ffd38140 --- /dev/null +++ b/shared/domain/src/commonMain/kotlin/network/bisq/mobile/utils/UUID.kt @@ -0,0 +1,9 @@ +package network.bisq.mobile.utils + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +fun createUuid(): String { + return Uuid.random().toString() +} \ No newline at end of file diff --git a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/ClientModule.ios.kt b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/ClientModule.ios.kt new file mode 100644 index 00000000..345a05c3 --- /dev/null +++ b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/ClientModule.ios.kt @@ -0,0 +1,16 @@ +package network.bisq.mobile.domain.di + +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val iosClientModule = module { + single(named("RestApiHost")) { provideRestApiHost() } + single(named("WebsocketApiHost")) { provideWebsocketHost() } +} + +fun provideRestApiHost(): String { + return "localhost" +} +fun provideWebsocketHost(): String { + return "localhost" // Default for Android emulator +} \ No newline at end of file diff --git a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt b/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt deleted file mode 100644 index bf9d5b98..00000000 --- a/shared/domain/src/iosMain/kotlin/network/bisq/mobile/domain/di/DomainModule.ios.kt +++ /dev/null @@ -1,12 +0,0 @@ -package network.bisq.mobile.domain.di - -import org.koin.core.qualifier.named -import org.koin.dsl.module - -val iosDomainModule = module { - single(named("ApiBaseUrl")) { provideApiBaseUrl() } -} - -fun provideApiBaseUrl(): String { - return "localhost" -} \ 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 897a443c..e9ccb65d 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,9 @@ package network.bisq.mobile.client +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.bisq.mobile.client.websocket.WebSocketClient +import network.bisq.mobile.domain.data.BackgroundDispatcher import network.bisq.mobile.domain.data.repository.main.bootstrap.ApplicationBootstrapFacade import network.bisq.mobile.domain.service.market_price.MarketPriceServiceFacade import network.bisq.mobile.domain.service.offerbook.OfferbookServiceFacade @@ -7,18 +11,27 @@ import network.bisq.mobile.presentation.MainPresenter class ClientMainPresenter( private val applicationBootstrapFacade: ApplicationBootstrapFacade, + private val webSocketClient: WebSocketClient, private val offerbookServiceFacade: OfferbookServiceFacade, private val marketPriceServiceFacade: MarketPriceServiceFacade ) : MainPresenter() { + private val coroutineScope = CoroutineScope(BackgroundDispatcher) override fun onViewAttached() { super.onViewAttached() + + coroutineScope.launch { webSocketClient.connect() } + applicationBootstrapFacade.activate() offerbookServiceFacade.activate() marketPriceServiceFacade.activate() } 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() } + applicationBootstrapFacade.deactivate() offerbookServiceFacade.deactivate() marketPriceServiceFacade.deactivate() 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 66662bd6..71b34946 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 @@ -8,11 +8,15 @@ import network.bisq.mobile.presentation.ui.AppPresenter import network.bisq.mobile.presentation.ui.components.molecules.ITopBarPresenter import network.bisq.mobile.presentation.ui.components.molecules.TopBarPresenter import network.bisq.mobile.presentation.ui.uicases.GettingStartedPresenter -import network.bisq.mobile.presentation.ui.uicases.IGettingStarted import network.bisq.mobile.presentation.ui.uicases.offers.IOffersListPresenter import network.bisq.mobile.presentation.ui.uicases.offers.MarketListPresenter import network.bisq.mobile.presentation.ui.uicases.offers.OffersListPresenter -import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.* +import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.ITakeOfferPaymentMethodPresenter +import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.ITakeOfferReviewTradePresenter +import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.ITakeOfferTradeAmountPresenter +import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.PaymentMethodPresenter +import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.ReviewTradePresenter +import network.bisq.mobile.presentation.ui.uicases.offers.takeOffer.TradeAmountPresenter import network.bisq.mobile.presentation.ui.uicases.startup.CreateProfilePresenter import network.bisq.mobile.presentation.ui.uicases.startup.IOnboardingPresenter import network.bisq.mobile.presentation.ui.uicases.startup.ITrustedNodeSetupPresenter @@ -31,7 +35,7 @@ val presentationModule = module { single(named("RootNavController")) { getKoin().getProperty("RootNavController") } single(named("TabNavController")) { getKoin().getProperty("TabNavController") } - single { ClientMainPresenter(get(), get(), get()) } bind AppPresenter::class + single { ClientMainPresenter(get(), get(), get(), get()) } bind AppPresenter::class single { TopBarPresenter(get(), get()) } bind ITopBarPresenter::class @@ -48,10 +52,10 @@ val presentationModule = module { single { GettingStartedPresenter( get(), - priceRepository = get(), - bisqStatsRepository = get() + bisqStatsRepository = get(), + get() ) - } bind IGettingStarted::class + } single { CreateProfilePresenter( diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt index ebc6b832..7ca907b5 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedPresenter.kt @@ -1,43 +1,44 @@ package network.bisq.mobile.presentation.ui.uicases import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import network.bisq.mobile.domain.data.BackgroundDispatcher -import network.bisq.mobile.domain.data.model.BisqStats import network.bisq.mobile.domain.data.repository.BisqStatsRepository -import network.bisq.mobile.domain.data.repository.BtcPriceRepository +import network.bisq.mobile.domain.service.market_price.MarketPriceServiceFacade import network.bisq.mobile.presentation.BasePresenter import network.bisq.mobile.presentation.MainPresenter -import network.bisq.mobile.presentation.ui.navigation.Routes class GettingStartedPresenter( mainPresenter: MainPresenter, - private val priceRepository: BtcPriceRepository, - private val bisqStatsRepository: BisqStatsRepository -) : BasePresenter(mainPresenter), IGettingStarted { - private val _btcPrice = MutableStateFlow("Loading...")//("$75,000") - override val btcPrice: StateFlow = _btcPrice + private val bisqStatsRepository: BisqStatsRepository, + private val marketPriceServiceFacade: MarketPriceServiceFacade +) : BasePresenter(mainPresenter) { + private val _offersOnline = MutableStateFlow(145) - override val offersOnline: StateFlow = _offersOnline + val offersOnline: StateFlow = _offersOnline private val _publishedProfiles = MutableStateFlow(1145) - override val publishedProfiles: StateFlow = _publishedProfiles + val publishedProfiles: StateFlow = _publishedProfiles + + private val _formattedMarketPrice = MutableStateFlow("N/A") + val formattedMarketPrice: StateFlow = _formattedMarketPrice + private var job: Job? = null + private val coroutineScope = CoroutineScope(BackgroundDispatcher) private fun refresh() { - CoroutineScope(BackgroundDispatcher).launch { + job = coroutineScope.launch { try { val bisqStats = bisqStatsRepository.fetch() _offersOnline.value = bisqStats?.offersOnline ?: 0 _publishedProfiles.value = bisqStats?.publishedProfiles ?: 0 - val btcPrice = priceRepository.fetch() - val priceList = btcPrice?.prices - _btcPrice.value = (priceList?.get("USD") ?: 0).toString() + marketPriceServiceFacade.marketPriceItem.collect { marketPriceItem -> + _formattedMarketPrice.value = marketPriceItem.formattedPrice.value + } } catch (e: Exception) { // Handle errors println("Error: ${e.message}") @@ -50,6 +51,12 @@ class GettingStartedPresenter( refresh() } + override fun onViewUnattaching() { + super.onViewUnattaching() + job?.cancel() + job = null + } + override fun onResume() { super.onResume() refresh() diff --git a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedScreen.kt b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedScreen.kt index 71fef6d8..3a766a21 100644 --- a/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedScreen.kt +++ b/shared/presentation/src/commonMain/kotlin/network/bisq/mobile/presentation/ui/uicases/GettingStartedScreen.kt @@ -2,7 +2,17 @@ package network.bisq.mobile.presentation.ui.uicases import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -13,32 +23,24 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import bisqapps.shared.presentation.generated.resources.* import bisqapps.shared.presentation.generated.resources.Res +import bisqapps.shared.presentation.generated.resources.icon_chat_outlined +import bisqapps.shared.presentation.generated.resources.icon_star_outlined import bisqapps.shared.presentation.generated.resources.icon_tag_outlined import bisqapps.shared.presentation.generated.resources.img_fiat_btc import bisqapps.shared.presentation.generated.resources.img_learn_and_discover -import kotlinx.coroutines.flow.StateFlow -import network.bisq.mobile.presentation.ViewPresenter import network.bisq.mobile.presentation.ui.components.atoms.BisqText import network.bisq.mobile.presentation.ui.components.layout.BisqScrollLayout import network.bisq.mobile.presentation.ui.helpers.RememberPresenterLifecycle -import network.bisq.mobile.presentation.ui.theme.* +import network.bisq.mobile.presentation.ui.theme.BisqTheme import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject -interface IGettingStarted : ViewPresenter { - val btcPrice: StateFlow - val offersOnline: StateFlow - val publishedProfiles: StateFlow -} - @Composable fun GettingStartedScreen() { - val presenter: IGettingStarted = koinInject() - val btcPrice: String = presenter.btcPrice.collectAsState().value + val presenter: GettingStartedPresenter = koinInject() val offersOnline: Number = presenter.offersOnline.collectAsState().value val publishedProfiles: Number = presenter.publishedProfiles.collectAsState().value @@ -60,7 +62,7 @@ fun GettingStartedScreen() { Column { PriceProfileCard( - price = btcPrice, + price = presenter.formattedMarketPrice.collectAsState().value, priceText = "Market price" ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt b/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt index 66bd3a59..dbab184a 100644 --- a/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt +++ b/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/DependenciesProviderHelper.kt @@ -5,7 +5,7 @@ import kotlinx.cinterop.ObjCClass import kotlinx.cinterop.getOriginalKotlinClass import network.bisq.mobile.client.di.clientModule import network.bisq.mobile.domain.di.domainModule -import network.bisq.mobile.domain.di.iosDomainModule +import network.bisq.mobile.domain.di.iosClientModule import org.koin.core.Koin import org.koin.core.context.startKoin import org.koin.core.parameter.parametersOf @@ -15,7 +15,7 @@ class DependenciesProviderHelper { fun initKoin() { val instance = startKoin { - modules(listOf(domainModule, presentationModule, clientModule, iosDomainModule, iosPresentationModule)) + modules(listOf(domainModule, presentationModule, clientModule, iosClientModule, iosPresentationModule)) } koin = instance.koin diff --git a/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.ios.kt b/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.ios.kt index d3cf4b3f..c625ba8d 100644 --- a/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.ios.kt +++ b/shared/presentation/src/iosMain/kotlin/network/bisq/mobile/presentation/di/PresentationModule.ios.kt @@ -2,16 +2,9 @@ package network.bisq.mobile.presentation.di import network.bisq.mobile.client.user_profile.ClientCatHashService import network.bisq.mobile.service.IosClientCatHashService -import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module val iosPresentationModule = module { - single(named("ApiBaseUrl")) { provideApiBaseUrl() } - single { IosClientCatHashService() } bind ClientCatHashService::class -} - -fun provideApiBaseUrl(): String { - return "localhost" } \ No newline at end of file