Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions app/src/main/java/to/bitkit/data/CacheStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ data class AppCacheData(
val bolt11PaymentHash: String = "",
val bip21: String = "",
val balance: BalanceState? = null,
val usdtDisplayBalance: CachedUsdtBalance? = null,
val backupStatuses: Map<BackupCategory, BackupItemStatus> = mapOf(),
val deletedActivities: List<String> = listOf(),
val detachedActivityContacts: Set<String> = emptySet(),
Expand Down Expand Up @@ -207,3 +208,13 @@ data class BlocktankRefundAddress(
val address: String,
val index: Long,
)

@Serializable
data class CachedUsdtBalance(
val address: String,
val network: String,
val balance: ULong,
) {
fun balanceFor(address: String, network: String): ULong? =
balance.takeIf { this.address == address && this.network == network }
}
27 changes: 15 additions & 12 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,21 @@ class CurrencyRepo @Inject constructor(
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
) { settings, cachedData -> settings to cachedData }.collect { (settings, cachedData) ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates.toImmutableList(),
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
_currencyState.update {
it.copy(
rates = cachedData.cachedRates.toImmutableList(),
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false,
)
}
}
}
}
Expand All @@ -137,6 +137,7 @@ class CurrencyRepo @Inject constructor(
private suspend fun refresh() {
if (isRefreshing) return
isRefreshing = true
_currencyState.update { it.copy(isRefreshing = true) }
Comment thread
ben-kaufman marked this conversation as resolved.
try {
runSuspendCatching {
val fetchedRates = currencyService.fetchLatestRates()
Expand All @@ -160,6 +161,7 @@ class CurrencyRepo @Inject constructor(
}
} finally {
isRefreshing = false
_currencyState.update { it.copy(isRefreshing = false) }
}
}

Expand Down Expand Up @@ -273,6 +275,7 @@ data class CurrencyState(
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null,
val isRefreshing: Boolean = false,
)

val CurrencyState.paykitRate: PaykitExchangeRate?
Expand Down
101 changes: 73 additions & 28 deletions app/src/main/java/to/bitkit/repositories/UsdtRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,23 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import to.bitkit.data.CacheStore
import to.bitkit.data.CachedUsdtBalance
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.PaykitUsdt
import to.bitkit.services.UsdtService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.security.MessageDigest
import java.text.DecimalFormatSymbols
import java.util.Locale
import javax.inject.Inject
Expand All @@ -44,9 +49,16 @@ import kotlin.time.Instant
@Singleton
class UsdtRepo @Inject constructor(
private val service: UsdtService,
private val cacheStore: CacheStore,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val clock: Clock = Clock.System,
) {
companion object {
private const val TAG = "UsdtRepo"
}

private val displayCacheNetwork = MessageDigest.getInstance("SHA-256")
.digest("${Env.network}:${Env.usdtRpcUrl}:${Env.usdtBundlerUrl}".encodeToByteArray()).contentToString()
private val mutex = Mutex()

@Volatile var isSendPresented = false
Expand All @@ -65,35 +77,55 @@ class UsdtRepo @Inject constructor(
if (clock.now().toEpochMilliseconds() < refreshAfter) {
return@withLock if (force) Result.failure(UsdtException.RateLimited()) else Result.success(Unit)
}
runSuspendCatching {
val wallet = service.wallet()
val address = wallet.receiveAddress()
if (_state.value.address != address) _state.update { UsdtWalletState(address = address) }
val previous = _state.value.transfers.takeIf { _state.value.historyComplete }
val saved = wallet.history().toImmutableList()
_state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) }
val settlement = runSuspendCatching {
val balance = wallet.balance()
_state.update { it.copy(balance = balance) }
val transfers = wallet.refreshTransfers().toImmutableList()
_state.update { it.copy(transfers = transfers) }
}
if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow()
if (history && !isSendPresented) {
val complete = wallet.syncHistory()
val current = wallet.history().toImmutableList()
_state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) }
announceNewReceipts(previous, current)
}
settlement.getOrThrow()
}.also {
if (it.exceptionOrNull() is UsdtException.RateLimited) {
rateLimitedUntil = clock.now().toEpochMilliseconds() + 60.seconds.inWholeMilliseconds
_state.update { it.copy(refreshing = true, balanceIsStale = true) }
try {
runSuspendCatching {
val wallet = service.wallet()
val address = wallet.receiveAddress()
if (_state.value.address != address) {
_state.update { UsdtWalletState(address = address, refreshing = true) }
}
if (_state.value.balance == null) {
runSuspendCatching { cacheStore.data.first().usdtDisplayBalance }
.onSuccess { cached ->
_state.update {
it.copy(cachedBalance = cached?.balanceFor(address, displayCacheNetwork))
}
}.onFailure { Logger.warn("Failed to load display balance", it, context = TAG) }
}
val previous = _state.value.transfers.takeIf { _state.value.historyComplete }
val saved = wallet.history().toImmutableList()
_state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) }
val settlement = runSuspendCatching {
val balance = wallet.balance()
_state.update { it.copy(balance = balance, balanceIsStale = false) }
runSuspendCatching {
cacheStore.update {
it.copy(usdtDisplayBalance = CachedUsdtBalance(address, displayCacheNetwork, balance))
}
}.onFailure { Logger.warn("Failed to cache display balance", it, context = TAG) }
val transfers = wallet.refreshTransfers().toImmutableList()
_state.update { it.copy(transfers = transfers) }
}
if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow()
if (history && !isSendPresented) {
val complete = wallet.syncHistory()
val current = wallet.history().toImmutableList()
_state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) }
announceNewReceipts(previous, current)
}
settlement.getOrThrow()
}.also {
if (it.exceptionOrNull() is UsdtException.RateLimited) {
rateLimitedUntil = clock.now().toEpochMilliseconds() + 60.seconds.inWholeMilliseconds
}
nextRefresh = maxOf(
rateLimitedUntil,
clock.now().toEpochMilliseconds() + 10.seconds.inWholeMilliseconds
)
}
nextRefresh = maxOf(
rateLimitedUntil,
clock.now().toEpochMilliseconds() + 10.seconds.inWholeMilliseconds
)
} finally {
_state.update { it.copy(refreshing = false) }
}
}
}
Expand Down Expand Up @@ -155,9 +187,13 @@ class UsdtRepo @Inject constructor(
runSuspendCatching {
val wallet = service.wallet(requiresNetwork = false)
wallet.restoreBackup(snapshot)
clearDisplayBalance()
_state.update {
it.copy(
address = wallet.receiveAddress(),
balance = null,
cachedBalance = null,
balanceIsStale = true,
transfers = wallet.history().toImmutableList(),
historyComplete = false,
pendingReceipts = persistentListOf(),
Expand Down Expand Up @@ -194,6 +230,7 @@ class UsdtRepo @Inject constructor(

suspend fun wipe(clearCredentials: suspend () -> Unit = {}) = withContext(ioDispatcher) {
mutex.withLock {
clearDisplayBalance()
clearCredentials()
runSuspendCatching { service.wipe() }.also {
_state.update { UsdtWalletState() }
Expand All @@ -203,6 +240,11 @@ class UsdtRepo @Inject constructor(
}
}

private suspend fun clearDisplayBalance() {
runSuspendCatching { cacheStore.update { it.copy(usdtDisplayBalance = null) } }
.onFailure { Logger.warn("Failed to clear display balance", it, context = TAG) }
}

suspend fun quote(
recipient: String,
amount: String,
Expand Down Expand Up @@ -264,6 +306,9 @@ data class UsdtWalletState(
val address: String = "",
val receiveUri: String = "",
val balance: ULong? = null,
val cachedBalance: ULong? = null,
val balanceIsStale: Boolean = true,
val refreshing: Boolean = false,
val transfers: ImmutableList<UsdtTransfer> = persistentListOf(),
val historyComplete: Boolean = false,
val pendingReceipts: ImmutableList<UsdtTransfer> = persistentListOf(),
Expand Down
17 changes: 10 additions & 7 deletions app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavHostController
import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.UsdtTransferStatus
import com.synonym.bitkitcore.usdtFormatAmount
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
Expand All @@ -126,7 +125,6 @@ import to.bitkit.models.BalanceState
import to.bitkit.models.BannerItem
import to.bitkit.models.HwWallet
import to.bitkit.models.MoneyType
import to.bitkit.models.PaykitExchangeRate
import to.bitkit.models.Suggestion
import to.bitkit.models.TransportType
import to.bitkit.models.WidgetSize
Expand All @@ -145,6 +143,7 @@ import to.bitkit.ui.appViewModel
import to.bitkit.ui.components.ActivityBanner
import to.bitkit.ui.components.AppStatus
import to.bitkit.ui.components.BalanceHeaderView
import to.bitkit.ui.components.Caption13Up
import to.bitkit.ui.components.CaptionB
import to.bitkit.ui.components.EmptyStateView
import to.bitkit.ui.components.FillHeight
Expand All @@ -164,7 +163,6 @@ import to.bitkit.ui.components.Title
import to.bitkit.ui.components.TopBarSpacer
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.components.WalletBalanceView
import to.bitkit.ui.components.usdtDisplaySats
import to.bitkit.ui.navToActivityDetail
import to.bitkit.ui.navigateTo
import to.bitkit.ui.navigateToAllActivity
Expand Down Expand Up @@ -623,9 +621,6 @@ private fun UsdtHomeRefreshEffect() {
UsdtRefreshEffect(viewModel, pending = wallet.transfers.any { it.status == UsdtTransferStatus.PENDING })
}

private fun usdtHeadlineSats(balance: ULong?, visible: Boolean, rate: PaykitExchangeRate?): Long? =
if (visible && balance != 0uL) balance?.let { usdtDisplaySats(usdtFormatAmount(it), rate) } else 0L

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WalletPage(
Expand All @@ -649,7 +644,8 @@ private fun WalletPage(
val slotCapacity = if (isSmallScreen) SMALL_SCREEN_SLOT_CAPACITY else LARGE_SCREEN_SLOT_CAPACITY
val showUsdt = appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true
val rate = LocalCurrencies.current.paykitRate
val usdtSats = usdtHeadlineSats(usdt.balance, showUsdt, rate)
val usdtSats = usdtHeadlineSats(usdt.balance ?: usdt.cachedBalance, showUsdt, rate)
val balanceUpdating = showUsdt && usdtBalanceUpdating(usdt, LocalCurrencies.current)
val headlineSats = BigInteger(
balances.totalWithHardwareSats.toString()
).add(BigInteger.valueOf(usdtSats ?: 0L)).min(BigInteger.valueOf(Long.MAX_VALUE)).toLong()
Expand Down Expand Up @@ -692,6 +688,13 @@ private fun WalletPage(
.fillMaxWidth()
.testTag("TotalBalance")
)
if (balanceUpdating) {
Caption13Up(
text = stringResource(R.string.wallet__balance_updating),
color = Colors.White64,
modifier = Modifier.padding(top = 8.dp).testTag("TotalBalanceUpdating")
)
}
VerticalSpacer(32.dp)
BalancesSection(
balances = balances,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class HomeViewModel @Inject constructor(
}.combine(
combine(settingsStore.data, usdtRepo.state) { settings, usdt ->
Env.isUsdtEnabled && (settings.isUsdtEnabled || usdt.hasFundsOrPendingTransfers) &&
((usdt.balance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty())
((usdt.balance ?: usdt.cachedBalance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty())
}
) { hasActivity, hasUsdtActivity ->
hasActivity || hasUsdtActivity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package to.bitkit.ui.screens.wallets

import to.bitkit.ext.nowMillis
import to.bitkit.models.PaykitAmount
import to.bitkit.models.PaykitAsset
import to.bitkit.models.PaykitExchangeRate
import to.bitkit.repositories.CurrencyState
import to.bitkit.repositories.UsdtWalletState
import to.bitkit.repositories.paykitRate
import to.bitkit.ui.components.usdtDisplaySats

internal fun usdtHeadlineSats(
balance: ULong?,
visible: Boolean,
rate: PaykitExchangeRate?,
now: Long = nowMillis(),
): Long? {
if (!visible || balance == 0uL) return 0L
if (balance == null || rate == null || rate.timestampMillis > now) return null
// Home can show the last known value; payment conversions still require a fresh rate.
return usdtDisplaySats(PaykitAmount(PaykitAsset.USDT, balance).value, rate, now = rate.timestampMillis)
}

internal fun usdtBalanceUpdating(usdt: UsdtWalletState, currency: CurrencyState, now: Long = nowMillis()): Boolean =
usdt.balanceIsStale || usdt.refreshing ||
(
usdt.balance != 0uL && (
currency.isRefreshing || runCatching { currency.paykitRate?.value(now) }.getOrNull() == null
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ fun RowScope.UsdtWalletBalance(onClick: () -> Unit, viewModel: UsdtViewModel = h
if (settings.hideBalance) {
UiConstants.HIDE_BALANCE_SHORT
} else {
wallet.balance?.let { "$ " + usdtOverviewAmount(it) } ?: "—"
(wallet.balance ?: wallet.cachedBalance)?.let { "$ " + usdtOverviewAmount(it) } ?: "—"
},
style = UsdtDetailStyles.HistoryTitle
)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1732,4 +1732,5 @@
<string name="widgets__widget__settings">إعدادات الأداة</string>
<string name="widgets__widget__size_small">صغير</string>
<string name="widgets__widget__size_wide">عريض</string>
<string name="wallet__balance_updating">جارٍ تحديث الرصيد…</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-b+es+419/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1729,4 +1729,5 @@
<string name="widgets__widget__settings">Ajustes del widget</string>
<string name="widgets__widget__size_small">Pequeño</string>
<string name="widgets__widget__size_wide">Ancho</string>
<string name="wallet__balance_updating">Actualizando saldo…</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ca/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1729,4 +1729,5 @@
<string name="widgets__widget__settings">Configuració del giny</string>
<string name="widgets__widget__size_small">Petit</string>
<string name="widgets__widget__size_wide">Ample</string>
<string name="wallet__balance_updating">S’està actualitzant el saldo…</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-cs/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1730,4 +1730,5 @@
<string name="widgets__widget__settings">Nastavení widgetu</string>
<string name="widgets__widget__size_small">Malý</string>
<string name="widgets__widget__size_wide">Široký</string>
<string name="wallet__balance_updating">Aktualizace zůstatku…</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1728,4 +1728,5 @@
<string name="widgets__widget__settings">Widget-Einstellungen</string>
<string name="widgets__widget__size_small">Klein</string>
<string name="widgets__widget__size_wide">Breit</string>
<string name="wallet__balance_updating">Kontostand wird aktualisiert…</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-el/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1728,4 +1728,5 @@
<string name="widgets__widget__settings">Ρυθμίσεις widget</string>
<string name="widgets__widget__size_small">Μικρό</string>
<string name="widgets__widget__size_wide">Φαρδύ</string>
<string name="wallet__balance_updating">Ενημέρωση υπολοίπου…</string>
</resources>
Loading
Loading