Skip to content

Commit

Permalink
Add websocket support
Browse files Browse the repository at this point in the history
  • Loading branch information
HenrikJannsen committed Dec 6, 2024
1 parent 849875d commit b900925
Show file tree
Hide file tree
Showing 43 changed files with 905 additions and 311 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class NodeOfferbookListItemService(private val applicationService: AndroidApplic
offerId,
isMyMessage,
direction,
market.quoteCurrencyCode,
offerTitle,
date,
formattedDate,
Expand Down
29 changes: 18 additions & 11 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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' }
Expand Down
5 changes: 5 additions & 0 deletions shared/domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ApplicationBootstrapFacade> { ClientApplicationBootstrapFacade() }

single(named("ApiBaseUrl")) { provideApiBaseUrl() }
single { ApiRequestService(get(), get<String>(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<MarketPriceServiceFacade> { ClientMarketPriceServiceFacade(get()) }
single { MarketPriceApiGateway(get(), get()) }
single<MarketPriceServiceFacade> { ClientMarketPriceServiceFacade(get(), get()) }

single { UserProfileApiGateway(get()) }
single<UserProfileServiceFacade> { ClientUserProfileServiceFacade(get(), get()) }

single { OfferbookApiGateway(get()) }
single<OfferbookServiceFacade> { ClientOfferbookServiceFacade(get(), get()) }
single { OfferbookApiGateway(get(), get()) }
single<OfferbookServiceFacade> { 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
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, Long> = HashMap()
private val quotes = ConcurrentMap<String, Long>()

// 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<Map<String, Long>> =
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" }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long>)
}

@Serializable
class MarketPriceResponse(val quotes: Map<String, Long>)
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit b900925

Please sign in to comment.