From 062d9dabe348430d427bd4de640d16ccd81493aa Mon Sep 17 00:00:00 2001 From: pokkst Date: Mon, 30 Jan 2023 23:13:17 -0600 Subject: [PATCH] Add ability to create multiple outputs on send screen. WIP --- .../dialog/SendBottomSheetDialog.java | 3 +- .../wallet/fragment/home/HomeFragment.java | 4 +- .../wallet/fragment/send/SendFragment.java | 291 +++++++++++++----- .../wallet/fragment/send/SendViewModel.java | 41 +-- .../wallet/service/MoneroHandlerThread.java | 27 +- .../net/mynero/wallet/service/TxService.java | 7 + app/src/main/res/drawable/edittext_bg.xml | 4 +- app/src/main/res/layout/fragment_send.xml | 144 +++------ .../res/layout/transaction_output_item.xml | 114 +++++++ app/src/main/res/navigation/main_nav.xml | 10 + app/src/main/res/values/strings.xml | 1 + 11 files changed, 432 insertions(+), 214 deletions(-) create mode 100644 app/src/main/res/layout/transaction_output_item.xml diff --git a/app/src/main/java/net/mynero/wallet/fragment/dialog/SendBottomSheetDialog.java b/app/src/main/java/net/mynero/wallet/fragment/dialog/SendBottomSheetDialog.java index 9184787..295265d 100644 --- a/app/src/main/java/net/mynero/wallet/fragment/dialog/SendBottomSheetDialog.java +++ b/app/src/main/java/net/mynero/wallet/fragment/dialog/SendBottomSheetDialog.java @@ -44,7 +44,8 @@ public class SendBottomSheetDialog extends BottomSheetDialogFragment { private final MutableLiveData _sendingMax = new MutableLiveData<>(false); private final MutableLiveData _pendingTransaction = new MutableLiveData<>(null); public ArrayList selectedUtxos = new ArrayList<>(); - public LiveData sendingMax = _sendingMax; private final ActivityResultLauncher cameraPermissionsLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), + public LiveData sendingMax = _sendingMax; + private final ActivityResultLauncher cameraPermissionsLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { if (granted) { onScan(); diff --git a/app/src/main/java/net/mynero/wallet/fragment/home/HomeFragment.java b/app/src/main/java/net/mynero/wallet/fragment/home/HomeFragment.java index ecf8379..93aeaf7 100644 --- a/app/src/main/java/net/mynero/wallet/fragment/home/HomeFragment.java +++ b/app/src/main/java/net/mynero/wallet/fragment/home/HomeFragment.java @@ -71,8 +71,8 @@ public class HomeFragment extends Fragment implements TransactionInfoAdapter.TxI }); sendButton.setOnClickListener(view1 -> { - SendBottomSheetDialog sendDialog = new SendBottomSheetDialog(); - sendDialog.show(getActivity().getSupportFragmentManager(), null); + navigate(HomeFragmentDirections.navToSend()); + }); receiveButton.setOnClickListener(view1 -> { diff --git a/app/src/main/java/net/mynero/wallet/fragment/send/SendFragment.java b/app/src/main/java/net/mynero/wallet/fragment/send/SendFragment.java index 6037304..a124429 100644 --- a/app/src/main/java/net/mynero/wallet/fragment/send/SendFragment.java +++ b/app/src/main/java/net/mynero/wallet/fragment/send/SendFragment.java @@ -1,47 +1,56 @@ package net.mynero.wallet.fragment.send; -import android.graphics.Bitmap; +import android.app.Activity; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; import android.widget.ImageButton; -import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RadioGroup; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.EncodeHintType; -import com.google.zxing.WriterException; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; - +import net.mynero.wallet.MoneroApplication; import net.mynero.wallet.R; -import net.mynero.wallet.adapter.SubaddressAdapter; -import net.mynero.wallet.data.Subaddress; -import net.mynero.wallet.util.DayNightMode; +import net.mynero.wallet.model.PendingTransaction; +import net.mynero.wallet.model.Wallet; +import net.mynero.wallet.service.BalanceService; +import net.mynero.wallet.service.TxService; import net.mynero.wallet.util.Helper; -import net.mynero.wallet.util.NightmodeHelper; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; -import timber.log.Timber; +import kotlin.Pair; public class SendFragment extends Fragment { - private TextView addressTextView = null; - private TextView addressLabelTextView = null; - private ImageView addressImageView = null; - private ImageButton copyAddressImageButton = null; private SendViewModel mViewModel; + private Button sendMaxButton; + private ImageButton addOutputImageView; + private LinearLayout destList; + private LayoutInflater inflater; + private Button createButton; + private RadioGroup feeRadioGroup; + private TextView feeRadioGroupLabelTextView; + + private TextView feeTextView; + private TextView addressTextView; + private TextView amountTextView; + + public PendingTransaction.Priority priority; + @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @@ -53,63 +62,203 @@ public class SendFragment extends Fragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mViewModel = new ViewModelProvider(this).get(SendViewModel.class); - addressImageView = view.findViewById(R.id.monero_qr_imageview); - addressTextView = view.findViewById(R.id.address_textview); - addressLabelTextView = view.findViewById(R.id.address_label_textview); - copyAddressImageButton = view.findViewById(R.id.copy_address_imagebutton); - bindListeners(view); - bindObservers(view); - mViewModel.init(); + sendMaxButton = view.findViewById(R.id.send_max_button); + addOutputImageView = view.findViewById(R.id.add_output_button); + destList = view.findViewById(R.id.transaction_destination_list); + createButton = view.findViewById(R.id.create_tx_button); + feeRadioGroup = view.findViewById(R.id.tx_fee_radiogroup); + feeTextView = view.findViewById(R.id.fee_textview); + addressTextView = view.findViewById(R.id.address_pending_textview); + amountTextView = view.findViewById(R.id.amount_pending_textview); + feeRadioGroup = view.findViewById(R.id.tx_fee_radiogroup); + feeRadioGroupLabelTextView = view.findViewById(R.id.tx_fee_radiogroup_label_textview); + + FragmentActivity activity = getActivity(); + if(activity != null) { + inflater = activity.getLayoutInflater(); + } + bindListeners(); + bindObservers(); + init(); } - private void bindListeners(View view) { - ImageButton freshAddressImageView = view.findViewById(R.id.fresh_address_imageview); - freshAddressImageView.setOnClickListener(view1 -> { - mViewModel.getFreshSubaddress(); + private void init() { + addOutput(); + } + + private void bindListeners() { + feeRadioGroup.check(R.id.low_fee_radiobutton); + priority = PendingTransaction.Priority.Priority_Low; + feeRadioGroup.setOnCheckedChangeListener((radioGroup, i) -> { + if (i == R.id.low_fee_radiobutton) { + priority = PendingTransaction.Priority.Priority_Low; + } else if (i == R.id.med_fee_radiobutton) { + priority = PendingTransaction.Priority.Priority_Medium; + } else if (i == R.id.high_fee_radiobutton) { + priority = PendingTransaction.Priority.Priority_High; + } + }); + + addOutputImageView.setOnClickListener(view1 -> { + sendMaxButton.setVisibility(View.GONE); + addOutput(); + }); + sendMaxButton.setOnClickListener(view1 -> { + addOutputImageView.setVisibility(View.INVISIBLE); + boolean currentValue = mViewModel.sendingMax.getValue() != null ? mViewModel.sendingMax.getValue() : false; + mViewModel.setSendingMax(!currentValue); + }); + createButton.setOnClickListener(view1 -> { + boolean sendAll = mViewModel.sendingMax.getValue() != null ? mViewModel.sendingMax.getValue() : false; + ArrayList> dests = new ArrayList<>(); + for(int i = 0; i < getDestCount(); i++) { + ConstraintLayout entryView = getDestView(i); + EditText amountField = entryView.findViewById(R.id.amount_edittext); + EditText addressField = entryView.findViewById(R.id.address_edittext); + String amount = amountField.getText().toString().trim(); + if(!sendAll) { + if(amount.isEmpty()) { + Toast.makeText(getActivity(), getString(R.string.send_amount_empty), Toast.LENGTH_SHORT).show(); + return; + } + + long amountRaw = Wallet.getAmountFromString(amount); + long balance = BalanceService.getInstance().getUnlockedBalanceRaw(); + if (amountRaw >= balance || amountRaw <= 0) { + Toast.makeText(getActivity(), getString(R.string.send_amount_invalid), Toast.LENGTH_SHORT).show(); + return; + } + } + + String address = addressField.getText().toString().trim(); + boolean isValidAddress = Wallet.isAddressValid(address); + if(!isValidAddress) { + Toast.makeText(getActivity(), getString(R.string.send_address_invalid), Toast.LENGTH_SHORT).show(); + return; + } + dests.add(new Pair<>(address, amount)); + } + + Toast.makeText(getActivity(), getString(R.string.creating_tx), Toast.LENGTH_SHORT).show(); + createButton.setEnabled(false); + createTx(dests, sendAll, priority); }); } - private void bindObservers(View view) { - SubaddressAdapter adapter = new SubaddressAdapter(mViewModel::selectAddress); - RecyclerView recyclerView = view.findViewById(R.id.address_list_recyclerview); - recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); - recyclerView.setAdapter(adapter); - mViewModel.address.observe(getViewLifecycleOwner(), this::setAddress); - mViewModel.addresses.observe(getViewLifecycleOwner(), adapter::submitList); - } - - private void setAddress(Subaddress subaddress) { - final String label = subaddress.getDisplayLabel(); - final String address = getContext().getString(R.string.subbaddress_info_subtitle, - subaddress.getAddressIndex(), subaddress.getSquashedAddress()); - addressLabelTextView.setText(label.isEmpty() ? address : label); - addressTextView.setText(subaddress.getAddress()); - addressImageView.setImageBitmap(generate(subaddress.getAddress(), 256, 256)); - copyAddressImageButton.setOnClickListener(view1 -> Helper.clipBoardCopy(getContext(), "address", subaddress.getAddress())); - } - - private Bitmap generate(String text, int width, int height) { - if ((width <= 0) || (height <= 0)) return null; - Map hints = new HashMap<>(); - hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8); - hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); - try { - BitMatrix bitMatrix = new QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, width, height, hints); - int[] pixels = new int[width * height]; - for (int i = 0; i < height; i++) { - for (int j = 0; j < width; j++) { - boolean night = NightmodeHelper.getPreferredNightmode() == DayNightMode.NIGHT; - if (bitMatrix.get(j, i)) { - pixels[i * width + j] = night ? 0xffffffff : 0x00000000; - } else { - pixels[i * height + j] = getResources().getColor(R.color.oled_colorBackground); - } + private void bindObservers() { + mViewModel.sendingMax.observe(getViewLifecycleOwner(), sendingMax -> { + if (mViewModel.pendingTransaction.getValue() == null) { + if (sendingMax) { + addOutputImageView.setVisibility(View.GONE); + prepareOutputsForMaxSend(); + sendMaxButton.setText(getText(R.string.undo)); + } else { + addOutputImageView.setVisibility(View.VISIBLE); + unprepareMaxSend(); + sendMaxButton.setText(getText(R.string.send_max)); } } - return Bitmap.createBitmap(pixels, 0, width, width, height, Bitmap.Config.RGB_565); - } catch (WriterException ex) { - Timber.e(ex); + }); + + mViewModel.pendingTransaction.observe(getViewLifecycleOwner(), pendingTx -> { + showConfirmationLayout(pendingTx != null); + + if (pendingTx != null) { + String address = getDestCount() == 1 ? getAddressField(0).getText().toString() : "Multiple"; + addressTextView.setText(getString(R.string.tx_address_text, address)); + amountTextView.setText(getString(R.string.tx_amount_text, Helper.getDisplayAmount(pendingTx.getAmount()))); + feeTextView.setText(getString(R.string.tx_fee_text, Helper.getDisplayAmount(pendingTx.getFee()))); + } + }); + } + + private void addOutput() { + if (inflater != null) { + ConstraintLayout entryView = (ConstraintLayout)inflater.inflate(R.layout.transaction_output_item, null); + entryView.findViewById(R.id.remove_output_imagebutton).setOnClickListener(view -> { + int currentCount = getDestCount(); + if(currentCount > 1) { + destList.removeView(entryView); + } + }); + destList.addView(entryView); } - return null; + } + + private int getDestCount() { + return destList.getChildCount(); + } + + private ConstraintLayout getDestView(int pos) { + return (ConstraintLayout) destList.getChildAt(pos); + } + + private EditText getAddressField(int pos) { + return (EditText) getDestView(pos).findViewById(R.id.address_edittext); + } + + private void unprepareMaxSend() { + ConstraintLayout entryView = getDestView(0); + entryView.findViewById(R.id.sending_all_textview).setVisibility(View.GONE); + entryView.findViewById(R.id.amount_edittext).setVisibility(View.VISIBLE); + } + + private void prepareOutputsForMaxSend() { + ConstraintLayout entryView = getDestView(0); + entryView.findViewById(R.id.sending_all_textview).setVisibility(View.VISIBLE); + entryView.findViewById(R.id.amount_edittext).setVisibility(View.GONE); + } + + private void showConfirmationLayout(boolean show) { + if (show) { + destList.setVisibility(View.GONE); + addOutputImageView.setVisibility(View.GONE); + sendMaxButton.setVisibility(View.GONE); + createButton.setVisibility(View.GONE); + feeRadioGroup.setVisibility(View.GONE); + feeRadioGroupLabelTextView.setVisibility(View.GONE); + + feeTextView.setVisibility(View.VISIBLE); + addressTextView.setVisibility(View.VISIBLE); + amountTextView.setVisibility(View.VISIBLE); + } else { + destList.setVisibility(View.VISIBLE); + addOutputImageView.setVisibility(View.VISIBLE); + sendMaxButton.setVisibility(View.VISIBLE); + createButton.setVisibility(View.VISIBLE); + feeRadioGroup.setVisibility(View.VISIBLE); + feeRadioGroupLabelTextView.setVisibility(View.VISIBLE); + + feeTextView.setVisibility(View.GONE); + addressTextView.setVisibility(View.GONE); + amountTextView.setVisibility(View.GONE); + } + } + + private void createTx(List> dests, boolean sendAll, PendingTransaction.Priority feePriority) { + ((MoneroApplication)getActivity().getApplication()).getExecutor().execute(() -> { + try { + PendingTransaction pendingTx = TxService.getInstance().createTx(dests, sendAll, feePriority, new ArrayList<>()); + if (pendingTx != null && pendingTx.getStatus() == PendingTransaction.Status.Status_Ok) { + mViewModel.setPendingTransaction(pendingTx); + } else { + Activity activity = getActivity(); + if (activity != null) { + activity.runOnUiThread(() -> { + createButton.setEnabled(true); + Toast.makeText(getActivity(), getString(R.string.error_creating_tx), Toast.LENGTH_SHORT).show(); + }); + } + } + } catch (Exception e) { + Activity activity = getActivity(); + if (activity != null) { + activity.runOnUiThread(() -> { + createButton.setEnabled(true); + Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show(); + }); + } + } + }); } } \ No newline at end of file diff --git a/app/src/main/java/net/mynero/wallet/fragment/send/SendViewModel.java b/app/src/main/java/net/mynero/wallet/fragment/send/SendViewModel.java index 3b713e0..cb807cc 100644 --- a/app/src/main/java/net/mynero/wallet/fragment/send/SendViewModel.java +++ b/app/src/main/java/net/mynero/wallet/fragment/send/SendViewModel.java @@ -4,42 +4,19 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; -import net.mynero.wallet.data.Subaddress; -import net.mynero.wallet.model.Wallet; -import net.mynero.wallet.model.WalletManager; -import net.mynero.wallet.service.AddressService; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import net.mynero.wallet.model.PendingTransaction; public class SendViewModel extends ViewModel { - private final MutableLiveData _address = new MutableLiveData<>(); - public LiveData address = _address; - private final MutableLiveData> _addresses = new MutableLiveData<>(); - public LiveData> addresses = _addresses; + private final MutableLiveData _sendingMax = new MutableLiveData<>(false); + public LiveData sendingMax = _sendingMax; + private final MutableLiveData _pendingTransaction = new MutableLiveData<>(null); + public LiveData pendingTransaction = _pendingTransaction; - public void init() { - _address.setValue(AddressService.getInstance().currentSubaddress()); - _addresses.setValue(getSubaddresses()); + public void setSendingMax(boolean value) { + _sendingMax.setValue(value); } - private List getSubaddresses() { - Wallet wallet = WalletManager.getInstance().getWallet(); - ArrayList subaddresses = new ArrayList<>(); - int addressesSize = AddressService.getInstance().getLatestAddressIndex(); - for(int i = addressesSize - 1; i >= 0; i--) { - subaddresses.add(wallet.getSubaddressObject(i)); - } - return Collections.unmodifiableList(subaddresses); - } - - public void getFreshSubaddress() { - _address.setValue(AddressService.getInstance().freshSubaddress()); - _addresses.setValue(getSubaddresses()); - } - - public void selectAddress(Subaddress subaddress) { - _address.setValue(subaddress); + public void setPendingTransaction(PendingTransaction pendingTx) { + _pendingTransaction.postValue(pendingTx); } } \ No newline at end of file diff --git a/app/src/main/java/net/mynero/wallet/service/MoneroHandlerThread.java b/app/src/main/java/net/mynero/wallet/service/MoneroHandlerThread.java index 30f7f5d..18b823e 100644 --- a/app/src/main/java/net/mynero/wallet/service/MoneroHandlerThread.java +++ b/app/src/main/java/net/mynero/wallet/service/MoneroHandlerThread.java @@ -33,6 +33,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import kotlin.Pair; + /** * Handy class for starting a new thread that has a looper. The looper can then be @@ -131,23 +133,36 @@ public class MoneroHandlerThread extends Thread implements WalletListener { } public PendingTransaction createTx(String address, String amountStr, boolean sendAll, PendingTransaction.Priority feePriority, ArrayList selectedUtxos) throws Exception { - long amount = Wallet.getAmountFromString(amountStr); + ArrayList> dests = new ArrayList<>(); + dests.add(new Pair(address, amountStr)); + return createTx(dests, sendAll, feePriority, selectedUtxos); + } + + public PendingTransaction createTx(List> dests, boolean sendAll, PendingTransaction.Priority feePriority, ArrayList selectedUtxos) throws Exception { + long totalAmount = 0; + ArrayList outputs = new ArrayList<>(); + + for(Pair dest : dests) { + long amount = Wallet.getAmountFromString(dest.component2()); + totalAmount += amount; + outputs.add(new TransactionOutput(dest.component1(), amount)); + } ArrayList preferredInputs; if (selectedUtxos.isEmpty()) { // no inputs manually selected, we are sending from home screen most likely, or user somehow broke the app - preferredInputs = UTXOService.getInstance().selectUtxos(amount, sendAll); + preferredInputs = UTXOService.getInstance().selectUtxos(totalAmount, sendAll); } else { preferredInputs = selectedUtxos; - checkSelectedAmounts(preferredInputs, amount, sendAll); + checkSelectedAmounts(preferredInputs, totalAmount, sendAll); } if(sendAll) { + Pair dest = dests.get(0); + String address = dest.component1(); return wallet.createSweepTransaction(address, feePriority, preferredInputs); } - ArrayList outputs = new ArrayList<>(); - outputs.add(new TransactionOutput(address, amount)); - List finalOutputs = maybeAddDonationOutputs(amount, outputs, preferredInputs); + List finalOutputs = maybeAddDonationOutputs(totalAmount, outputs, preferredInputs); return wallet.createTransactionMultDest(finalOutputs, feePriority, preferredInputs); } diff --git a/app/src/main/java/net/mynero/wallet/service/TxService.java b/app/src/main/java/net/mynero/wallet/service/TxService.java index e0e726d..bb8d7d0 100644 --- a/app/src/main/java/net/mynero/wallet/service/TxService.java +++ b/app/src/main/java/net/mynero/wallet/service/TxService.java @@ -3,6 +3,9 @@ package net.mynero.wallet.service; import net.mynero.wallet.model.PendingTransaction; import java.util.ArrayList; +import java.util.List; + +import kotlin.Pair; public class TxService extends ServiceBase { public static TxService instance = null; @@ -20,6 +23,10 @@ public class TxService extends ServiceBase { return this.getThread().createTx(address, amount, sendAll, feePriority, selectedUtxos); } + public PendingTransaction createTx(List> dests, boolean sendAll, PendingTransaction.Priority feePriority, ArrayList selectedUtxos) throws Exception { + return this.getThread().createTx(dests, sendAll, feePriority, selectedUtxos); + } + public boolean sendTx(PendingTransaction pendingTransaction) { return this.getThread().sendTx(pendingTransaction); } diff --git a/app/src/main/res/drawable/edittext_bg.xml b/app/src/main/res/drawable/edittext_bg.xml index f224a03..1fe35cf 100644 --- a/app/src/main/res/drawable/edittext_bg.xml +++ b/app/src/main/res/drawable/edittext_bg.xml @@ -2,10 +2,10 @@ + android:top="8dp" /> diff --git a/app/src/main/res/layout/fragment_send.xml b/app/src/main/res/layout/fragment_send.xml index f45a5b3..6741e87 100644 --- a/app/src/main/res/layout/fragment_send.xml +++ b/app/src/main/res/layout/fragment_send.xml @@ -4,69 +4,24 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fillViewport="true" android:fitsSystemWindows="true"> + android:layout_height="0dp"> - - - - - -