diff options
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/exchanges')
6 files changed, 333 insertions, 69 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt index e0cf5be..674632e 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeAdapter.kt @@ -26,25 +26,15 @@ import android.widget.TextView import androidx.appcompat.widget.PopupMenu import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter -import kotlinx.serialization.Serializable import net.taler.wallet.R -import net.taler.wallet.cleanExchange import net.taler.wallet.exchanges.ExchangeAdapter.ExchangeItemViewHolder -@Serializable -data class ExchangeItem( - val exchangeBaseUrl: String, - // can be null before exchange info in wallet-core was fully loaded - val currency: String? = null, - val paytoUris: List<String>, -) { - val name: String get() = cleanExchange(exchangeBaseUrl) -} - interface ExchangeClickListener { fun onExchangeSelected(item: ExchangeItem) fun onManualWithdraw(item: ExchangeItem) fun onPeerReceive(item: ExchangeItem) + fun onExchangeReload(item: ExchangeItem) + fun onExchangeDelete(item: ExchangeItem) } internal class ExchangeAdapter( @@ -80,8 +70,9 @@ internal class ExchangeAdapter( fun bind(item: ExchangeItem) { urlView.text = item.name + // If currency is null, it's because we have no data from the exchange... currencyView.text = if (item.currency == null) { - context.getString(R.string.settings_version_unknown) + context.getString(R.string.exchange_not_contacted) } else { context.getString(R.string.exchange_list_currency, item.currency) } @@ -91,7 +82,8 @@ internal class ExchangeAdapter( } else { itemView.setOnClickListener(null) itemView.isClickable = false - overflowIcon.visibility = VISIBLE + // ...thus, we should prevent the user from interacting with it. + overflowIcon.visibility = if (item.currency != null) VISIBLE else GONE } overflowIcon.setOnClickListener { openMenu(overflowIcon, item) } } @@ -108,6 +100,14 @@ internal class ExchangeAdapter( listener.onPeerReceive(item) true } + R.id.action_reload -> { + listener.onExchangeReload(item) + true + } + R.id.action_delete -> { + listener.onExchangeDelete(item) + true + } else -> false } } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt index 21e31f4..8a40bff 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListFragment.kt @@ -18,21 +18,29 @@ package net.taler.wallet.exchanges import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast import android.widget.Toast.LENGTH_LONG +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL +import com.google.android.material.dialog.MaterialAlertDialogBuilder import net.taler.common.EventObserver import net.taler.common.fadeIn import net.taler.common.fadeOut +import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R import net.taler.wallet.databinding.FragmentExchangeListBinding +import net.taler.wallet.showError open class ExchangeListFragment : Fragment(), ExchangeClickListener { @@ -54,6 +62,21 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + if (model.devMode.value == true) { + menuInflater.inflate(R.menu.exchange_list, menu) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.action_add_dev_exchanges) { + exchangeManager.addDevExchanges() + } + return true + } + }, viewLifecycleOwner, RESUMED) + ui.list.apply { adapter = exchangeAdapter addItemDecoration(DividerItemDecoration(context, VERTICAL)) @@ -69,7 +92,30 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { onExchangeUpdate(exchanges) } exchangeManager.addError.observe(viewLifecycleOwner, EventObserver { error -> - if (error) onAddExchangeFailed() + onAddExchangeFailed() + if (model.devMode.value == true) { + showError(error) + } + }) + exchangeManager.listError.observe(viewLifecycleOwner, EventObserver { error -> + onListExchangeFailed() + if (model.devMode.value == true) { + showError(error) + } + }) + exchangeManager.deleteError.observe(viewLifecycleOwner, EventObserver { error -> + if (model.devMode.value == true) { + showError(error) + } else { + showError(error.userFacingMsg) + } + }) + exchangeManager.reloadError.observe(viewLifecycleOwner, EventObserver { error -> + if (model.devMode.value == true) { + showError(error) + } else { + showError(error.userFacingMsg) + } }) } @@ -88,6 +134,10 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { Toast.makeText(requireContext(), R.string.exchange_add_error, LENGTH_LONG).show() } + private fun onListExchangeFailed() { + Toast.makeText(requireContext(), R.string.exchange_list_error, LENGTH_LONG).show() + } + override fun onExchangeSelected(item: ExchangeItem) { throw AssertionError("must not get triggered here") } @@ -98,8 +148,27 @@ open class ExchangeListFragment : Fragment(), ExchangeClickListener { } override fun onPeerReceive(item: ExchangeItem) { - transactionManager.selectedCurrency = item.currency + transactionManager.selectedScope = item.scopeInfo findNavController().navigate(R.id.action_global_receiveFunds) } + override fun onExchangeReload(item: ExchangeItem) { + exchangeManager.reload(item.exchangeBaseUrl) + } + + override fun onExchangeDelete(item: ExchangeItem) { + val optionsArray = arrayOf(getString(R.string.exchange_delete_force)) + val checkedArray = BooleanArray(1) { false } + + MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) + .setTitle(R.string.exchange_delete) + .setMultiChoiceItems(optionsArray, checkedArray) { _, which, isChecked -> + checkedArray[which] = isChecked + } + .setNegativeButton(R.string.transactions_delete) { _, _ -> + exchangeManager.delete(item.exchangeBaseUrl, checkedArray[0]) + } + .setPositiveButton(R.string.cancel) { _, _ -> } + .show() + } } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt index 4a57068..fa357b5 100644 --- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt +++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt @@ -21,6 +21,7 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch @@ -28,6 +29,7 @@ import kotlinx.serialization.Serializable import net.taler.common.Event import net.taler.common.toEvent import net.taler.wallet.TAG +import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi @Serializable @@ -35,6 +37,11 @@ data class ExchangeListResponse( val exchanges: List<ExchangeItem>, ) +@Serializable +data class ExchangeDetailedResponse( + val exchange: ExchangeItem, +) + class ExchangeManager( private val api: WalletBackendApi, private val scope: CoroutineScope, @@ -46,8 +53,17 @@ class ExchangeManager( private val mExchanges = MutableLiveData<List<ExchangeItem>>() val exchanges: LiveData<List<ExchangeItem>> get() = list() - private val mAddError = MutableLiveData<Event<Boolean>>() - val addError: LiveData<Event<Boolean>> = mAddError + private val mAddError = MutableLiveData<Event<TalerErrorInfo>>() + val addError: LiveData<Event<TalerErrorInfo>> = mAddError + + private val mListError = MutableLiveData<Event<TalerErrorInfo>>() + val listError: LiveData<Event<TalerErrorInfo>> = mListError + + private val mDeleteError = MutableLiveData<Event<TalerErrorInfo>>() + val deleteError: LiveData<Event<TalerErrorInfo>> = mDeleteError + + private val mReloadError = MutableLiveData<Event<TalerErrorInfo>>() + val reloadError: LiveData<Event<TalerErrorInfo>> = mReloadError var withdrawalExchange: ExchangeItem? = null @@ -56,7 +72,8 @@ class ExchangeManager( scope.launch { val response = api.request("listExchanges", ExchangeListResponse.serializer()) response.onError { - throw AssertionError("Wallet core failed to return exchanges! ${it.userFacingMsg}") + mProgress.value = false + mListError.value = it.toEvent() }.onSuccess { Log.d(TAG, "Exchange list: ${it.exchanges}") mProgress.value = false @@ -71,9 +88,9 @@ class ExchangeManager( api.request<Unit>("addExchange") { put("exchangeBaseUrl", exchangeUrl) }.onError { - mProgress.value = false Log.e(TAG, "Error adding exchange: $it") - mAddError.value = true.toEvent() + mProgress.value = false + mAddError.value = it.toEvent() }.onSuccess { mProgress.value = false Log.d(TAG, "Exchange $exchangeUrl added") @@ -81,6 +98,38 @@ class ExchangeManager( } } + fun reload(exchangeUrl: String, force: Boolean = true) = scope.launch { + mProgress.value = true + api.request<Unit>("updateExchangeEntry") { + put("exchangeBaseUrl", exchangeUrl) + put("force", force) + }.onError { + Log.e(TAG, "Error reloading exchange: $it") + mProgress.value = false + mReloadError.value = it.toEvent() + }.onSuccess { + mProgress.value = false + Log.d(TAG, "Exchange $exchangeUrl reloaded") + list() + } + } + + fun delete(exchangeUrl: String, purge: Boolean = false) = scope.launch { + mProgress.value = true + api.request<Unit>("deleteExchange") { + put("exchangeBaseUrl", exchangeUrl) + put("purge", purge) + }.onError { + Log.e(TAG, "Error deleting exchange: $it") + mProgress.value = false + mDeleteError.value = it.toEvent() + }.onSuccess { + mProgress.value = false + Log.d(TAG, "Exchange $exchangeUrl deleted") + list() + } + } + fun findExchangeForCurrency(currency: String): Flow<ExchangeItem?> = flow { emit(findExchange(currency)) } @@ -98,4 +147,49 @@ class ExchangeManager( return exchange } + @WorkerThread + suspend fun findExchangeByUrl(exchangeUrl: String): ExchangeItem? { + var exchange: ExchangeItem? = null + api.request("getExchangeDetailedInfo", ExchangeDetailedResponse.serializer()) { + put("exchangeBaseUrl", exchangeUrl) + }.onError { + Log.e(TAG, "Error getExchangeDetailedInfo: $it") + }.onSuccess { + exchange = it.exchange + } + return exchange + } + + fun addDevExchanges() { + scope.launch { + listOf( + "https://exchange.demo.taler.net/", + "https://exchange.test.taler.net/", + "https://exchange.head.taler.net/", + "https://exchange.taler.ar/", + "https://exchange.taler.fdold.eu/", + "https://exchange.taler.grothoff.org/", + ).forEach { exchangeUrl -> + add(exchangeUrl) + delay(100) + } + exchanges.value?.let { exs -> + exs.find { + it.exchangeBaseUrl.startsWith("https://exchange.taler.fdold.eu") + }?.let { fDoldExchange -> + api.request<Unit>("addGlobalCurrencyExchange") { + put("currency", fDoldExchange.currency) + put("exchangeBaseUrl", fDoldExchange.exchangeBaseUrl) + put("exchangeMasterPub", + "7ER30ZWJEXAG026H5KG9M19NGTFC2DKKFPV79GVXA6DK5DCNSWXG") + }.onError { + Log.e(TAG, "Error addGlobalCurrencyExchange: $it") + }.onSuccess { + Log.i(TAG, "fdold is global now!") + } + } + } + } + } + } diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt new file mode 100644 index 0000000..0015e1c --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt @@ -0,0 +1,38 @@ +/* + * This file is part of GNU Taler + * (C) 2024 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.exchanges + +import kotlinx.serialization.Serializable +import net.taler.wallet.balances.ScopeInfo +import net.taler.wallet.cleanExchange + +@Serializable +data class BuiltinExchange( + val exchangeBaseUrl: String, + val currencyHint: String? = null, +) + +@Serializable +data class ExchangeItem( + val exchangeBaseUrl: String, + // can be null before exchange info in wallet-core was fully loaded + val currency: String? = null, + val paytoUris: List<String>, + val scopeInfo: ScopeInfo? = null, +) { + val name: String get() = cleanExchange(exchangeBaseUrl) +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt new file mode 100644 index 0000000..136738b --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeDialogFragment.kt @@ -0,0 +1,111 @@ +/* + * This file is part of GNU Taler + * (C) 2024 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.exchanges + +import android.app.Dialog +import android.os.Bundle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import com.google.accompanist.themeadapter.material3.Mdc3Theme +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import net.taler.common.Event +import net.taler.common.toEvent +import net.taler.wallet.R +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.collectAsStateLifecycleAware + +class SelectExchangeDialogFragment: DialogFragment() { + private var exchangeList = MutableLiveData<List<ExchangeItem>>() + + private var mExchangeSelection = MutableLiveData<Event<ExchangeItem>>() + val exchangeSelection: LiveData<Event<ExchangeItem>> = mExchangeSelection + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val view = ComposeView(requireContext()).apply { + setContent { + val exchanges = exchangeList.asFlow().collectAsStateLifecycleAware(initial = emptyList()) + SelectExchangeComposable(exchanges.value) { + onExchangeSelected(it) + } + } + } + + return MaterialAlertDialogBuilder(requireContext(), R.style.MaterialAlertDialog_Material3) + .setIcon(R.drawable.ic_account_balance) + .setTitle(R.string.exchange_list_select) + .setView(view) + .setNegativeButton(R.string.cancel) { _, _ -> + dismiss() + } + .create() + } + + fun setExchanges(exchanges: List<ExchangeItem>) { + exchangeList.value = exchanges + } + + private fun onExchangeSelected(exchange: ExchangeItem) { + mExchangeSelection.value = exchange.toEvent() + dismiss() + } +} + +@Composable +fun SelectExchangeComposable( + exchanges: List<ExchangeItem>, + onExchangeSelected: (exchange: ExchangeItem) -> Unit, +) { + Mdc3Theme { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(exchanges) { + ExchangeItemComposable(it) { + onExchangeSelected(it) + } + } + } + } +} + +@Composable +fun ExchangeItemComposable(exchange: ExchangeItem, onSelected: () -> Unit) { + ListItem( + modifier = Modifier.clickable { onSelected() }, + headlineContent = { Text(cleanExchange(exchange.exchangeBaseUrl)) }, + supportingContent = exchange.currency?.let { + { Text(stringResource(R.string.exchange_list_currency, it)) } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ) + ) +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt b/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt deleted file mode 100644 index 61e0db5..0000000 --- a/wallet/src/main/java/net/taler/wallet/exchanges/SelectExchangeFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.exchanges - -import androidx.navigation.fragment.findNavController -import net.taler.common.fadeOut - -class SelectExchangeFragment : ExchangeListFragment() { - - private val withdrawManager by lazy { model.withdrawManager } - - override val isSelectOnly = true - private val exchangeSelection by lazy { - requireNotNull(withdrawManager.exchangeSelection.value?.getEvenIfConsumedAlready()) - } - - override fun onExchangeUpdate(exchanges: List<ExchangeItem>) { - ui.progressBar.fadeOut() - super.onExchangeUpdate(exchanges.filter { exchangeItem -> - exchangeItem.currency == exchangeSelection.amount.currency - }) - } - - override fun onExchangeSelected(item: ExchangeItem) { - withdrawManager.getWithdrawalDetails( - exchangeBaseUrl = item.exchangeBaseUrl, - amount = exchangeSelection.amount, - showTosImmediately = true, - uri = exchangeSelection.talerWithdrawUri, - ) - findNavController().navigateUp() - } - -} |