Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b6042f9
refactor: improve exception handling and return a NetworkException to…
jvsena42 Sep 9, 2025
066ab22
refactor: improve retry process and toast display
jvsena42 Sep 9, 2025
2c0ec5b
refactor: improve exception handling
jvsena42 Sep 9, 2025
8538f44
refactor: improve network exception handling
jvsena42 Sep 9, 2025
731acba
refactor: improve network exception handling
jvsena42 Sep 9, 2025
bf488f2
fix: improve retry logic
jvsena42 Sep 9, 2025
b6edfdf
fix: improve retry logic
jvsena42 Sep 9, 2025
d7665f3
fix: improve retry logic
jvsena42 Sep 9, 2025
d796226
fix: improve exception handling
jvsena42 Sep 9, 2025
39a0910
chore: remove log
jvsena42 Sep 9, 2025
6b62c7e
refactor: centralize network error related handling
jvsena42 Sep 9, 2025
41654cb
refactor: centralize network error related handling
jvsena42 Sep 9, 2025
1a8f0d2
fix: update node state on failures
jvsena42 Sep 9, 2025
3ed54fb
Merge branch 'master' into fix/crash-offline-startup
jvsena42 Sep 9, 2025
35c0594
chore: change log to verbose
jvsena42 Sep 9, 2025
63f5a47
chore: lint
jvsena42 Sep 9, 2025
cf0f01d
Merge branch 'master' into fix/crash-offline-startup
jvsena42 Sep 10, 2025
d82769a
chore: lint
jvsena42 Sep 10, 2025
0d00b78
chore: remove magic numbers
jvsena42 Sep 10, 2025
35f12d4
chore: remove unused argument
jvsena42 Sep 10, 2025
a820cdd
fix: disable retry for server changes
jvsena42 Sep 10, 2025
92ea097
chore: remove annotation
jvsena42 Sep 10, 2025
1d7ed86
fix: remove NetworkException handling from configureGossipSource
jvsena42 Sep 10, 2025
03508d5
fix: remove NetworkException handling from configureChainSource
jvsena42 Sep 10, 2025
4b8d0bc
fix: add connectivity check to onRefresh
jvsena42 Sep 11, 2025
2ff0513
fix: remove max attempts
jvsena42 Sep 11, 2025
1205350
chore: remove suppress
jvsena42 Sep 11, 2025
2a49148
Merge branch 'master' into fix/crash-offline-startup
jvsena42 Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 109 additions & 3 deletions app/src/main/java/to/bitkit/async/ServiceQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +23,7 @@ enum class ServiceQueue {

private val scope by lazy { CoroutineScope(dispatcher("$name-queue".lowercase()) + SupervisorJob()) }

@Suppress("TooGenericExceptionCaught")
fun <T> blocking(
coroutineContext: CoroutineContext = scope.coroutineContext,
functionName: String = Thread.currentThread().callerName,
Expand All @@ -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 <T> background(
coroutineContext: CoroutineContext = scope.coroutineContext,
functionName: String = Thread.currentThread().callerName,
Expand All @@ -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 <T> 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 <T> 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 <T> 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")
}
}
}
Expand All @@ -60,3 +164,5 @@ enum class ServiceQueue {
}
}
}

class NetworkException(message: String, cause: Throwable? = null) : Exception(message, cause)
34 changes: 29 additions & 5 deletions app/src/main/java/to/bitkit/data/BlocktankHttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,49 @@ 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

@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
}
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/data/widgets/NewsService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ class NewsService @Inject constructor(
// Future services can be added here
private suspend inline fun <reified T> 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<T>() }.getOrElse {
throw NewsError.InvalidResponse(it.message.orEmpty())
}
responseBody
}

else -> throw NewsError.InvalidResponse(response.status.description)
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ConnectivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,5 @@ class ConnectivityRepo @Inject constructor(
}

enum class ConnectivityState { CONNECTED, CONNECTING, DISCONNECTED }

fun ConnectivityState.connected() = this == ConnectivityState.CONNECTED
58 changes: 48 additions & 10 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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."
)
}
}
Expand Down Expand Up @@ -126,6 +128,7 @@ class CurrencyRepo @Inject constructor(
refresh()
}

@Suppress("TooGenericExceptionCaught")
private suspend fun refresh() {
if (isRefreshing) return
isRefreshing = true
Expand All @@ -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) {
Expand Down Expand Up @@ -235,7 +273,7 @@ class CurrencyRepo @Inject constructor(

fun convertFiatToSats(
fiatAmount: Double,
currency: String?
currency: String?,
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
Expand All @@ -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,
)
Loading