diff options
author | Iván Ávalos <avalos@disroot.org> | 2023-02-23 11:30:29 -0600 |
---|---|---|
committer | Torsten Grote <t@grobox.de> | 2023-03-21 11:52:36 -0300 |
commit | 1c979ef1d0efd8bdaed7dda292825c41f1d48893 (patch) | |
tree | 57f27d92794b032a5ae7513b380ccfcbd0c2ea4b | |
parent | 0532aa1e215b957397432a8b0c03b7f867ab8cb0 (diff) | |
download | taler-android-1c979ef1d0efd8bdaed7dda292825c41f1d48893.tar.gz taler-android-1c979ef1d0efd8bdaed7dda292825c41f1d48893.tar.bz2 taler-android-1c979ef1d0efd8bdaed7dda292825c41f1d48893.zip |
[wallet] Add support for expiration of peer payments
bug 0007439
9 files changed, 316 insertions, 24 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt new file mode 100644 index 0000000..c9d2fc5 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt @@ -0,0 +1,71 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NumericInputField( + modifier: Modifier = Modifier, + value: Long, + onValueChange: (Long) -> Unit, + readOnly: Boolean = true, + label: @Composable () -> Unit, + minValue: Long? = 0L, + maxValue: Long? = null, +) { + OutlinedTextField( + modifier = modifier, + value = value.toString(), + readOnly = readOnly, + onValueChange = { + val dd = it.toLongOrNull() ?: 0 + onValueChange(dd) + }, + trailingIcon = { + Row { + IconButton( + content = { Icon(Icons.Default.Remove, "add1") }, + onClick = { + if (minValue != null && value - 1 >= minValue) { + onValueChange(value - 1) + } + }, + ) + IconButton( + content = { Icon(Icons.Default.Add, "add1") }, + onClick = { + if (maxValue != null && value + 1 <= maxValue) { + onValueChange(value + 1) + } + }, + ) + } + }, + label = label, + ) +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt new file mode 100644 index 0000000..454bbfa --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt @@ -0,0 +1,48 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.compose + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun <T> SelectionChip( + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean, + value: T, + onSelected: (T) -> Unit, +) { + val theme = MaterialTheme.colorScheme + SuggestionChip( + label = label, + modifier = modifier, + onClick = { + onSelected(value) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = if (selected) theme.primaryContainer else Color.Transparent, + labelColor = if (selected) theme.onPrimaryContainer else theme.onSurface + ) + ) +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt new file mode 100644 index 0000000..4393e47 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt @@ -0,0 +1,128 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.wallet.R +import net.taler.wallet.compose.NumericInputField +import net.taler.wallet.compose.SelectionChip +import net.taler.wallet.compose.TalerSurface + +enum class ExpirationOption(val hours: Long) { + DAYS_1(24), + DAYS_7(24 * 7), + DAYS_30(24 * 30), + CUSTOM(-1) +} + +@Composable +fun ExpirationComposable( + modifier: Modifier = Modifier, + option: ExpirationOption, + hours: Long, + onOptionChange: (ExpirationOption) -> Unit, + onHoursChange: (Long) -> Unit, +) { + val options = listOf( + ExpirationOption.DAYS_1 to stringResource(R.string.send_peer_expiration_1d), + ExpirationOption.DAYS_7 to stringResource(R.string.send_peer_expiration_7d), + ExpirationOption.DAYS_30 to stringResource(R.string.send_peer_expiration_30d), + ExpirationOption.CUSTOM to stringResource(R.string.send_peer_expiration_custom), + ) + Column( + modifier = modifier, + ) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + items(items = options, key = { it.first }) { + SelectionChip( + label = { Text(it.second) }, + modifier = Modifier.padding(horizontal = 4.dp), + selected = it.first == option, + value = it.first, + onSelected = { o -> + onOptionChange(o) + if (o != ExpirationOption.CUSTOM) { + onHoursChange(o.hours) + } + }, + ) + } + } + + if (option == ExpirationOption.CUSTOM) { + val d = hours / 24L + val h = hours - d * 24L + Row( + modifier = Modifier.fillMaxWidth(), + ) { + NumericInputField( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(end = 4.dp), + value = d, + onValueChange = { + onHoursChange(it * 24 + h) + }, + label = { Text(stringResource(R.string.send_peer_expiration_days)) }, + maxValue = 365, + ) + NumericInputField( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 4.dp), + value = h, + onValueChange = { + onHoursChange(d * 24 + it) + }, + label = { Text(stringResource(R.string.send_peer_expiration_hours)) }, + maxValue = 23, + ) + } + } + } +} + +@Preview +@Composable +fun ExpirationComposablePreview() { + TalerSurface { + var option = ExpirationOption.CUSTOM + var hours = 25L + ExpirationComposable( + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt index 79030f1..565aeb1 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -76,7 +76,7 @@ class OutgoingPullFragment : Fragment() { if (!requireActivity().isChangingConfigurations) peerManager.resetPullPayment() } - private fun onCreateInvoice(amount: Amount, summary: String, exchange: ExchangeItem) { - peerManager.initiatePeerPullCredit(amount, summary, exchange) + private fun onCreateInvoice(amount: Amount, summary: String, hours: Long, exchange: ExchangeItem) { + peerManager.initiatePeerPullCredit(amount, summary, hours, exchange) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt index a7cd2a8..a8f24fd 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullIntroComposable.kt @@ -50,6 +50,7 @@ import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.transactions.AmountType import net.taler.wallet.transactions.TransactionAmountComposable import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.peer.ExpirationOption.DAYS_1 import kotlin.random.Random @OptIn(ExperimentalMaterial3Api::class) @@ -57,7 +58,7 @@ import kotlin.random.Random fun OutgoingPullIntroComposable( amount: Amount, state: OutgoingState, - onCreateInvoice: (amount: Amount, subject: String, exchange: ExchangeItem) -> Unit, + onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -72,7 +73,6 @@ fun OutgoingPullIntroComposable( OutlinedTextField( modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp, start = 16.dp, end = 16.dp) .focusRequester(focusRequester), singleLine = true, value = subject, @@ -96,7 +96,7 @@ fun OutgoingPullIntroComposable( Text( modifier = Modifier .fillMaxWidth() - .padding(top = 5.dp, end = 16.dp), + .padding(top = 5.dp), color = if (subject.isBlank()) MaterialTheme.colorScheme.error else Color.Unspecified, text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT), textAlign = TextAlign.End, @@ -119,6 +119,19 @@ fun OutgoingPullIntroComposable( label = stringResource(id = R.string.withdraw_exchange), info = if (exchangeItem == null) "" else cleanExchange(exchangeItem.exchangeBaseUrl), ) + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = stringResource(R.string.send_peer_expiration_period), + style = MaterialTheme.typography.bodyMedium, + ) + var option by rememberSaveable { mutableStateOf(DAYS_1) } + var hours by rememberSaveable { mutableStateOf(1L) } + ExpirationComposable( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } Button( modifier = Modifier.padding(16.dp), enabled = subject.isNotBlank() && state is OutgoingChecked, @@ -126,6 +139,7 @@ fun OutgoingPullIntroComposable( onCreateInvoice( amount, subject, + hours, exchangeItem ?: error("clickable without exchange") ) }, @@ -142,7 +156,7 @@ fun PreviewReceiveFundsCheckingIntro() { OutgoingPullIntroComposable( Amount.fromDouble("TESTKUDOS", 42.23), if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, - ) { _, _, _ -> } + ) { _, _, _, _ -> } } } @@ -156,6 +170,6 @@ fun PreviewReceiveFundsCheckedIntro() { OutgoingPullIntroComposable( Amount.fromDouble("TESTKUDOS", 42.23), OutgoingChecked(amountRaw, amountEffective, exchangeItem) - ) { _, _, _ -> } + ) { _, _, _, _ -> } } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt index 7019757..0aa029b 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -74,7 +74,7 @@ class OutgoingPushFragment : Fragment() { if (!requireActivity().isChangingConfigurations) peerManager.resetPushPayment() } - private fun onSend(amount: Amount, summary: String) { - peerManager.initiatePeerPushDebit(amount, summary) + private fun onSend(amount: Amount, summary: String, hours: Long) { + peerManager.initiatePeerPushDebit(amount, summary, hours) } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt index 33e8390..63542a8 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushIntroComposable.kt @@ -16,7 +16,6 @@ package net.taler.wallet.peer -import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -29,12 +28,16 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -42,6 +45,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.wallet.R +import net.taler.wallet.peer.ExpirationOption.DAYS_1 import kotlin.random.Random @OptIn(ExperimentalMaterial3Api::class) @@ -49,7 +53,7 @@ import kotlin.random.Random fun OutgoingPushIntroComposable( state: OutgoingState, amount: Amount, - onSend: (amount: Amount, summary: String) -> Unit, + onSend: (amount: Amount, summary: String, hours: Long) -> Unit, ) { val scrollState = rememberScrollState() Column( @@ -58,9 +62,9 @@ fun OutgoingPushIntroComposable( .padding(16.dp) .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, - verticalArrangement = spacedBy(16.dp), ) { Text( + modifier = Modifier.padding(vertical = 16.dp), text = amount.toString(), softWrap = false, style = MaterialTheme.typography.titleLarge, @@ -68,6 +72,7 @@ fun OutgoingPushIntroComposable( if (state is OutgoingChecked) { val fee = state.amountEffective - state.amountRaw Text( + modifier = Modifier.padding(vertical = 16.dp), text = stringResource(id = R.string.payment_fee, fee), softWrap = false, color = MaterialTheme.colorScheme.error, @@ -75,8 +80,11 @@ fun OutgoingPushIntroComposable( } var subject by rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), singleLine = true, value = subject, onValueChange = { input -> @@ -93,21 +101,37 @@ fun OutgoingPushIntroComposable( ) } ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } Text( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(top = 5.dp), color = if (subject.isBlank()) MaterialTheme.colorScheme.error else Color.Unspecified, text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT), textAlign = TextAlign.End, ) Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = stringResource(R.string.send_peer_expiration_period), + style = MaterialTheme.typography.bodyMedium, + ) + var option by rememberSaveable { mutableStateOf(DAYS_1) } + var hours by rememberSaveable { mutableStateOf(DAYS_1.hours) } + ExpirationComposable( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } + Text( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), text = stringResource(R.string.send_peer_warning), ) Button( enabled = state is OutgoingChecked && subject.isNotBlank(), - onClick = { - onSend(amount, subject) - }, + onClick = { onSend(amount, subject, hours) }, ) { Text(text = stringResource(R.string.send_peer_create_button)) } @@ -119,7 +143,7 @@ fun OutgoingPushIntroComposable( fun PeerPushIntroComposableCheckingPreview() { Surface { val state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking - OutgoingPushIntroComposable(state, Amount.fromDouble("TESTKUDOS", 42.23)) { _, _ -> } + OutgoingPushIntroComposable(state, Amount.fromDouble("TESTKUDOS", 42.23)) { _, _, _ -> } } } @@ -130,6 +154,6 @@ fun PeerPushIntroComposableCheckedPreview() { val amountEffective = Amount.fromDouble("TESTKUDOS", 42.42) val amountRaw = Amount.fromDouble("TESTKUDOS", 42.23) val state = OutgoingChecked(amountRaw, amountEffective) - OutgoingPushIntroComposable(state, amountEffective) { _, _ -> } + OutgoingPushIntroComposable(state, amountEffective) { _, _, _ -> } } } diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt index f031d44..f7796bb 100644 --- a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt +++ b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -34,7 +34,7 @@ import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeManager import org.json.JSONObject -import java.util.concurrent.TimeUnit.DAYS +import java.util.concurrent.TimeUnit.HOURS const val MAX_LENGTH_SUBJECT = 100 @@ -82,10 +82,10 @@ class PeerManager( } } - fun initiatePeerPullCredit(amount: Amount, summary: String, exchange: ExchangeItem) { + fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchange: ExchangeItem) { _outgoingPullState.value = OutgoingCreating scope.launch(Dispatchers.IO) { - val expiry = Timestamp.fromMillis(System.currentTimeMillis() + DAYS.toMillis(3)) + val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) api.request("initiatePeerPullCredit", InitiatePeerPullPaymentResponse.serializer()) { put("exchangeBaseUrl", exchange.exchangeBaseUrl) put("partialContractTerms", JSONObject().apply { @@ -125,10 +125,10 @@ class PeerManager( } } - fun initiatePeerPushDebit(amount: Amount, summary: String) { + fun initiatePeerPushDebit(amount: Amount, summary: String, expirationHours: Long) { _outgoingPushState.value = OutgoingCreating scope.launch(Dispatchers.IO) { - val expiry = Timestamp.fromMillis(System.currentTimeMillis() + DAYS.toMillis(3)) + val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) api.request("initiatePeerPushDebit", InitiatePeerPullCreditResponse.serializer()) { put("amount", amount.toJSONString()) put("partialContractTerms", JSONObject().apply { diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml index 52dacfe..c313248 100644 --- a/wallet/src/main/res/values/strings.xml +++ b/wallet/src/main/res/values/strings.xml @@ -151,6 +151,13 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <string name="send_peer_payment_instruction">Let the payee scan this QR code to receive:</string> <string name="send_peer_payment_amount_received">Amount received</string> <string name="send_peer_payment_amount_sent">Amount sent</string> + <string name="send_peer_expiration_period">Expires in</string> + <string name="send_peer_expiration_1d">1 day</string> + <string name="send_peer_expiration_7d">7 days</string> + <string name="send_peer_expiration_30d">30 days</string> + <string name="send_peer_expiration_custom">Custom</string> + <string name="send_peer_expiration_days">Days</string> + <string name="send_peer_expiration_hours">Hours</string> <string name="send_peer_purpose">Purpose</string> <string name="pay_peer_title">Pay invoice</string> |