diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index d5714cf90..8f5ad043f 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -10,6 +10,10 @@ import to.bitkit.ext.callerName import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.measured +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory import kotlin.coroutines.CoroutineContext @@ -19,6 +23,7 @@ enum class ServiceQueue { private val scope by lazy { CoroutineScope(dispatcher("$name-queue".lowercase()) + SupervisorJob()) } + @Suppress("TooGenericExceptionCaught") fun blocking( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, @@ -30,12 +35,12 @@ enum class ServiceQueue { block() } } catch (e: Exception) { - Logger.error("ServiceQueue.$name error", e) - throw AppError(e) + handleExceptionForBlocking(e, functionName) } } } + @Suppress("TooGenericExceptionCaught") suspend fun background( coroutineContext: CoroutineContext = scope.coroutineContext, functionName: String = Thread.currentThread().callerName, @@ -47,8 +52,107 @@ enum class ServiceQueue { block() } } catch (e: Exception) { + handleExceptionForBackground(e, functionName) + } + } + } + + /** + * Handle exceptions for blocking calls (these can be more aggressive as they're usually + * called from background threads) + */ + @Suppress("ThrowsCount") + private fun handleExceptionForBlocking(e: Exception, functionName: String): T { + when (e) { + is UnknownHostException, is SocketTimeoutException, is ConnectException -> { + Logger.warn("Network error in $functionName: ${e.message}") + val networkException = NetworkException("Network unavailable: ${e.message}", e) + Logger.error("ServiceQueue.$name error", networkException) + throw networkException + } + + is IOException -> { + Logger.warn("IO error in $functionName: ${e.message}") + val networkException = NetworkException("Connection error: ${e.message}", e) + Logger.error("ServiceQueue.$name error", networkException) + throw networkException + } + + is NetworkException -> { + Logger.warn("Network error in $functionName: ${e.message}") + Logger.error("ServiceQueue.$name error", e) + throw e + } + + else -> { + val wrappedException = AppError(e) + Logger.error("ServiceQueue.$name error", wrappedException) + throw wrappedException + } + } + } + + /** + * Handle exceptions for background calls (these are more lenient for network errors + * to prevent main thread crashes) + */ + @Suppress("ThrowsCount", "ReturnCount") + private fun handleExceptionForBackground(e: Exception, functionName: String): T { + when (e) { + is UnknownHostException, is SocketTimeoutException, is ConnectException -> { + Logger.warn("Network error in $functionName: ${e.message}") + // For certain critical services, we want to fail silently to prevent crashes + if (name == CORE.name || name == FOREX.name) { + Logger.warn("Suppressing network error for $name to prevent crash") + return getNetworkErrorFallback() + } + val networkException = NetworkException("Network unavailable: ${e.message}", e) + Logger.error("ServiceQueue.$name error", networkException) + throw networkException + } + + is IOException -> { + Logger.warn("IO error in $functionName: ${e.message}") + if (name == CORE.name || name == FOREX.name) { + Logger.warn("Suppressing IO error for $name to prevent crash") + return getNetworkErrorFallback() + } + val networkException = NetworkException("Connection error: ${e.message}", e) + Logger.error("ServiceQueue.$name error", networkException) + throw networkException + } + + is NetworkException -> { + Logger.warn("Network error in $functionName: ${e.message}") + if (name == CORE.name || name == FOREX.name) { + Logger.warn("Suppressing network exception for $name to prevent crash") + return getNetworkErrorFallback() + } Logger.error("ServiceQueue.$name error", e) - throw AppError(e) + throw e + } + + else -> { + val wrappedException = AppError(e) + Logger.error("ServiceQueue.$name error", wrappedException) + throw wrappedException + } + } + } + + /** + * Provides safe fallback values for network errors to prevent crashes + */ + @Suppress("UNCHECKED_CAST") + private fun getNetworkErrorFallback(): T { + return when (name) { + CORE.name -> { + // For geo blocking check, assume not blocked if network fails + false as T + } + + else -> { + throw NetworkException("Network unavailable") } } } @@ -60,3 +164,5 @@ enum class ServiceQueue { } } } + +class NetworkException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt b/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt index 02504e0ee..1c32e7536 100644 --- a/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt +++ b/app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt @@ -2,11 +2,16 @@ package to.bitkit.data import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.request.get import io.ktor.http.isSuccess +import to.bitkit.async.NetworkException import to.bitkit.env.Env import to.bitkit.models.FxRateResponse import to.bitkit.utils.Logger +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import javax.inject.Inject import javax.inject.Singleton @@ -14,13 +19,32 @@ import javax.inject.Singleton class BlocktankHttpClient @Inject constructor( private val client: HttpClient, ) { + + @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught", "ThrowsCount") suspend fun fetchLatestRates(): FxRateResponse { - val response = client.get(Env.btcRatesServer) - Logger.verbose("Http call: $response") + return try { + val response = client.get(Env.btcRatesServer) + Logger.verbose("Http call: $response") - return when (response.status.isSuccess()) { - true -> response.body() - else -> throw Exception("Http error: ${response.status}") + when (response.status.isSuccess()) { + true -> response.body() + else -> throw Exception("Http error: ${response.status}") + } + } catch (e: UnknownHostException) { + Logger.warn("DNS resolution failed for rates server: ${e.message}") + throw NetworkException("Unable to resolve rates server: ${e.message}", e) + } catch (e: ConnectException) { + Logger.warn("Connection failed to rates server: ${e.message}") + throw NetworkException("Cannot connect to rates server: ${e.message}", e) + } catch (e: SocketTimeoutException) { + Logger.warn("Timeout connecting to rates server: ${e.message}") + throw NetworkException("Timeout connecting to rates server: ${e.message}", e) + } catch (e: HttpRequestTimeoutException) { + Logger.warn("HTTP request timeout to rates server: ${e.message}") + throw NetworkException("Request timeout to rates server: ${e.message}", e) + } catch (e: Exception) { + Logger.error("Unexpected error fetching rates", e) + throw e } } } diff --git a/app/src/main/java/to/bitkit/data/widgets/NewsService.kt b/app/src/main/java/to/bitkit/data/widgets/NewsService.kt index a04d8f125..9f75aff1f 100644 --- a/app/src/main/java/to/bitkit/data/widgets/NewsService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/NewsService.kt @@ -31,7 +31,6 @@ class NewsService @Inject constructor( // Future services can be added here private suspend inline fun get(url: String): T { val response: HttpResponse = client.get(url) - Logger.debug("Http call: $response") return when (response.status.isSuccess()) { true -> { val responseBody = runCatching { response.body() }.getOrElse { @@ -39,6 +38,7 @@ class NewsService @Inject constructor( } responseBody } + else -> throw NewsError.InvalidResponse(response.status.description) } } diff --git a/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt index cc0f6a05e..065ccd2d9 100644 --- a/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt @@ -155,3 +155,5 @@ class ConnectivityRepo @Inject constructor( } enum class ConnectivityState { CONNECTED, CONNECTING, DISCONNECTED } + +fun ConnectivityState.connected() = this == ConnectivityState.CONNECTED diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index bfd971892..d70d0161c 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock +import to.bitkit.async.NetworkException import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -31,6 +32,7 @@ import to.bitkit.models.PrimaryDisplay import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.Toast import to.bitkit.models.asBtc +import to.bitkit.services.CurrencyError import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.utils.formatCurrency @@ -89,9 +91,9 @@ class CurrencyRepo @Inject constructor( .collect { isStale -> if (isStale) { ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Rates currently unavailable", - description = "An error has occurred. Please try again later." + type = Toast.ToastType.WARNING, + title = "Exchange rates outdated", + description = "Using cached rates. Check your connection." ) } } @@ -126,6 +128,7 @@ class CurrencyRepo @Inject constructor( refresh() } + @Suppress("TooGenericExceptionCaught") private suspend fun refresh() { if (isRefreshing) return isRefreshing = true @@ -139,20 +142,55 @@ class CurrencyRepo @Inject constructor( lastSuccessfulRefresh = clock.now().toEpochMilliseconds(), ) } - Logger.debug("Currency rates refreshed successfully", context = TAG) + Logger.verbose("Currency rates refreshed successfully", context = TAG) + } catch (e: CurrencyError.NetworkUnavailable) { + Logger.warn("Currency rates refresh failed due to network: ${e.message}", context = TAG) + handleNetworkError(e) + } catch (e: NetworkException) { + Logger.warn("Currency rates refresh failed due to network: ${e.message}", context = TAG) + handleNetworkError(e) } catch (e: Exception) { Logger.error("Currency rates refresh failed", e, context = TAG) _currencyState.update { it.copy(error = e) } - _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> - val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold - _currencyState.update { it.copy(hasStaleData = isStale) } - } + // Show error toast only for non-network errors + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Rates update failed", + description = "An unexpected error occurred." + ) + + updateStaleStatus() } finally { isRefreshing = false } } + private suspend fun handleNetworkError(e: Exception) { + _currencyState.update { it.copy(error = e) } + + // Only show network error toast if we have no cached data + if (_currencyState.value.rates.isEmpty()) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = "Network unavailable", + description = "Using default exchange rates." + ) + } + + updateStaleStatus() + } + + private suspend fun updateStaleStatus() { + _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> + val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold + _currencyState.update { it.copy(hasStaleData = isStale) } + } ?: run { + // No previous successful refresh, mark as stale + _currencyState.update { it.copy(hasStaleData = true) } + } + } + suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) { settingsStore.update { settings -> val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) { @@ -235,7 +273,7 @@ class CurrencyRepo @Inject constructor( fun convertFiatToSats( fiatAmount: Double, - currency: String? + currency: String?, ): Result { return convertFiatToSats( fiatValue = BigDecimal.valueOf(fiatAmount), @@ -256,5 +294,5 @@ data class CurrencyState( val currencySymbol: String = "$", val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, - val lastSuccessfulRefresh: Long? = null + val lastSuccessfulRefresh: Long? = null, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a786fee65..dbe042d68 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -32,6 +32,7 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId +import to.bitkit.async.NetworkException import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.dto.TransactionMetadata @@ -58,13 +59,20 @@ import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.min +import kotlin.math.pow +import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds private const val SYNC_TIMEOUT_MS = 10_000L +private const val INITIAL_RETRY_DELAY_MS = 1000L +private const val MAX_DELAY_MS = 30_000L +private const val JITTER_PERCENTAGE = 0.25 @Singleton +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") class LightningRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningService: LightningService, @@ -108,7 +116,8 @@ class LightningRepo @Inject constructor( if (!_lightningState.value.nodeLifecycleState.canRun()) { return@withContext Result.failure( Exception( - "Cannot execute $operationName: Node is ${_lightningState.value.nodeLifecycleState} and not starting" + "Cannot execute $operationName: Node is ${_lightningState.value.nodeLifecycleState} and" + + " not starting" ) ) } @@ -134,6 +143,7 @@ class LightningRepo @Inject constructor( return@withContext executeOperation(operationName, operation) } + @Suppress("TooGenericExceptionCaught") private suspend fun executeOperation( operationName: String, operation: suspend () -> Result, @@ -146,6 +156,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private suspend fun setup( walletIndex: Int, customServer: ElectrumServer? = null, @@ -160,6 +171,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught", "LongParameterList", "LongMethod") suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, @@ -167,6 +179,7 @@ class LightningRepo @Inject constructor( eventHandler: NodeEventHandler? = null, customServer: ElectrumServer? = null, customRgsServerUrl: String? = null, + retryAttempt: Int = 0, ): Result = withContext(bgDispatcher) { val initialLifecycleState = _lightningState.value.nodeLifecycleState if (initialLifecycleState.isRunningOrStarting()) { @@ -181,11 +194,30 @@ class LightningRepo @Inject constructor( if (lightningService.node == null) { val setupResult = setup(walletIndex, customServer, customRgsServerUrl) if (setupResult.isFailure) { + val setupError = setupResult.exceptionOrNull()!! _lightningState.update { - it.copy( - nodeLifecycleState = NodeLifecycleState.ErrorStarting( - setupResult.exceptionOrNull() ?: Exception("Unknown setup error") - ) + it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(setupError)) + } + + // Handle setup failures with retry logic + if (shouldRetry && isRetryableError(setupError)) { + Logger.warn( + "Setup failed (attempt ${retryAttempt + 1}), retrying...", + setupError, + context = TAG + ) + + val retryDelay = calculateRetryDelayWithJitter(retryAttempt) + delay(retryDelay) + + return@withContext start( + walletIndex = walletIndex, + timeout = timeout, + shouldRetry = shouldRetry, + eventHandler = eventHandler, + customServer = customServer, + customRgsServerUrl = customRgsServerUrl, + retryAttempt = retryAttempt + 1 ) } return@withContext setupResult @@ -213,51 +245,110 @@ class LightningRepo @Inject constructor( syncState() updateGeoBlockState() - // Perform post-startup tasks + // Perform post-startup tasks with error handling connectToTrustedPeers().onFailure { e -> Logger.error("Failed to connect to trusted peers", e) } - sync() + + // Handle sync gracefully even if it fails + try { + sync() + } catch (e: Exception) { + Logger.warn("Initial sync failed, but continuing startup", e, context = TAG) + } + registerForNotifications() Result.success(Unit) } catch (e: Throwable) { - if (shouldRetry) { - Logger.warn("Start error, retrying after two seconds...", e = e, context = TAG) + _lightningState.update { + it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) + } + + if (shouldRetry && isRetryableError(e)) { + Logger.warn( + "Start failed (attempt ${retryAttempt + 1}), retrying...", + e, + context = TAG + ) + _lightningState.update { it.copy(nodeLifecycleState = initialLifecycleState) } - delay(2.seconds) + val retryDelay = calculateRetryDelayWithJitter(retryAttempt) + delay(retryDelay) + return@withContext start( walletIndex = walletIndex, timeout = timeout, - shouldRetry = false, eventHandler = eventHandler, customServer = customServer, customRgsServerUrl = customRgsServerUrl, + retryAttempt = retryAttempt + 1 ) } else { - Logger.error("Node start error", e, context = TAG) - _lightningState.update { - it.copy(nodeLifecycleState = NodeLifecycleState.ErrorStarting(e)) - } + Logger.error( + "Node start error (attempt ${retryAttempt + 1}), giving up", + e, + context = TAG + ) Result.failure(e) } } } + /** + * Determines if an error is retryable based on its type and characteristics + */ + private fun isRetryableError(error: Throwable): Boolean { + return when { + error is NetworkException -> true + error.message?.contains("Network unavailable") == true -> true + error.message?.contains("Unable to resolve host") == true -> true + error.message?.contains("Connection refused") == true -> true + error.message?.contains("Read failed") == true -> true + error.message?.contains("timeout") == true -> true + error.cause is NetworkException -> true + else -> false + } + } + + /** + * Calculates retry delay with exponential backoff and jitter + */ + private fun calculateRetryDelayWithJitter(retryAttempt: Int): Long { + val baseDelay = INITIAL_RETRY_DELAY_MS + val exponentialDelay = baseDelay * 2.0.pow(retryAttempt.toDouble()).toLong() + val delayWithCap = min(exponentialDelay, MAX_DELAY_MS) + + val maxJitter = (delayWithCap * JITTER_PERCENTAGE).toLong() + val jitter = Random.nextLong(-maxJitter, maxJitter + 1) + + return delayWithCap + jitter + } + /**Updates the shouldBlockLightning state and returns the current value*/ + @Suppress("TooGenericExceptionCaught") private suspend fun updateGeoBlockState(): Boolean { - val shouldBlock = coreService.shouldBlockLightning() - _lightningState.update { - it.copy(shouldBlockLightning = shouldBlock) + return try { + val shouldBlock = coreService.shouldBlockLightning() + _lightningState.update { + it.copy(shouldBlockLightning = shouldBlock) + } + shouldBlock + } catch (e: NetworkException) { + Logger.warn("Failed to check geo block status due to network, assuming not blocked", e, context = TAG) + false + } catch (e: Exception) { + Logger.error("Failed to check geo block status", e, context = TAG) + false } - return shouldBlock } fun setInitNodeLifecycleState() { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } + @Suppress("TooGenericExceptionCaught") suspend fun stop(): Result = withContext(bgDispatcher) { if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) { return@withContext Result.success(Unit) @@ -285,13 +376,22 @@ class LightningRepo @Inject constructor( } _lightningState.update { it.copy(isSyncingWallet = true) } - lightningService.sync() - syncState() - _lightningState.update { it.copy(isSyncingWallet = false) } - Result.success(Unit) + try { + lightningService.sync() + syncState() + Result.success(Unit) + } catch (e: Exception) { + Logger.error("Sync failed", e, context = TAG) + Result.failure(e) + } finally { + _lightningState.update { it.copy(isSyncingWallet = false) } + } + }.onFailure { + _lightningState.update { it.copy(isSyncingWallet = false) } } + @Suppress("TooGenericExceptionCaught") suspend fun wipeStorage(walletIndex: Int): Result = withContext(bgDispatcher) { Logger.debug("wipeStorage called, stopping node first", context = TAG) stop().onSuccess { @@ -328,7 +428,7 @@ class LightningRepo @Inject constructor( start( eventHandler = cachedEventHandler, customServer = newServer, - shouldRetry = false, + shouldRetry = false, // Disable retry for server changes ).onFailure { startError -> Logger.warn("Failed ldk-node config change, attempting recovery…") restartWithPreviousConfig() @@ -354,7 +454,7 @@ class LightningRepo @Inject constructor( start( eventHandler = cachedEventHandler, - shouldRetry = false, + shouldRetry = false, // Disable retry for server changes customRgsServerUrl = newRgsUrl, ).onFailure { startError -> Logger.warn("Failed ldk-node config change, attempting recovery…") @@ -380,7 +480,6 @@ class LightningRepo @Inject constructor( start( eventHandler = cachedEventHandler, - shouldRetry = false, ).onSuccess { Logger.debug("Successfully started node with previous config") }.onFailure { e -> @@ -404,14 +503,19 @@ class LightningRepo @Inject constructor( } suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("Connect to trusted peers") { - lightningService.connectToTrustedPeers() - Result.success(Unit) + try { + lightningService.connectToTrustedPeers() + Result.success(Unit) + } catch (e: NetworkException) { + Logger.warn("Failed to connect to trusted peers due to network", e, context = TAG) + Result.failure(e) + } } suspend fun connectPeer(peer: LnPeer): Result = executeWhenNodeRunning("connectPeer") { - lightningService.connectPeer(peer) - syncState() - Result.success(Unit) + lightningService.connectPeer(peer).also { + syncState() + } } suspend fun disconnectPeer(peer: LnPeer): Result = executeWhenNodeRunning("Disconnect peer") { @@ -511,6 +615,7 @@ class LightningRepo @Inject constructor( Result.success(paymentId) } + @Suppress("LongParameterList") suspend fun sendOnChain( address: Address, sats: ULong, @@ -671,7 +776,7 @@ class LightningRepo @Inject constructor( Result.success(Unit) } - suspend fun syncState() { + fun syncState() { _lightningState.update { it.copy( nodeId = getNodeId().orEmpty(), @@ -705,6 +810,7 @@ class LightningRepo @Inject constructor( fun hasChannels(): Boolean = _lightningState.value.nodeLifecycleState.isRunning() && lightningService.channels?.isNotEmpty() == true + @Suppress("TooGenericExceptionCaught") suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") { return@executeWhenNodeRunning try { val token = token ?: firebaseMessaging.token.await() @@ -717,6 +823,9 @@ class LightningRepo @Inject constructor( lspNotificationsService.registerDevice(token) Result.success(Unit) + } catch (e: NetworkException) { + Logger.warn("Failed to register for notifications due to network", e) + Result.failure(e) } catch (e: Throwable) { Logger.error("Register for notifications error", e) Result.failure(e) @@ -725,6 +834,7 @@ class LightningRepo @Inject constructor( fun registerForNotificationsAsync(token: String) = scope.launch { registerForNotifications(token) } + @Suppress("TooGenericExceptionCaught") suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: UInt, @@ -751,7 +861,8 @@ class LightningRepo @Inject constructor( satsPerVByte = satsPerVByte, ) Logger.debug( - "bumpFeeByRbf success, replacementTxId: $replacementTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte" + "bumpFeeByRbf success, replacementTxId: $replacementTxId originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte" ) Result.success(replacementTxId) } catch (e: Throwable) { @@ -764,6 +875,7 @@ class LightningRepo @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") suspend fun accelerateByCpfp( originalTxId: Txid, satsPerVByte: UInt, @@ -800,12 +912,14 @@ class LightningRepo @Inject constructor( destinationAddress = destinationAddress, ) Logger.debug( - "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" + "accelerateByCpfp success, newDestinationTxId: $newDestinationTxId originalTxId: $originalTxId," + + "satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress" ) Result.success(newDestinationTxId) } catch (e: Throwable) { Logger.error( - "accelerateByCpfp error originalTxId: $originalTxId, satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", + "accelerateByCpfp error originalTxId: $originalTxId, " + + "satsPerVByte: $satsPerVByte destinationAddress: $destinationAddress", e, context = TAG ) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index e43708632..5023236af 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -63,6 +63,9 @@ import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -112,28 +115,51 @@ class CoreService @Inject constructor( } } - /** Returns true if geo blocked */ + /** + * Returns true if geo blocked, false if allowed, null if unable to check + */ + @Suppress("InstanceOfCheckForException", "TooGenericExceptionCaught") suspend fun checkGeoStatus(): Boolean? { - return ServiceQueue.CORE.background { - Logger.verbose("Checking geo status…", context = "GeoCheck") - val response = httpClient.get(Env.geoCheckUrl) - - when (response.status.value) { - HttpStatusCode.OK.value -> { - Logger.verbose("Region allowed", context = "GeoCheck") - false - } - - HttpStatusCode.Forbidden.value -> { - Logger.warn("Region blocked", context = "GeoCheck") - true - } - - else -> { - Logger.warn("Unexpected status code: ${response.status.value}", context = "GeoCheck") - null + return try { + ServiceQueue.CORE.background { + Logger.verbose("Checking geo status…", context = "GeoCheck") + val response = httpClient.get(Env.geoCheckUrl) + + when (response.status.value) { + HttpStatusCode.OK.value -> { + Logger.verbose("Region allowed", context = "GeoCheck") + false + } + + HttpStatusCode.Forbidden.value -> { + Logger.warn("Region blocked", context = "GeoCheck") + true + } + + else -> { + Logger.warn("Unexpected status code: ${response.status.value}", context = "GeoCheck") + null + } } } + } catch (e: Exception) { + // Handle network failures gracefully + val isNetworkError = e is UnknownHostException || + e is SocketTimeoutException || + e is ConnectException || + e.message?.contains("Unable to resolve host") == true || + e.message?.contains("No address associated with hostname") == true + + if (isNetworkError) { + Logger.warn( + "Network error during geo check, unable to determine status: ${e.message}", + context = "GeoCheck" + ) + null // Return null when network is unavailable + } else { + Logger.error("Unexpected error during geo check", e, context = "GeoCheck") + null // Return null for any other errors too + } } } @@ -141,7 +167,8 @@ class CoreService @Inject constructor( val blocktankPeers = Env.trustedLnPeers // TODO get from blocktank info when lightningService.setup sets trustedPeers0conf using BT API // pseudocode idea: - // val blocktankPeers = getInfo(refresh = true)?.nodes?.map { LnPeer(nodeId = it.pubkey, address = "TO_DO") }.orEmpty() + // val blocktankPeers = getInfo(refresh = true)?.nodes?.map { LnPeer(nodeId = it.pubkey, address = "TO_DO") } + // .orEmpty() return blocktankPeers } @@ -149,7 +176,39 @@ class CoreService @Inject constructor( suspend fun hasExternalNode() = getConnectedPeers().any { connectedPeer -> connectedPeer !in getLspPeers() } - suspend fun shouldBlockLightning() = checkGeoStatus() == true && !hasExternalNode() + suspend fun shouldBlockLightning(): Boolean { + return try { + val geoStatus = checkGeoStatus() + + when (geoStatus) { + true -> { + // Geo blocked - check if user has external nodes + val hasExternal = hasExternalNode() + val shouldBlock = !hasExternal + Logger.info( + "Geo blocked region, has external node: $hasExternal, blocking: $shouldBlock", + context = "GeoCheck" + ) + shouldBlock + } + + false -> { + // Geo allowed + Logger.debug("Geo allowed region", context = "GeoCheck") + false + } + + null -> { + // Unable to check (network error) - use safe default + Logger.warn("Unable to check geo status, defaulting to not blocked", context = "GeoCheck") + false // Safe default: don't block if we can't verify + } + } + } catch (e: Exception) { + Logger.error("Error in shouldBlockLightning, defaulting to not blocked", e, context = "GeoCheck") + false // Safe default: don't block on any error + } + } } // endregion @@ -530,27 +589,42 @@ class BlocktankService( private val coreService: CoreService, private val lightningService: LightningService, ) { + suspend fun info(refresh: Boolean = true): IBtInfo? { - return ServiceQueue.CORE.background { - getInfo(refresh = refresh) + return try { + ServiceQueue.CORE.background { + getInfo(refresh = refresh) + } + } catch (e: Exception) { + handleNetworkError("info", e) } } private suspend fun fees(refresh: Boolean = true): FeeRates? { - return info(refresh)?.onchain?.feeRates + return try { + info(refresh)?.onchain?.feeRates + } catch (e: Exception) { + Logger.warn("Failed to get fees: ${e.message}") + null + } } suspend fun getFees(): Result { - var fees = fees(refresh = true) - if (fees == null) { - Logger.warn("Failed to fetch fresh fee rate, using cached rate.") - fees = fees(refresh = false) - } - if (fees == null) { - return Result.failure(AppError("Fees unavailable from bitkit-core")) - } + return try { + var fees = fees(refresh = true) + if (fees == null) { + Logger.warn("Failed to fetch fresh fee rate, using cached rate.") + fees = fees(refresh = false) + } + if (fees == null) { + return Result.failure(AppError("Fees unavailable from bitkit-core")) + } - return Result.success(fees) + Result.success(fees) + } catch (e: Exception) { + Logger.error("Error getting fees", e) + Result.failure(e) + } } suspend fun createCjit( @@ -578,11 +652,25 @@ class BlocktankService( filter: CJitStateEnum? = null, refresh: Boolean = true, ): List { - return ServiceQueue.CORE.background { - if (refresh) { - refreshActiveCjitEntries() + return try { + ServiceQueue.CORE.background { + if (refresh) { + try { + refreshActiveCjitEntries() + } catch (e: Exception) { + Logger.warn("Failed to refresh CJIT entries: ${e.message}") + // Continue with cached data + } + } + getCjitEntries( + entryIds = entryIds, + filter = filter, + refresh = false + ) // Use cached after refresh attempt } - getCjitEntries(entryIds = entryIds, filter = filter, refresh = refresh) + } catch (e: Exception) { + Logger.error("Error getting CJIT orders", e) + emptyList() // Return empty list on error } } @@ -615,11 +703,21 @@ class BlocktankService( filter: BtOrderState2? = null, refresh: Boolean = true, ): List { - return ServiceQueue.CORE.background { - if (refresh) { - refreshActiveOrders() + return try { + ServiceQueue.CORE.background { + if (refresh) { + try { + refreshActiveOrders() + } catch (e: Exception) { + Logger.warn("Failed to refresh orders: ${e.message}") + // Continue with cached data + } + } + getOrders(orderIds = orderIds, filter = filter, refresh = false) // Use cached after refresh attempt } - getOrders(orderIds = orderIds, filter = filter, refresh = refresh) + } catch (e: Exception) { + Logger.error("Error getting orders", e) + emptyList() // Return empty list on error } } @@ -641,7 +739,27 @@ class BlocktankService( } } - // MARK: - Regtest methods + /** + * Handles network errors gracefully, returning null and logging appropriately + */ + private fun handleNetworkError(operation: String, e: Exception): T? { + val isNetworkError = e is UnknownHostException || + e is SocketTimeoutException || + e is ConnectException || + e.message?.contains("Unable to resolve host") == true || + e.message?.contains("Network") == true || + e.message?.contains("api1.blocktank.to") == true + + if (isNetworkError) { + Logger.warn("Network error in $operation: ${e.message}") + } else { + Logger.error("Error in $operation", e) + } + + return null + } + + // MARK: - Regtest methods (these don't typically require network so keep as-is) suspend fun regtestMine(count: UInt = 1u) { com.synonym.bitkitcore.regtestMine(count = count) } diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 463db9bd6..b3b16ee3c 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -1,10 +1,12 @@ package to.bitkit.services import kotlinx.coroutines.delay +import to.bitkit.async.NetworkException import to.bitkit.async.ServiceQueue import to.bitkit.data.BlocktankHttpClient import to.bitkit.models.FxRate import to.bitkit.utils.AppError +import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.math.pow @@ -23,20 +25,46 @@ class CurrencyService @Inject constructor( val response = ServiceQueue.FOREX.background { blocktankHttpClient.fetchLatestRates() } val rates = response.tickers return rates + } catch (e: NetworkException) { + Logger.warn( + "Network error fetching rates (attempt ${attempt + 1}/$maxRetries): ${e.message}", + context = TAG + ) + lastError = e + + // Don't retry network errors on last attempt, or if it's a DNS resolution issue + if (attempt == maxRetries - 1 || e.message?.contains("No address associated with hostname") == true) { + break + } + + // Wait before retrying, with exponential backoff + val waitTime = 2.0.pow(attempt.toDouble()).toLong() * 1000L + Logger.debug("Retrying in ${waitTime}ms...", context = TAG) + delay(waitTime) } catch (e: Exception) { + Logger.error("Unexpected error fetching rates (attempt ${attempt + 1}/$maxRetries)", e, context = TAG) lastError = e + if (attempt < maxRetries - 1) { - // Wait a bit before retrying, with exponential backoff + // Wait before retrying, with exponential backoff val waitTime = 2.0.pow(attempt.toDouble()).toLong() * 1000L delay(waitTime) } } } - throw lastError ?: CurrencyError.Unknown + when (lastError) { + is NetworkException -> throw CurrencyError.NetworkUnavailable(lastError.message ?: "Network unavailable") + else -> throw lastError ?: CurrencyError.Unknown + } + } + + private companion object { + const val TAG = "CurrencyService" } } sealed class CurrencyError(message: String) : AppError(message) { data object Unknown : CurrencyError("Unknown error occurred while fetching rates") + data class NetworkUnavailable(val details: String) : CurrencyError("Network unavailable: $details") } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index b0b87dabf..b1e09ae29 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -35,6 +35,7 @@ import org.lightningdevkit.ldknode.Txid import org.lightningdevkit.ldknode.UserChannelId import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope +import to.bitkit.async.NetworkException import to.bitkit.async.ServiceQueue import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssStoreIdProvider @@ -64,6 +65,7 @@ import kotlin.time.Duration.Companion.seconds typealias NodeEventHandler = suspend (Event) -> Unit @Singleton +@Suppress("TooManyFunctions", "LargeClass") class LightningService @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, @@ -76,6 +78,7 @@ class LightningService @Inject constructor( private lateinit var trustedLnPeers: List + @Suppress("ComplexCondition", "ThrowsCount") suspend fun setup( walletIndex: Int, customServer: ElectrumServer? = null, @@ -133,6 +136,12 @@ class LightningService @Inject constructor( ) } } catch (e: BuildException) { + // Check if the build exception is due to network issues + val cause = e.cause?.message ?: e.message + if (isNetworkRelatedError(cause)) { + Logger.warn("LDK build failed due to network issues: $cause") + throw NetworkException("Network error during LDK build: $cause", e) + } throw LdkError(e) } } @@ -140,6 +149,28 @@ class LightningService @Inject constructor( Logger.info("LDK node setup") } + /** + * Enhanced network error detection for LDK-specific errors + */ + private fun isNetworkRelatedError(message: String?): Boolean { + if (message == null) return false + val lowerMessage = message.lowercase() + return lowerMessage.contains("read failed") || + lowerMessage.contains("network") || + lowerMessage.contains("connection") || + lowerMessage.contains("resolve host") || + lowerMessage.contains("timeout") || + lowerMessage.contains("dns") || + lowerMessage.contains("unreachable") || + lowerMessage.contains("refused") || + // VSS-specific network errors + lowerMessage.contains("vss") || + lowerMessage.contains("auth") || + // LDK build-specific network failures + lowerMessage.contains("failed to get") || + lowerMessage.contains("http") + } + private suspend fun Builder.configureGossipSource(customRgsServerUrl: String?) { val rgsServerUrl = customRgsServerUrl ?: settingsStore.data.first().rgsServerUrl if (rgsServerUrl != null) { @@ -224,15 +255,26 @@ class LightningService @Inject constructor( Logger.info("Lightning wallet wiped") } + @Suppress("ThrowsCount", "TooGenericExceptionCaught") suspend fun sync() { val node = this.node ?: throw ServiceError.NodeNotSetup Logger.verbose("Syncing LDK…") ServiceQueue.LDK.background { - node.syncWallets() - // launch { setMaxDustHtlcExposureForCurrentChannels() } + try { + node.syncWallets() + // launch { setMaxDustHtlcExposureForCurrentChannels() } + } catch (e: Exception) { + // Check if sync failure is due to network issues + val message = e.message?.lowercase() ?: "" + if (isNetworkRelatedError(message = message)) { + Logger.warn("Sync failed due to network issues: ${e.message}") + throw NetworkException("Network error during sync: ${e.message}", e) + } + throw e + } } - Logger.debug("LDK synced") + Logger.verbose("LDK synced") } // private fun setMaxDustHtlcExposureForCurrentChannels() { @@ -275,12 +317,28 @@ class LightningService @Inject constructor( val node = this.node ?: throw ServiceError.NodeNotSetup ServiceQueue.LDK.background { + var networkFailures = 0 + val maxNetworkFailures = trustedLnPeers.size // Allow all to fail due to network issues + for (peer in trustedLnPeers) { try { node.connect(peer.nodeId, peer.address, persist = true) Logger.info("Connected to trusted peer: $peer") } catch (e: NodeException) { - Logger.error("Peer connect error: $peer", LdkError(e)) + val ldkError = LdkError(e) + val isNetworkError = isNetworkRelatedError(e.message) + + if (isNetworkError) { + networkFailures++ + Logger.warn("Network error connecting to trusted peer: $peer", ldkError) + + // If all connections failed due to network, throw network exception + if (networkFailures >= maxNetworkFailures) { + throw NetworkException("Failed to connect to any trusted peers due to network issues") + } + } else { + Logger.error("Peer connect error: $peer", ldkError) + } } } } @@ -301,7 +359,14 @@ class LightningService @Inject constructor( } catch (e: NodeException) { val error = LdkError(e) Logger.error("Peer connect error: $peer", error) - Result.failure(error) + + val isNetworkError = isNetworkRelatedError(e.message) + + if (isNetworkError) { + Result.failure(NetworkException("Network error connecting to peer: ${e.message}", e)) + } else { + Result.failure(error) + } } } } @@ -347,7 +412,14 @@ class LightningService @Inject constructor( } catch (e: NodeException) { val error = LdkError(e) Logger.error("Error initiating channel open", error) - Result.failure(error) + + val isNetworkError = isNetworkRelatedError(e.message) + + if (isNetworkError) { + Result.failure(NetworkException("Network error opening channel: ${e.message}", e)) + } else { + Result.failure(error) + } } } } @@ -399,6 +471,7 @@ class LightningService @Inject constructor( } } + @Suppress("ReturnCount") fun canSend(amountSats: ULong): Boolean { val channels = this.channels if (channels == null) { @@ -452,6 +525,7 @@ class LightningService @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") suspend fun estimateRoutingFees(bolt11: String): Result { val node = this.node ?: throw ServiceError.NodeNotSetup @@ -461,14 +535,15 @@ class LightningService @Inject constructor( val feesMsat = node.bolt11Payment().estimateRoutingFees(invoice) val feeSat = feesMsat / 1000u Result.success(feeSat) + } catch (e: NodeException) { + Result.failure(LdkError(e)) } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + Result.failure(e) } } } + @Suppress("TooGenericExceptionCaught") suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result { val node = this.node ?: throw ServiceError.NodeNotSetup @@ -479,16 +554,17 @@ class LightningService @Inject constructor( val feesMsat = node.bolt11Payment().estimateRoutingFeesUsingAmount(invoice, amountMsat) val feeSat = feesMsat / 1000u Result.success(feeSat) + } catch (e: NodeException) { + Result.failure(LdkError(e)) } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + Result.failure(e) } } } // endregion // region utxo selection + @Suppress("TooGenericExceptionCaught") suspend fun listSpendableOutputs(): Result> { val node = this.node ?: throw ServiceError.NodeNotSetup @@ -496,14 +572,15 @@ class LightningService @Inject constructor( return@background try { val result = node.onchainPayment().listSpendableOutputs() Result.success(result) + } catch (e: NodeException) { + Result.failure(LdkError(e)) } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + Result.failure(e) } } } + @Suppress("TooGenericExceptionCaught") suspend fun selectUtxosWithAlgorithm( targetAmountSats: ULong, satsPerVByte: UInt, @@ -521,10 +598,10 @@ class LightningService @Inject constructor( utxos = utxos, ) Result.success(result) + } catch (e: NodeException) { + Result.failure(LdkError(e)) } catch (e: Exception) { - Result.failure( - if (e is NodeException) LdkError(e) else e - ) + Result.failure(e) } } } @@ -641,6 +718,7 @@ class LightningService @Inject constructor( } } + @Suppress("LongMethod") private fun logEvent(event: Event) { when (event) { is Event.PaymentSuccessful -> { @@ -673,7 +751,8 @@ class LightningService @Inject constructor( val paymentHash = event.paymentHash val claimableAmountMsat = event.claimableAmountMsat Logger.info( - "🫰 Payment claimable: paymentId: $paymentId paymentHash: $paymentHash claimableAmountMsat: $claimableAmountMsat" + "🫰 Payment claimable: paymentId: $paymentId " + + "paymentHash: $paymentHash claimableAmountMsat: $claimableAmountMsat" ) } @@ -686,7 +765,9 @@ class LightningService @Inject constructor( val counterpartyNodeId = event.counterpartyNodeId val fundingTxo = event.fundingTxo Logger.info( - "⏳ Channel pending: channelId: $channelId userChannelId: $userChannelId formerTemporaryChannelId: $formerTemporaryChannelId counterpartyNodeId: $counterpartyNodeId fundingTxo: $fundingTxo" + "⏳ Channel pending: channelId: $channelId userChannelId: $userChannelId " + + "formerTemporaryChannelId: $formerTemporaryChannelId " + + "counterpartyNodeId: $counterpartyNodeId fundingTxo: $fundingTxo" ) } @@ -695,7 +776,8 @@ class LightningService @Inject constructor( val userChannelId = event.userChannelId val counterpartyNodeId = event.counterpartyNodeId ?: "?" Logger.info( - "👐 Channel ready: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId" + "👐 Channel ready: channelId: $channelId userChannelId: $userChannelId " + + "counterpartyNodeId: $counterpartyNodeId" ) } @@ -705,7 +787,8 @@ class LightningService @Inject constructor( val counterpartyNodeId = event.counterpartyNodeId ?: "?" val reason = event.reason Logger.info( - "⛔ Channel closed: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId reason: $reason" + "⛔ Channel closed: channelId: $channelId userChannelId: $userChannelId" + + " counterpartyNodeId: $counterpartyNodeId reason: $reason" ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 0e9e086e2..642258982 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -71,7 +71,9 @@ import to.bitkit.R import to.bitkit.env.Env import to.bitkit.models.BalanceState import to.bitkit.models.Suggestion +import to.bitkit.models.Toast import to.bitkit.models.WidgetType +import to.bitkit.repositories.connected import to.bitkit.ui.LocalBalances import to.bitkit.ui.Routes import to.bitkit.ui.components.AppStatus @@ -116,6 +118,7 @@ import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.MainUiState import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.WalletViewModel +import kotlin.time.Duration.Companion.seconds @Composable fun HomeScreen( @@ -136,7 +139,7 @@ fun HomeScreen( val hasSeenWidgetsIntro: Boolean by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() val quickPayIntroSeen by settingsViewModel.quickPayIntroSeen.collectAsStateWithLifecycle() val latestActivities by activityListViewModel.latestActivities.collectAsStateWithLifecycle() - + val connectivity by appViewModel.isOnline.collectAsStateWithLifecycle() val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() homeUiState.deleteWidgetAlert?.let { type -> @@ -151,9 +154,19 @@ fun HomeScreen( drawerState = drawerState, latestActivities = latestActivities, onRefresh = { - walletViewModel.onPullToRefresh() - homeViewModel.refreshWidgets() - activityListViewModel.syncLdkNodePayments() + if (connectivity.connected()) { + walletViewModel.onPullToRefresh() + homeViewModel.refreshWidgets() + activityListViewModel.syncLdkNodePayments() + } else { + walletViewModel.updateRefreshing(true) + appViewModel.toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.other__connection_issue), + description = context.getString(R.string.other__connection_issue_explain), + ) + walletViewModel.updateRefreshing(false, delay = 2.seconds) + } }, onClickProfile = { if (!hasSeenProfileIntro) { diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d966ccdbc..184e9a223 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @HiltViewModel @@ -170,13 +171,20 @@ class WalletViewModel @Inject constructor( fun onPullToRefresh() { viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } + updateRefreshing(true) walletRepo.syncNodeAndWallet() .onFailure { error -> Logger.error("Failed to refresh state: ${error.message}", error) ToastEventBus.send(error) } - _uiState.update { it.copy(isRefreshing = false) } + updateRefreshing(false) + } + } + + fun updateRefreshing(isRefreshing: Boolean, delay: Duration? = null) { + viewModelScope.launch { + delay?.let { delay(it) } + _uiState.update { it.copy(isRefreshing = isRefreshing) } } }