Skip to content

Commit 7948a5b

Browse files
authored
Merge pull request #908 from synonymdev/fix/block-input-over-max
fix: block numberpad input exceeding max amount
2 parents e7547cd + a7a77c3 commit 7948a5b

19 files changed

Lines changed: 702 additions & 32 deletions

Justfile

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ list:
1212
"list" \
1313
"init" \
1414
"compile" \
15-
"run" \
15+
"run [docker]" \
1616
"build [TASK]" \
1717
"release" \
1818
"install" \
@@ -45,12 +45,18 @@ init:
4545
compile:
4646
{{ gradle }} compileDevDebugKotlin
4747

48-
run:
48+
run mode="":
4949
#!/usr/bin/env sh
5050
set -eu
5151
5252
app_id="to.bitkit.dev"
5353
app_dir="app/build/outputs/apk/dev/debug"
54+
mode="{{ mode }}"
55+
56+
if [ -n "$mode" ] && [ "$mode" != "docker" ]; then
57+
echo "usage: just run [docker]" >&2
58+
exit 1
59+
fi
5460
5561
if ! command -v adb >/dev/null 2>&1; then
5662
echo "adb is required to run the app." >&2
@@ -90,8 +96,19 @@ run:
9096
fi
9197
9298
echo "Using $device_name ($device_id)"
99+
100+
build_env=""
101+
if [ "$mode" = "docker" ]; then
102+
echo "Forwarding bitkit-docker ports via adb reverse..."
103+
adb -s "$device_id" reverse tcp:60001 tcp:60001 # local Electrum
104+
adb -s "$device_id" reverse tcp:6288 tcp:6288 # local homegate
105+
adb -s "$device_id" reverse tcp:9735 tcp:9735 # local lnd peer
106+
adb -s "$device_id" reverse tcp:3000 tcp:3000 # local lnurl-server
107+
build_env="E2E=true"
108+
fi
109+
93110
echo "Building Debug app..."
94-
{{ gradle }} assembleDevDebug
111+
env $build_env {{ gradle }} assembleDevDebug
95112
96113
app_path="$(
97114
find "$app_dir" -maxdepth 1 -name '*-universal.apk' -type f \

app/src/main/java/to/bitkit/models/Toast.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ data class Toast(
1111
enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR }
1212

1313
companion object {
14+
const val VISIBILITY_TIME_SHORT = 1500L
1415
const val VISIBILITY_TIME_DEFAULT = 3000L
1516
}
1617
}

app/src/main/java/to/bitkit/ui/components/NumberPad.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue
1919
import androidx.compose.runtime.remember
2020
import androidx.compose.ui.Alignment
2121
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.draw.alpha
2223
import androidx.compose.ui.focus.FocusRequester
2324
import androidx.compose.ui.focus.focusRequester
2425
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -170,15 +171,16 @@ fun NumberPad(
170171
viewModel: AmountInputViewModel,
171172
modifier: Modifier = Modifier,
172173
currencies: CurrencyState = LocalCurrencies.current,
174+
enabled: Boolean = true,
173175
type: NumberPadType = viewModel.getNumberPadType(currencies),
174176
availableHeight: Dp = defaultHeight,
175177
decimalSeparator: String = KEY_DECIMAL,
176178
includeNavigationBarsPadding: Boolean = false,
177179
) {
178180
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
179181
NumberPad(
180-
onPress = { key -> viewModel.handleNumberPadInput(key, currencies) },
181-
modifier = modifier,
182+
onPress = { key -> if (enabled) viewModel.handleNumberPadInput(key, currencies) },
183+
modifier = modifier.alpha(if (enabled) 1f else 0.5f),
182184
type = type,
183185
availableHeight = availableHeight,
184186
decimalSeparator = decimalSeparator,

app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState
1717
import androidx.compose.runtime.setValue
1818
import androidx.compose.ui.Alignment
1919
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.platform.LocalContext
2021
import androidx.compose.ui.platform.testTag
2122
import androidx.compose.ui.res.stringResource
2223
import androidx.compose.ui.tooling.preview.Devices.NEXUS_5
@@ -27,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
2728
import to.bitkit.R
2829
import to.bitkit.ext.mockOrder
2930
import to.bitkit.models.Toast
31+
import to.bitkit.models.formatToModernDisplay
3032
import to.bitkit.repositories.CurrencyState
3133
import to.bitkit.ui.LocalCurrencies
3234
import to.bitkit.ui.appViewModel
@@ -46,6 +48,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
4648
import to.bitkit.ui.theme.AppThemeSurface
4749
import to.bitkit.ui.theme.Colors
4850
import to.bitkit.ui.utils.withAccent
51+
import to.bitkit.viewmodels.AmountInputEffect
4952
import to.bitkit.viewmodels.AmountInputViewModel
5053
import to.bitkit.viewmodels.TransferEffect
5154
import to.bitkit.viewmodels.TransferToSpendingUiState
@@ -64,12 +67,14 @@ fun SpendingAdvancedScreen(
6467
) {
6568
val currentOnOrderCreated by rememberUpdatedState(onOrderCreated)
6669
val app = appViewModel ?: return
70+
val context = LocalContext.current
6771
val state by viewModel.spendingUiState.collectAsStateWithLifecycle()
6872
val order = state.order ?: return
6973
val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
7074
var isLoading by remember { mutableStateOf(false) }
7175

7276
val transferValues by viewModel.transferValues.collectAsStateWithLifecycle()
77+
val currentMaxLspBalance by rememberUpdatedState(transferValues.maxLspBalance)
7378

7479
LaunchedEffect(order.clientBalanceSat) {
7580
viewModel.updateTransferValues(order.clientBalanceSat)
@@ -79,6 +84,10 @@ fun SpendingAdvancedScreen(
7984
viewModel.onReceivingAmountChange(amountUiState.sats)
8085
}
8186

87+
LaunchedEffect(transferValues.maxLspBalance) {
88+
amountInputViewModel.setMaxAmount(transferValues.maxLspBalance.toLong())
89+
}
90+
8291
LaunchedEffect(Unit) {
8392
viewModel.transferEffects.collect { effect ->
8493
when (effect) {
@@ -100,6 +109,20 @@ fun SpendingAdvancedScreen(
100109
}
101110
}
102111

112+
LaunchedEffect(Unit) {
113+
amountInputViewModel.effect.collect {
114+
when (it) {
115+
AmountInputEffect.MaxExceeded -> app.toast(
116+
type = Toast.ToastType.WARNING,
117+
title = context.getString(R.string.lightning__spending_advanced__error_max__title),
118+
description = context.getString(R.string.lightning__spending_advanced__error_max__description)
119+
.replace("{amount}", currentMaxLspBalance.formatToModernDisplay()),
120+
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
121+
)
122+
}
123+
}
124+
}
125+
103126
val isValid = transferValues.let {
104127
val amount = amountUiState.sats.toULong()
105128
amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance

app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import androidx.compose.material3.HorizontalDivider
1515
import androidx.compose.runtime.Composable
1616
import androidx.compose.runtime.LaunchedEffect
1717
import androidx.compose.runtime.getValue
18+
import androidx.compose.runtime.rememberUpdatedState
1819
import androidx.compose.ui.Alignment
1920
import androidx.compose.ui.Modifier
2021
import androidx.compose.ui.platform.LocalContext
@@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp
2627
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
2728
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2829
import to.bitkit.R
30+
import to.bitkit.models.formatToModernDisplay
2931
import to.bitkit.repositories.CurrencyState
3032
import to.bitkit.ui.LocalCurrencies
3133
import to.bitkit.ui.components.ConnectionIssuesView
@@ -47,6 +49,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
4749
import to.bitkit.ui.theme.AppThemeSurface
4850
import to.bitkit.ui.theme.Colors
4951
import to.bitkit.ui.utils.withAccent
52+
import to.bitkit.viewmodels.AmountInputEffect
5053
import to.bitkit.viewmodels.AmountInputViewModel
5154
import to.bitkit.viewmodels.TransferEffect
5255
import to.bitkit.viewmodels.TransferToSpendingUiState
@@ -70,6 +73,7 @@ fun SpendingAmountScreen(
7073
val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle()
7174
val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
7275
val context = LocalContext.current
76+
val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend)
7377

7478
LaunchedEffect(isOffline) {
7579
viewModel.updateLimits()
@@ -85,6 +89,18 @@ fun SpendingAmountScreen(
8589
}
8690
}
8791

92+
LaunchedEffect(Unit) {
93+
amountInputViewModel.effect.collect {
94+
when (it) {
95+
AmountInputEffect.MaxExceeded -> toast(
96+
context.getString(R.string.lightning__spending_amount__error_max__title),
97+
context.getString(R.string.lightning__spending_amount__error_max__description)
98+
.replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()),
99+
)
100+
}
101+
}
102+
}
103+
88104
Box {
89105
Content(
90106
isNodeRunning = isNodeRunning,
@@ -99,7 +115,7 @@ fun SpendingAmountScreen(
99115
toast(
100116
context.getString(R.string.lightning__spending_amount__error_max__title),
101117
context.getString(R.string.lightning__spending_amount__error_max__description)
102-
.replace("{amount}", "$max"),
118+
.replace("{amount}", max.formatToModernDisplay()),
103119
)
104120
}
105121
val cappedQuarter = min(quarter, max)
@@ -174,6 +190,10 @@ private fun SpendingAmountNodeRunning(
174190
onClickMaxAmount: () -> Unit,
175191
onConfirmAmount: () -> Unit,
176192
) {
193+
LaunchedEffect(uiState.maxAllowedToSend) {
194+
amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend)
195+
}
196+
177197
Column(
178198
modifier = Modifier
179199
.padding(horizontal = 16.dp)
@@ -244,6 +264,7 @@ private fun SpendingAmountNodeRunning(
244264
NumberPad(
245265
viewModel = amountInputViewModel,
246266
currencies = currencies,
267+
enabled = !uiState.isLoading,
247268
)
248269

249270
PrimaryButton(

app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
4242
import to.bitkit.ui.theme.AppThemeSurface
4343
import to.bitkit.ui.theme.Colors
4444
import to.bitkit.ui.utils.withAccent
45+
import to.bitkit.viewmodels.AmountInputEffect
4546
import to.bitkit.viewmodels.AmountInputViewModel
4647
import to.bitkit.viewmodels.previewAmountInputViewModel
4748
import kotlin.math.min
@@ -63,6 +64,18 @@ fun ExternalAmountScreen(
6364
viewModel.onAmountChange(amountUiState.sats)
6465
}
6566

67+
LaunchedEffect(uiState.amount.max) {
68+
amountInputViewModel.setMaxAmount(uiState.amount.max)
69+
}
70+
71+
LaunchedEffect(Unit) {
72+
amountInputViewModel.effect.collect {
73+
when (it) {
74+
AmountInputEffect.MaxExceeded -> viewModel.onMaxExceeded()
75+
}
76+
}
77+
}
78+
6679
Content(
6780
amountInputViewModel = amountInputViewModel,
6881
amountState = uiState.amount,
@@ -167,7 +180,7 @@ private fun Content(
167180
PrimaryButton(
168181
text = stringResource(R.string.common__continue),
169182
onClick = { onContinueClick() },
170-
enabled = amountUiState.sats != 0L,
183+
enabled = amountUiState.sats in 1..amountState.max,
171184
modifier = Modifier.testTag("ExternalAmountContinue")
172185
)
173186

app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,20 @@ class ExternalNodeViewModel @Inject constructor(
9797
}
9898

9999
fun onAmountChange(sats: Long) {
100-
val maxAmount = _uiState.value.amount.max
100+
_uiState.update { it.copy(amount = it.amount.copy(sats = sats)) }
101+
}
101102

102-
if (sats > maxAmount) {
103-
viewModelScope.launch {
104-
ToastEventBus.send(
105-
type = Toast.ToastType.ERROR,
106-
title = context.getString(R.string.lightning__spending_amount__error_max__title),
107-
description = context.getString(R.string.lightning__spending_amount__error_max__description)
108-
.replace("{amount}", maxAmount.formatToModernDisplay()),
109-
)
110-
}
111-
return
103+
fun onMaxExceeded() {
104+
val maxAmount = _uiState.value.amount.max
105+
viewModelScope.launch {
106+
ToastEventBus.send(
107+
type = Toast.ToastType.WARNING,
108+
title = context.getString(R.string.lightning__spending_amount__error_max__title),
109+
description = context.getString(R.string.lightning__spending_amount__error_max__description)
110+
.replace("{amount}", maxAmount.formatToModernDisplay()),
111+
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
112+
)
112113
}
113-
114-
_uiState.update { it.copy(amount = it.amount.copy(sats = sats)) }
115114
}
116115

117116
fun onAmountContinue() {

app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight
5656
import to.bitkit.ui.shared.util.gradientBackground
5757
import to.bitkit.ui.theme.AppThemeSurface
5858
import to.bitkit.ui.theme.Colors
59+
import to.bitkit.viewmodels.AmountInputEffect
5960
import to.bitkit.viewmodels.AmountInputUiState
6061
import to.bitkit.viewmodels.AmountInputViewModel
6162
import to.bitkit.viewmodels.LnurlParams
@@ -73,13 +74,30 @@ fun SendAmountScreen(
7374
onBack: () -> Unit,
7475
onEvent: (SendEvent) -> Unit,
7576
currencies: CurrencyState = LocalCurrencies.current,
77+
balances: BalanceState = LocalBalances.current,
7678
amountInputViewModel: AmountInputViewModel = hiltViewModel(),
7779
) {
7880
val app = appViewModel
7981
val context = LocalContext.current
8082
val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
8183
val currentOnEvent by rememberUpdatedState(onEvent)
8284

85+
val maxExceededMessage = run {
86+
val lnurl = uiState.lnurl
87+
val lnurlPayMaxExceeded = lnurl is LnurlParams.LnurlPay &&
88+
lnurl.data.maxSendableSat().toLong() <
89+
(balances.maxSendLightningSats.safe() - uiState.estimatedRoutingFee.safe()).toLong()
90+
when {
91+
lnurl is LnurlParams.LnurlWithdraw ->
92+
R.string.wallet__lnurl_w_error_max__title to R.string.wallet__lnurl_w_error_max__description
93+
lnurlPayMaxExceeded ->
94+
R.string.wallet__lnurl_pay__error_max__title to R.string.wallet__lnurl_pay__error_max__description
95+
else ->
96+
R.string.wallet__send_amount_exceeded__title to R.string.wallet__send_amount_exceeded__description
97+
}
98+
}
99+
val currentMaxExceededMessage by rememberUpdatedState(maxExceededMessage)
100+
83101
LaunchedEffect(Unit) {
84102
if (uiState.amount > 0u) {
85103
amountInputViewModel.setSats(uiState.amount.toLong(), currencies)
@@ -90,6 +108,23 @@ fun SendAmountScreen(
90108
currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong()))
91109
}
92110

111+
LaunchedEffect(Unit) {
112+
amountInputViewModel.effect.collect {
113+
when (it) {
114+
AmountInputEffect.MaxExceeded -> {
115+
val (titleRes, descriptionRes) = currentMaxExceededMessage
116+
app?.toast(
117+
type = Toast.ToastType.WARNING,
118+
title = context.getString(titleRes),
119+
description = context.getString(descriptionRes),
120+
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
121+
testTag = "SendAmountExceededToast",
122+
)
123+
}
124+
}
125+
}
126+
}
127+
93128
LaunchedEffect(uiState.decodedInvoice, uiState.payMethod) {
94129
if (uiState.payMethod == SendMethod.LIGHTNING && uiState.decodedInvoice != null) {
95130
currentOnEvent(SendEvent.EstimateMaxRoutingFee)
@@ -203,6 +238,15 @@ private fun SendAmountNodeRunning(
203238
}
204239
}
205240

241+
val maxAllowed = when (val lnurl = uiState.lnurl) {
242+
is LnurlParams.LnurlPay -> minOf(lnurl.data.maxSendableSat().toLong(), availableAmount)
243+
else -> availableAmount
244+
}
245+
246+
LaunchedEffect(maxAllowed) {
247+
amountInputViewModel.setMaxAmount(maxAllowed)
248+
}
249+
206250
Column(
207251
modifier = Modifier.padding(horizontal = 16.dp)
208252
) {

0 commit comments

Comments
 (0)