refactor: code smell runBlocking inside an OkHttp Interceptor

This commit is contained in:
2026-06-12 12:03:36 +02:00
parent 98bc466edd
commit aaadd0d73f
5 changed files with 72 additions and 32 deletions
@@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() { get() {
val secrets = EncryptedSecretStore(this) val secrets = EncryptedSecretStore(this)
val api = LNbitsClient.create(secrets) val client = LNbitsClient(secrets)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = client.api, secrets = secrets)
val paymentCache = PaymentCacheRepository(this) val paymentCache = PaymentCacheRepository(this)
val syncStore = HistoricalSyncStore(this) val syncStore = HistoricalSyncStore(this)
return Configuration.Builder() return Configuration.Builder()
@@ -2,7 +2,11 @@ package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -10,61 +14,80 @@ import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
object LNbitsClient { class LNbitsClient(secrets: SecretStore) {
// Scope lives as long as this client — SupervisorJob so one failure
// doesn't cancel the URL-watching coroutine.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Cached in memory. Starts blank; populated as soon as DataStore emits.
private val cachedBaseUrl = AtomicReference("")
init {
scope.launch {
// Suspend until DataStore has emitted at least once.
// This primes cachedBaseUrl before any request can realistically fire.
val initial = secrets.credentials.first()
cachedBaseUrl.set(initial.baseUrl)
// Then keep it updated reactively for the lifetime of this client.
secrets.credentials.collect { credentials ->
cachedBaseUrl.set(credentials.baseUrl)
}
}
}
// Single shared client — used by both Retrofit and WebSocket // Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient = OkHttpClient.Builder() val httpClient: OkHttpClient get() = LNbitsHttpClient.instance
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) val api: LNbitsApi by lazy { buildApi() }
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.build()
fun create(secrets: SecretStore): LNbitsApi { private fun buildApi(): LNbitsApi {
// Dynamic base URL interceptor — rewrites the placeholder host to the
// real base URL at request time, not at Retrofit construction time.
// This means credentials only need to be in DataStore by the time the
// first HTTP request fires, not at app startup.
val dynamicBaseUrlInterceptor = Interceptor { chain -> val dynamicBaseUrlInterceptor = Interceptor { chain ->
val originalRequest = chain.request() val originalRequest = chain.request()
val originalUrl = originalRequest.url val originalUrl = originalRequest.url
// Only rewrite requests targeting the Retrofit placeholder host.
// External LNURL/Lightning Address requests must pass through unchanged.
if (originalUrl.host != "placeholder.invalid") { if (originalUrl.host != "placeholder.invalid") {
return@Interceptor chain.proceed(originalRequest) return@Interceptor chain.proceed(originalRequest)
} }
val realBaseUrl = runBlocking { secrets.baseUrl() } val raw = cachedBaseUrl.get().ifBlank { null }
.ifBlank { "https://placeholder.invalid" } ?: throw IllegalStateException(
.trimEnd('/') "LNbits base URL is not configured. " +
val realHttpUrl = realBaseUrl.toHttpUrl() "Complete onboarding before making API calls."
)
val realHttpUrl = raw.trimEnd('/').toHttpUrl()
val newUrl = originalUrl.newBuilder() val newUrl = originalUrl.newBuilder()
.scheme(realHttpUrl.scheme) .scheme(realHttpUrl.scheme)
.host(realHttpUrl.host) .host(realHttpUrl.host)
.port(realHttpUrl.port) .port(realHttpUrl.port)
// Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
.encodedPath(
realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
)
.build() .build()
chain.proceed( chain.proceed(originalRequest.newBuilder().url(newUrl).build())
originalRequest.newBuilder()
.url(newUrl)
.build()
)
} }
val client = httpClient.newBuilder() val client = LNbitsHttpClient.instance.newBuilder()
.addInterceptor(dynamicBaseUrlInterceptor) .addInterceptor(dynamicBaseUrlInterceptor)
.build() .build()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit; .baseUrl("https://placeholder.invalid/")
// ↑ Structurally required: Retrofit validates that baseUrl is a
// well-formed absolute URL at construction time. The interceptor
// rewrites it before any real request leaves the device.
.client(client) .client(client)
.addConverterFactory( .addConverterFactory(
JsonProvider.json.asConverterFactory( JsonProvider.json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType() "application/json; charset=UTF-8".toMediaType()
) )
) .build() )
.build()
.create(LNbitsApi::class.java) .create(LNbitsApi::class.java)
} }
} }
@@ -0,0 +1,17 @@
package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.util.WalletConstants
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* Single shared OkHttpClient for the entire app.
* Used by both LNbitsClient (Retrofit) and WalletNotificationService (WebSocket).
*/
object LNbitsHttpClient {
val instance: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.build()
}
@@ -8,7 +8,7 @@ import android.os.Build
import android.os.IBinder import android.os.IBinder
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.JsonProvider import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.LNbitsHttpClient
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
@@ -157,7 +157,7 @@ class WalletNotificationService : Service() {
val request = Request.Builder().url(wsUrl).build() val request = Request.Builder().url(wsUrl).build()
webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() { webSocket = LNbitsHttpClient.instance.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.d("WebSocket connected") Timber.d("WebSocket connected")
@@ -26,12 +26,12 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app))
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secrets = EncryptedSecretStore(app) val secrets = EncryptedSecretStore(app)
val api = LNbitsClient.create(secrets)
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc) val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache) val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)