Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Block numberpad input above max amount on Send, Transfer to Spending, and Receiving Capacity screens, with short toast explaining the limit #908

## [2.2.0] - 2026-04-07

### Fixed
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/Toast.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class Toast(
enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR }

companion object {
const val VISIBILITY_TIME_SHORT = 1500L
const val VISIBILITY_TIME_DEFAULT = 3000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices.NEXUS_5
Expand Down Expand Up @@ -46,6 +47,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.TransferEffect
import to.bitkit.viewmodels.TransferToSpendingUiState
Expand All @@ -64,6 +66,7 @@ fun SpendingAdvancedScreen(
) {
val currentOnOrderCreated by rememberUpdatedState(onOrderCreated)
val app = appViewModel ?: return
val context = LocalContext.current
val state by viewModel.spendingUiState.collectAsStateWithLifecycle()
val order = state.order ?: return
val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
Expand All @@ -79,6 +82,10 @@ fun SpendingAdvancedScreen(
viewModel.onReceivingAmountChange(amountUiState.sats)
}

LaunchedEffect(transferValues.maxLspBalance) {
amountInputViewModel.setMaxAmount(transferValues.maxLspBalance.toLong())
}

LaunchedEffect(Unit) {
viewModel.transferEffects.collect { effect ->
when (effect) {
Expand All @@ -100,6 +107,20 @@ fun SpendingAdvancedScreen(
}
}

LaunchedEffect(amountInputViewModel) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> app.toast(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.lightning__spending_advanced__error_max__title),
description = context.getString(R.string.lightning__spending_advanced__error_max__description)
.replace("{amount}", "${transferValues.maxLspBalance}"),
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
)
}
}
}

val isValid = transferValues.let {
val amount = amountUiState.sats.toULong()
amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.TransferEffect
import to.bitkit.viewmodels.TransferToSpendingUiState
Expand Down Expand Up @@ -78,6 +79,18 @@ fun SpendingAmountScreen(
}
}

LaunchedEffect(amountInputViewModel) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> toast(
context.getString(R.string.lightning__spending_amount__error_max__title),
context.getString(R.string.lightning__spending_amount__error_max__description)
.replace("{amount}", "${uiState.maxAllowedToSend}"),
)
}
}
}

Content(
isNodeRunning = isNodeRunning,
uiState = uiState,
Expand Down Expand Up @@ -155,6 +168,10 @@ private fun SpendingAmountNodeRunning(
onClickMaxAmount: () -> Unit,
onConfirmAmount: () -> Unit,
) {
LaunchedEffect(uiState.maxAllowedToSend) {
amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend)
}

Column(
modifier = Modifier
.padding(horizontal = 16.dp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.shared.util.gradientBackground
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputUiState
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.LnurlParams
Expand Down Expand Up @@ -91,6 +92,19 @@ fun SendAmountScreen(
currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong()))
}

LaunchedEffect(amountInputViewModel) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> app?.toast(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.wallet__send_amount_exceeded__title),
description = context.getString(R.string.wallet__send_amount_exceeded__description),
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
)
}
}
}

LaunchedEffect(uiState.decodedInvoice, uiState.payMethod) {
if (uiState.payMethod == SendMethod.LIGHTNING && uiState.decodedInvoice != null) {
currentOnEvent(SendEvent.EstimateMaxRoutingFee)
Expand Down Expand Up @@ -203,6 +217,10 @@ private fun SendAmountNodeRunning(
}
}

LaunchedEffect(availableAmount) {
amountInputViewModel.setMaxAmount(availableAmount)
}

Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
Expand Down
30 changes: 26 additions & 4 deletions app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -52,8 +55,16 @@ class AmountInputViewModel @Inject constructor(
private val _uiState = MutableStateFlow(AmountInputUiState())
val uiState: StateFlow<AmountInputUiState> = _uiState.asStateFlow()

private val _effect = MutableSharedFlow<AmountInputEffect>(extraBufferCapacity = 1)
val effect: SharedFlow<AmountInputEffect> = _effect.asSharedFlow()

private var maxAmount: Long = MAX_AMOUNT
private var rawInputText: String = ""

fun setMaxAmount(amount: Long) {
maxAmount = amount.coerceIn(0, MAX_AMOUNT)
}

fun handleNumberPadInput(
key: String,
currencyState: CurrencyState,
Expand All @@ -74,7 +85,7 @@ class AmountInputViewModel @Inject constructor(
if (primaryDisplay == PrimaryDisplay.BITCOIN && isModern) {
val newAmount = convertToSats(newText, primaryDisplay, isModern = true)

if (newAmount <= MAX_AMOUNT) {
if (newAmount <= maxAmount) {
rawInputText = newText
_uiState.update {
it.copy(
Expand All @@ -84,14 +95,14 @@ class AmountInputViewModel @Inject constructor(
)
}
} else {
// Block input when limit exceeded
emitMaxExceeded()
triggerErrorState(key)
}
} else {
// For decimal input, check limits before updating state
if (newText.isNotEmpty()) {
val newAmount = convertToSats(newText, primaryDisplay, isModern)
if (newAmount <= MAX_AMOUNT) {
if (newAmount <= maxAmount) {
// Update both raw input and display text
rawInputText = newText
_uiState.update {
Expand All @@ -106,7 +117,7 @@ class AmountInputViewModel @Inject constructor(
)
}
} else {
// Block input when limit exceeded
emitMaxExceeded()
triggerErrorState(key)
}
} else {
Expand Down Expand Up @@ -251,9 +262,16 @@ class AmountInputViewModel @Inject constructor(

fun clearInput() {
rawInputText = ""
maxAmount = MAX_AMOUNT
_uiState.update { AmountInputUiState() }
}

private fun emitMaxExceeded() {
if (maxAmount < MAX_AMOUNT) {
_effect.tryEmit(AmountInputEffect.MaxExceeded)
}
}

private fun triggerErrorState(key: String) {
_uiState.update { it.copy(errorKey = key) }
viewModelScope.launch {
Expand Down Expand Up @@ -412,6 +430,10 @@ data class AmountInputUiState(
val errorKey: String? = null,
)

sealed interface AmountInputEffect {
data object MaxExceeded : AmountInputEffect
}

@SuppressLint("ViewModelConstructorInComposable")
@Composable
fun previewAmountInputViewModel(
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@
<string name="lightning__setting_up_text">Please wait, your funds transfer is in progress. This should take &lt;accent&gt;±10 minutes.&lt;/accent&gt;</string>
<string name="lightning__spendable_onchain">Spendable Onchain</string>
<string name="lightning__spending">Spending</string>
<string name="lightning__spending_advanced__error_max__description">The receiving capacity is currently limited to ₿ {amount}.</string>
<string name="lightning__spending_advanced__error_max__title">Receiving Capacity Maximum</string>
<string name="lightning__spending_advanced__fee">Liquidity fee</string>
<string name="lightning__spending_advanced__title">Receiving\n&lt;accent&gt;capacity&lt;/accent&gt;</string>
<string name="lightning__spending_amount__error_max__description">The amount you can transfer to your spending balance is currently limited to ₿ {amount}.</string>
Expand Down Expand Up @@ -1056,6 +1058,8 @@
<string name="wallet__send">Send</string>
<string name="wallet__send_address_placeholder">Enter an invoice, address, or profile key</string>
<string name="wallet__send_amount">Bitcoin Amount</string>
<string name="wallet__send_amount_exceeded__description">The amount exceeds your available balance.</string>
<string name="wallet__send_amount_exceeded__title">Insufficient balance</string>
<string name="wallet__send_available">Available</string>
<string name="wallet__send_available_savings">Available (savings)</string>
<string name="wallet__send_available_spending">Available (spending)</string>
Expand Down
108 changes: 108 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package to.bitkit.viewmodels
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
Expand Down Expand Up @@ -919,6 +921,112 @@ class AmountInputViewModelTest : BaseUnitTest() {
assertTrue("Toggle operation should complete without error", true)
}

// MARK: - Max Amount Enforcement Tests

@Test
fun `max amount blocks input when exceeded`() = test {
val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN)

viewModel.setMaxAmount(500)

// Type 50 - should succeed
viewModel.handleNumberPadInput("5", currency)
viewModel.handleNumberPadInput("0", currency)
assertEquals(50L, viewModel.uiState.value.sats)

// Type 0 to make 500 - should succeed
viewModel.handleNumberPadInput("0", currency)
assertEquals(500L, viewModel.uiState.value.sats)

// Type 0 to make 5000 - should be blocked
viewModel.handleNumberPadInput("0", currency)
assertEquals(500L, viewModel.uiState.value.sats)
assertNotNull(viewModel.uiState.value.errorKey)
}

@Test
fun `max exceeded effect is emitted when dynamic limit is hit`() = test {
val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN)

viewModel.setMaxAmount(100)

// Type 100 - should succeed
"100".forEach { viewModel.handleNumberPadInput(it.toString(), currency) }
assertEquals(100L, viewModel.uiState.value.sats)

// Collect effect
var effectReceived = false
val job = backgroundScope.launch(testDispatcher) {
viewModel.effect.collect {
if (it is AmountInputEffect.MaxExceeded) effectReceived = true
}
}

// Type 0 to make 1000 - should be blocked and emit effect
viewModel.handleNumberPadInput("0", currency)
assertTrue(effectReceived)

job.cancel()
}

@Test
fun `no max exceeded effect when hitting global MAX_AMOUNT`() = test {
val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN)

// Don't set a custom max - use default MAX_AMOUNT

// Type max amount
"999999999".forEach { viewModel.handleNumberPadInput(it.toString(), currency) }
assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.sats)

// Collect effect
var effectReceived = false
val job = backgroundScope.launch(testDispatcher) {
viewModel.effect.collect {
if (it is AmountInputEffect.MaxExceeded) effectReceived = true
}
}

// Try to exceed - should be blocked but NOT emit MaxExceeded
viewModel.handleNumberPadInput("0", currency)
assertFalse(effectReceived)

job.cancel()
}

@Test
fun `clearInput resets max amount to default`() = test {
val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN)

viewModel.setMaxAmount(100)
viewModel.handleNumberPadInput("5", currency)
viewModel.clearInput()

// After clear, max should be reset to MAX_AMOUNT
// Type amount above old max (100) but below MAX_AMOUNT
"500".forEach { viewModel.handleNumberPadInput(it.toString(), currency) }
assertEquals(500L, viewModel.uiState.value.sats)
}

@Test
fun `dynamic max amount update is respected mid-input`() = test {
val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN)

viewModel.setMaxAmount(1000)

// Type 500 - should succeed
"500".forEach { viewModel.handleNumberPadInput(it.toString(), currency) }
assertEquals(500L, viewModel.uiState.value.sats)

// Lower the max to 300
viewModel.setMaxAmount(300)

// Type 0 to make 5000 - should be blocked (above new max of 300)
viewModel.handleNumberPadInput("0", currency)
assertEquals(500L, viewModel.uiState.value.sats)
assertNotNull(viewModel.uiState.value.errorKey)
}

@Test
fun `classic conversion calculations are accurate`() = test {
val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC)
Expand Down
Loading