diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/GudariWalletApplication.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/GudariWalletApplication.kt index 7b029f4..3bfdcab 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/GudariWalletApplication.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/GudariWalletApplication.kt @@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider { override val workManagerConfiguration: Configuration get() { val secrets = EncryptedSecretStore(this) - val api = LNbitsClient.create(secrets) - val repo = LNbitsWalletRepository(api = api, secrets = secrets) + val client = LNbitsClient(secrets) + val repo = LNbitsWalletRepository(api = client.api, secrets = secrets) val paymentCache = PaymentCacheRepository(this) val syncStore = HistoricalSyncStore(this) return Configuration.Builder() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt index cc0c2c9..417cbe3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt @@ -2,7 +2,11 @@ package com.bitcointxoko.gudariwallet.api import com.bitcointxoko.gudariwallet.security.SecretStore 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.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -10,61 +14,80 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory 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 - val httpClient: 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() + val httpClient: OkHttpClient get() = LNbitsHttpClient.instance + val api: LNbitsApi by lazy { buildApi() } - fun create(secrets: SecretStore): 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. + private fun buildApi(): LNbitsApi { val dynamicBaseUrlInterceptor = Interceptor { chain -> val originalRequest = chain.request() 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") { return@Interceptor chain.proceed(originalRequest) } - val realBaseUrl = runBlocking { secrets.baseUrl() } - .ifBlank { "https://placeholder.invalid" } - .trimEnd('/') - val realHttpUrl = realBaseUrl.toHttpUrl() + val raw = cachedBaseUrl.get().ifBlank { null } + ?: throw IllegalStateException( + "LNbits base URL is not configured. " + + "Complete onboarding before making API calls." + ) + + val realHttpUrl = raw.trimEnd('/').toHttpUrl() val newUrl = originalUrl.newBuilder() .scheme(realHttpUrl.scheme) .host(realHttpUrl.host) .port(realHttpUrl.port) + // Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups) + .encodedPath( + realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath + ) .build() - chain.proceed( - originalRequest.newBuilder() - .url(newUrl) - .build() - ) + chain.proceed(originalRequest.newBuilder().url(newUrl).build()) } - val client = httpClient.newBuilder() + val client = LNbitsHttpClient.instance.newBuilder() .addInterceptor(dynamicBaseUrlInterceptor) .build() 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) .addConverterFactory( JsonProvider.json.asConverterFactory( "application/json; charset=UTF-8".toMediaType() ) - ) .build() + ) + .build() .create(LNbitsApi::class.java) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsHttpClient.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsHttpClient.kt new file mode 100644 index 0000000..30fd220 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsHttpClient.kt @@ -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() +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt index f726980..c94fee4 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt @@ -8,7 +8,7 @@ import android.os.Build import android.os.IBinder import timber.log.Timber 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.WsPaymentMessage import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository @@ -157,7 +157,7 @@ class WalletNotificationService : Service() { 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) { Timber.d("WebSocket connected") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index 19aec21..daec30d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -26,12 +26,12 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller import com.bitcointxoko.gudariwallet.ui.send.SendViewModel class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { + private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app)) override fun create(modelClass: Class): T { val secrets = EncryptedSecretStore(app) - val api = LNbitsClient.create(secrets) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc) - val repo = LNbitsWalletRepository(api = api, secrets = secrets) + val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets) val lnurlCache = LnurlCacheRepository(app) val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)