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 b2d340a
Show file tree
Hide file tree
Showing 48 changed files with 996 additions and 448 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
Original file line number Diff line number Diff line change
Expand Up @@ -19,34 +19,41 @@ class NodeMainPresenter(
private var applicationServiceCreated = false
override fun onViewAttached() {
super.onViewAttached()
if (!applicationServiceCreated) {
applicationServiceCreated = true
val filesDirsPath = (view as Activity).filesDir.toPath()
val applicationContext = (view as Activity).applicationContext
val applicationService =
AndroidApplicationService(
androidMemoryReportService,
applicationContext,
filesDirsPath
)
provider.applicationService = applicationService

applicationBootstrapFacade.activate()
log.i { "Start initializing applicationService" }
applicationService.initialize()
.whenComplete { r: Boolean?, throwable: Throwable? ->
if (throwable == null) {
log.i { "ApplicationService initialized" }
applicationBootstrapFacade.deactivate()
offerbookServiceFacade.activate()
marketPriceServiceFacade.activate()
} else {
log.e("Initializing applicationService failed", throwable)
runCatching {
if (!applicationServiceCreated) {
applicationServiceCreated = true
val filesDirsPath = (view as Activity).filesDir.toPath()
val applicationContext = (view as Activity).applicationContext
val applicationService =
AndroidApplicationService(
androidMemoryReportService,
applicationContext,
filesDirsPath
)
provider.applicationService = applicationService

applicationBootstrapFacade.activate()
log.i { "Start initializing applicationService" }
applicationService.initialize()
.whenComplete { r: Boolean?, throwable: Throwable? ->
if (throwable == null) {
log.i { "ApplicationService initialized" }
applicationBootstrapFacade.deactivate()
offerbookServiceFacade.activate()
marketPriceServiceFacade.activate()
} else {
log.e("Initializing applicationService failed", throwable)
}
}
}
} else {
offerbookServiceFacade.activate()
marketPriceServiceFacade.activate()
} else {
offerbookServiceFacade.activate()
marketPriceServiceFacade.activate()
}
}.onFailure { e ->
// TODO give user feedback (we could have a general error screen covering usual
// issues like connection issues and potential solutions)
log.e("Error at onViewAttached", e)
}
}

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,23 @@
package network.bisq.mobile.client.market

import kotlinx.coroutines.CancellationException
import io.ktor.util.collections.ConcurrentMap
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,59 +27,50 @@ 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" }
}
}

private fun cancelJob() {
try {
job?.cancel()
job = null
} catch (e: CancellationException) {
log.e("Job cancel failed", e)
}
job?.cancel()
job = null
}
}
Loading

0 comments on commit b2d340a

Please sign in to comment.