mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Payments.
Co-authored-by: Alan Evans <alan@signal.org> Co-authored-by: Alex Hart <alex@signal.org> Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public final class Balance {
|
||||
private final Money fullAmount;
|
||||
private final Money transferableAmount;
|
||||
private final long checkedAt;
|
||||
|
||||
public Balance(@NonNull Money fullAmount, @NonNull Money transferableAmount, long checkedAt) {
|
||||
this.fullAmount = fullAmount;
|
||||
this.transferableAmount = transferableAmount;
|
||||
this.checkedAt = checkedAt;
|
||||
}
|
||||
|
||||
public @NonNull Money getFullAmount() {
|
||||
return fullAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full amount minus estimated fees required to send all funds.
|
||||
*/
|
||||
public @NonNull Money getTransferableAmount() {
|
||||
return transferableAmount;
|
||||
}
|
||||
|
||||
public long getCheckedAt() {
|
||||
return checkedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Balance{" +
|
||||
"fullAmount=" + fullAmount +
|
||||
", transferableAmount=" + transferableAmount +
|
||||
", checkedAt=" + checkedAt +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Utility to display a dialog when the user tries to send a payment to someone they do not have
|
||||
* a profile key for.
|
||||
*/
|
||||
public final class CanNotSendPaymentDialog {
|
||||
|
||||
private CanNotSendPaymentDialog() {
|
||||
}
|
||||
|
||||
public static void show(@NonNull Context context) {
|
||||
show(context, null);
|
||||
}
|
||||
|
||||
public static void show(@NonNull Context context, @Nullable Runnable onSendAMessageClicked) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.CanNotSendPaymentDialog__cant_send_payment)
|
||||
.setMessage(R.string.CanNotSendPaymentDialog__to_send_a_payment_to_this_user);
|
||||
|
||||
if (onSendAMessageClicked != null) {
|
||||
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.setPositiveButton(R.string.CanNotSendPaymentDialog__send_a_message, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
onSendAMessageClicked.run();
|
||||
})
|
||||
.show();
|
||||
} else {
|
||||
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public class CreatePaymentDetails implements Parcelable {
|
||||
private final PayeeParcelable payee;
|
||||
private final Money amount;
|
||||
private final String note;
|
||||
|
||||
public CreatePaymentDetails(@NonNull PayeeParcelable payee,
|
||||
@NonNull Money amount,
|
||||
@Nullable String note)
|
||||
{
|
||||
this.payee = payee;
|
||||
this.amount = amount;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
protected CreatePaymentDetails(@NonNull Parcel in) {
|
||||
this.payee = in.readParcelable(PayeeParcelable.class.getClassLoader());
|
||||
this.amount = Money.parseOrThrow(in.readString());
|
||||
this.note = in.readString();
|
||||
}
|
||||
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee.getPayee();
|
||||
}
|
||||
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public @Nullable String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeParcelable(payee, flags);
|
||||
dest.writeString(amount.serialize());
|
||||
dest.writeString(note);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<CreatePaymentDetails> CREATOR = new Creator<CreatePaymentDetails>() {
|
||||
@Override
|
||||
public @NonNull CreatePaymentDetails createFromParcel(@NonNull Parcel in) {
|
||||
return new CreatePaymentDetails(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull CreatePaymentDetails[] newArray(int size) {
|
||||
return new CreatePaymentDetails[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Converts from database protobuf type {@link CryptoValue} to and from other types.
|
||||
*/
|
||||
public final class CryptoValueUtil {
|
||||
|
||||
private CryptoValueUtil() {
|
||||
}
|
||||
|
||||
public static @NonNull CryptoValue moneyToCryptoValue(@NonNull Money money) {
|
||||
CryptoValue.Builder builder = CryptoValue.newBuilder();
|
||||
|
||||
if (money instanceof Money.MobileCoin) {
|
||||
Money.MobileCoin mobileCoin = (Money.MobileCoin) money;
|
||||
builder.setMobileCoinValue(CryptoValue.MobileCoinValue
|
||||
.newBuilder()
|
||||
.setPicoMobileCoin(mobileCoin.serializeAmountString()));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static @NonNull Money cryptoValueToMoney(@NonNull CryptoValue amount) {
|
||||
CryptoValue.ValueCase valueCase = amount.getValueCase();
|
||||
|
||||
switch (valueCase) {
|
||||
case MOBILECOINVALUE:
|
||||
return Money.picoMobileCoin(new BigInteger(amount.getMobileCoinValue().getPicoMobileCoin()));
|
||||
case VALUE_NOT_SET:
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.reconciliation.LedgerReconcile;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class DataExportUtil {
|
||||
|
||||
private DataExportUtil() {}
|
||||
|
||||
public static @NonNull String createTsv() {
|
||||
if (!FeatureFlags.internalUser()) {
|
||||
throw new AssertionError("This is intended for internal use only");
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
List<PaymentDatabase.PaymentTransaction> paymentTransactions = DatabaseFactory.getPaymentDatabase(context)
|
||||
.getAll();
|
||||
MobileCoinLedgerWrapper ledger = SignalStore.paymentsValues().liveMobileCoinLedger().getValue();
|
||||
List<Payment> reconciled = LedgerReconcile.reconcile(paymentTransactions, Objects.requireNonNull(ledger));
|
||||
|
||||
return createTsv(reconciled);
|
||||
}
|
||||
|
||||
@RequiresApi(api = 26)
|
||||
private static @NonNull String createTsv(@NonNull List<Payment> payments) {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.append(String.format(Locale.US, "%s\t%s\t%s\t%s\t%s%n", "Date Time", "From", "To", "Amount", "Fee"));
|
||||
|
||||
for (Payment payment : payments) {
|
||||
if (payment.getState() != State.SUCCESSFUL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String self = Recipient.self().getDisplayName(context);
|
||||
String otherParty = describePayee(context, payment.getPayee());
|
||||
String from;
|
||||
String to;
|
||||
switch (payment.getDirection()) {
|
||||
case SENT:
|
||||
from = self;
|
||||
to = otherParty;
|
||||
break;
|
||||
case RECEIVED:
|
||||
from = otherParty;
|
||||
to = self;
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
sb.append(String.format(Locale.US, "%s\t%s\t%s\t%s\t%s%n",
|
||||
DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(payment.getDisplayTimestamp())),
|
||||
from,
|
||||
to,
|
||||
payment.getAmountWithDirection().requireMobileCoin().toBigDecimal(),
|
||||
payment.getFee().requireMobileCoin().toBigDecimal()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String describePayee(Context context, Payee payee) {
|
||||
if (payee.hasRecipientId()) {
|
||||
return Recipient.resolved(payee.requireRecipientId()).getDisplayName(context);
|
||||
} else if (payee.hasPublicAddress()) {
|
||||
return payee.requirePublicAddress().getPaymentAddressBase58();
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum Direction {
|
||||
// These are serialized into the database, do not change the values.
|
||||
SENT(0),
|
||||
RECEIVED(1);
|
||||
|
||||
private final int value;
|
||||
|
||||
Direction(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int serialize() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static @NonNull Direction deserialize(int value) {
|
||||
if (value == Direction.SENT.value) return Direction.SENT;
|
||||
else if (value == Direction.RECEIVED.value) return Direction.RECEIVED;
|
||||
else throw new AssertionError("" + value);
|
||||
}
|
||||
|
||||
public boolean isReceived() {
|
||||
return this == RECEIVED;
|
||||
}
|
||||
|
||||
public boolean isSent() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.Mnemonics;
|
||||
import com.mobilecoin.lib.exceptions.BadEntropyException;
|
||||
import com.mobilecoin.lib.exceptions.BadMnemonicException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class Entropy {
|
||||
private static final String TAG = Log.tag(Entropy.class);
|
||||
|
||||
private final byte[] bytes;
|
||||
|
||||
Entropy(@NonNull byte[] bytes) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
public static @NonNull Entropy generateNew() {
|
||||
return new Entropy(Util.getSecretBytes(PaymentsConstants.PAYMENTS_ENTROPY_LENGTH));
|
||||
}
|
||||
|
||||
public static Entropy fromBytes(@Nullable byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
if (bytes.length == PaymentsConstants.PAYMENTS_ENTROPY_LENGTH) {
|
||||
return new Entropy(bytes);
|
||||
} else {
|
||||
Log.w(TAG, String.format(Locale.US, "Entropy was supplied of length %d and ignored", bytes.length), new Throwable());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getBytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public Mnemonic asMnemonic() {
|
||||
try {
|
||||
String mnemonic = Mnemonics.bip39EntropyToMnemonic(bytes);
|
||||
byte[] check = Mnemonics.bip39EntropyFromMnemonic(mnemonic);
|
||||
if (!Arrays.equals(bytes, check)) {
|
||||
throw new AssertionError("Round trip mnemonic failure");
|
||||
}
|
||||
return new Mnemonic(mnemonic);
|
||||
} catch (BadEntropyException | BadMnemonicException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum FailureReason {
|
||||
// These are serialized into the database, do not change the values.
|
||||
UNKNOWN(0),
|
||||
INSUFFICIENT_FUNDS(1),
|
||||
NETWORK(2);
|
||||
|
||||
private final int value;
|
||||
|
||||
FailureReason(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int serialize() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static @NonNull FailureReason deserialize(int value) {
|
||||
if (value == FailureReason.UNKNOWN.value) return FailureReason.UNKNOWN;
|
||||
else if (value == FailureReason.INSUFFICIENT_FUNDS.value) return FailureReason.INSUFFICIENT_FUNDS;
|
||||
else if (value == FailureReason.NETWORK.value) return FailureReason.NETWORK;
|
||||
else throw new AssertionError("" + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.res.Resources;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
public class FiatMoneyUtil {
|
||||
|
||||
private static final String TAG = Log.tag(FiatMoneyUtil.class);
|
||||
|
||||
public static @NonNull LiveData<Optional<FiatMoney>> getExchange(@NonNull LiveData<Money> amount) {
|
||||
return LiveDataUtil.mapAsync(amount, a -> {
|
||||
try {
|
||||
return ApplicationDependencies.getPayments()
|
||||
.getCurrencyExchange(false)
|
||||
.getExchangeRate(SignalStore.paymentsValues().currentCurrency())
|
||||
.exchange(a);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return Optional.absent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static @NonNull String format(@NonNull Resources resources, @NonNull FiatMoney amount) {
|
||||
return format(resources, amount, new FormatOptions());
|
||||
}
|
||||
|
||||
public static @NonNull String format(@NonNull Resources resources, @NonNull FiatMoney amount, @NonNull FormatOptions options) {
|
||||
final NumberFormat formatter;
|
||||
|
||||
if (options.withSymbol) {
|
||||
formatter = NumberFormat.getCurrencyInstance();
|
||||
formatter.setCurrency(amount.getCurrency());
|
||||
} else {
|
||||
formatter = NumberFormat.getNumberInstance();
|
||||
formatter.setMinimumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
|
||||
}
|
||||
|
||||
String formattedAmount = formatter.format(amount.getAmount());
|
||||
if (amount.getTimestamp() > 0 && options.displayTime) {
|
||||
return resources.getString(R.string.CurrencyAmountFormatter_s_at_s,
|
||||
formattedAmount,
|
||||
DateUtils.getTimeString(ApplicationDependencies.getApplication(), Locale.getDefault(), amount.getTimestamp()));
|
||||
}
|
||||
return formattedAmount;
|
||||
}
|
||||
|
||||
public static FormatOptions formatOptions() {
|
||||
return new FormatOptions();
|
||||
}
|
||||
|
||||
public static class FormatOptions {
|
||||
private boolean displayTime = true;
|
||||
private boolean withSymbol = true;
|
||||
|
||||
private FormatOptions() {
|
||||
}
|
||||
|
||||
public @NonNull FormatOptions withDisplayTime(boolean enabled) {
|
||||
this.displayTime = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull FormatOptions withoutSymbol() {
|
||||
this.withSymbol = false;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GeographicalRestrictions {
|
||||
|
||||
private static final String TAG = Log.tag(GeographicalRestrictions.class);
|
||||
|
||||
private GeographicalRestrictions() {}
|
||||
|
||||
private static final Set<Integer> REGION_CODE_SET;
|
||||
|
||||
static {
|
||||
Set<Integer> set = new HashSet<>(BuildConfig.MOBILE_COIN_REGIONS.length);
|
||||
|
||||
for (int i = 0; i < BuildConfig.MOBILE_COIN_REGIONS.length; i++) {
|
||||
set.add(BuildConfig.MOBILE_COIN_REGIONS[i]);
|
||||
}
|
||||
|
||||
REGION_CODE_SET = Collections.unmodifiableSet(set);
|
||||
}
|
||||
|
||||
public static boolean regionAllowed(int regionCode) {
|
||||
return REGION_CODE_SET.contains(regionCode);
|
||||
}
|
||||
|
||||
public static boolean e164Allowed(@Nullable String e164) {
|
||||
try {
|
||||
int countryCode = PhoneNumberUtil.getInstance()
|
||||
.parse(e164, null)
|
||||
.getCountryCode();
|
||||
|
||||
return GeographicalRestrictions.regionAllowed(countryCode);
|
||||
} catch (NumberParseException e) {
|
||||
Log.w(TAG, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.Mnemonics;
|
||||
import com.mobilecoin.lib.exceptions.BadMnemonicException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class Mnemonic {
|
||||
|
||||
public static final List<String> BIP39_WORDS_ENGLISH;
|
||||
|
||||
private final String mnemonic;
|
||||
private final String[] words;
|
||||
|
||||
static {
|
||||
try {
|
||||
BIP39_WORDS_ENGLISH = Collections.unmodifiableList(Arrays.asList(Mnemonics.wordsByPrefix("")));
|
||||
} catch (BadMnemonicException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public Mnemonic(@NonNull String mnemonic) {
|
||||
this.mnemonic = mnemonic;
|
||||
this.words = mnemonic.split(" ");
|
||||
}
|
||||
|
||||
public @NonNull List<String> getWords() {
|
||||
return Collections.unmodifiableList(Arrays.asList(words));
|
||||
}
|
||||
|
||||
public int getWordCount() {
|
||||
return words.length;
|
||||
}
|
||||
|
||||
public String getMnemonic() {
|
||||
return mnemonic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RawRes;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class MobileCoinConfig {
|
||||
|
||||
abstract @NonNull Uri getConsensusUri();
|
||||
|
||||
abstract @NonNull Uri getFogUri();
|
||||
|
||||
abstract @NonNull byte[] getFogAuthoritySpki();
|
||||
|
||||
abstract @NonNull AuthCredentials getAuth() throws IOException;
|
||||
|
||||
abstract @NonNull ClientConfig getConfig();
|
||||
|
||||
public static MobileCoinConfig getTestNet(SignalServiceAccountManager signalServiceAccountManager) {
|
||||
return new MobileCoinTestNetConfig(signalServiceAccountManager);
|
||||
}
|
||||
|
||||
public static MobileCoinConfig getMainNet(SignalServiceAccountManager signalServiceAccountManager) {
|
||||
return new MobileCoinMainNetConfig(signalServiceAccountManager);
|
||||
}
|
||||
|
||||
protected static Set<X509Certificate> getTrustRoots(@RawRes int pemResource) {
|
||||
try (InputStream inputStream = ApplicationDependencies.getApplication().getResources().openRawResource(pemResource)) {
|
||||
Collection<? extends Certificate> certificates = CertificateFactory.getInstance("X.509")
|
||||
.generateCertificates(inputStream);
|
||||
|
||||
HashSet<X509Certificate> x509Certificates = new HashSet<>(certificates.size());
|
||||
for (Certificate c : certificates) {
|
||||
x509Certificates.add((X509Certificate) c);
|
||||
}
|
||||
|
||||
return x509Certificates;
|
||||
} catch (IOException | CertificateException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.MobileCoinLedger;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public final class MobileCoinLedgerWrapper {
|
||||
|
||||
private final MobileCoinLedger ledger;
|
||||
private final Balance balance;
|
||||
|
||||
public MobileCoinLedgerWrapper(@NonNull MobileCoinLedger ledger) {
|
||||
Money.MobileCoin fullAmount = Money.picoMobileCoin(ledger.getBalance());
|
||||
Money.MobileCoin transferableAmount = Money.picoMobileCoin(ledger.getTransferableBalance());
|
||||
|
||||
this.ledger = ledger;
|
||||
this.balance = new Balance(fullAmount, transferableAmount, ledger.getAsOfTimeStamp());
|
||||
}
|
||||
|
||||
public @NonNull Balance getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return ledger.toByteArray();
|
||||
}
|
||||
|
||||
public @NonNull List<OwnedTxo> getAllTxos() {
|
||||
List<OwnedTxo> txoList = new ArrayList<>(ledger.getSpentTxosCount() + ledger.getUnspentTxosCount());
|
||||
addAllMapped(txoList, ledger.getSpentTxosList());
|
||||
addAllMapped(txoList, ledger.getUnspentTxosList());
|
||||
return txoList;
|
||||
}
|
||||
|
||||
private static void addAllMapped(@NonNull List<OwnedTxo> output, @NonNull List<MobileCoinLedger.OwnedTXO> txosList) {
|
||||
for (MobileCoinLedger.OwnedTXO ownedTxo : txosList) {
|
||||
output.add(new OwnedTxo(ownedTxo));
|
||||
}
|
||||
}
|
||||
|
||||
public static class OwnedTxo {
|
||||
private final MobileCoinLedger.OwnedTXO ownedTXO;
|
||||
|
||||
OwnedTxo(MobileCoinLedger.OwnedTXO ownedTXO) {
|
||||
this.ownedTXO = ownedTXO;
|
||||
}
|
||||
|
||||
public @NonNull Money.MobileCoin getValue() {
|
||||
return Money.picoMobileCoin(ownedTXO.getAmount());
|
||||
}
|
||||
|
||||
public @NonNull ByteString getKeyImage() {
|
||||
return ownedTXO.getKeyImage();
|
||||
}
|
||||
|
||||
public @NonNull ByteString getPublicKey() {
|
||||
return ownedTXO.getPublicKey();
|
||||
}
|
||||
|
||||
public long getReceivedInBlock() {
|
||||
return ownedTXO.getReceivedInBlock().getBlockNumber();
|
||||
}
|
||||
|
||||
public @Nullable Long getSpentInBlock() {
|
||||
return nullIfZero(ownedTXO.getSpentInBlock().getBlockNumber());
|
||||
}
|
||||
|
||||
public boolean isSpent() {
|
||||
return ownedTXO.getSpentInBlock().getBlockNumber() != 0;
|
||||
}
|
||||
|
||||
public @Nullable Long getReceivedInBlockTimestamp() {
|
||||
return nullIfZero(ownedTXO.getReceivedInBlock().getTimestamp());
|
||||
}
|
||||
|
||||
public @Nullable Long getSpentInBlockTimestamp() {
|
||||
return nullIfZero(ownedTXO.getSpentInBlock().getTimestamp());
|
||||
}
|
||||
|
||||
private @Nullable Long nullIfZero(long value) {
|
||||
return value == 0 ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.log.LogAdapter;
|
||||
import com.mobilecoin.lib.log.Logger;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
final class MobileCoinLogAdapter implements LogAdapter {
|
||||
|
||||
@Override
|
||||
public boolean isLoggable(@NonNull Logger.Level level, @NonNull String s) {
|
||||
return level.ordinal() >= Logger.Level.WARNING.ordinal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param metadata May contain PII, do not log.
|
||||
*/
|
||||
@Override
|
||||
public void log(@NonNull Logger.Level level,
|
||||
@NonNull String tag,
|
||||
@NonNull String message,
|
||||
@Nullable Throwable throwable,
|
||||
@NonNull Object... metadata)
|
||||
{
|
||||
switch (level) {
|
||||
case INFO:
|
||||
Log.i(tag, message, throwable);
|
||||
break;
|
||||
case VERBOSE:
|
||||
Log.v(tag, message, throwable);
|
||||
break;
|
||||
case DEBUG:
|
||||
Log.d(tag, message, throwable);
|
||||
break;
|
||||
case WARNING:
|
||||
Log.w(tag, message, throwable);
|
||||
break;
|
||||
case ERROR:
|
||||
Log.e(tag, message, throwable);
|
||||
break;
|
||||
case WTF:
|
||||
Log.wtf(tag, message, throwable);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
import com.mobilecoin.lib.Verifier;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import com.mobilecoin.lib.util.Hex;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Set;
|
||||
|
||||
final class MobileCoinMainNetConfig extends MobileCoinConfig {
|
||||
|
||||
private final SignalServiceAccountManager signalServiceAccountManager;
|
||||
|
||||
public MobileCoinMainNetConfig(@NonNull SignalServiceAccountManager signalServiceAccountManager) {
|
||||
this.signalServiceAccountManager = signalServiceAccountManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getConsensusUri() {
|
||||
return Uri.parse("mc://node1.prod.mobilecoinww.com");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getFogUri() {
|
||||
return Uri.parse("fog://service.fog.mob.production.namda.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull byte[] getFogAuthoritySpki() {
|
||||
return Base64.decodeOrThrow("MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxaNIOgcoQtq0S64dFVha\n"
|
||||
+ "6rn0hDv/ec+W0cKRdFKygiyp5xuWdW3YKVAkK1PPgSDD2dwmMN/1xcGWrPMqezx1\n"
|
||||
+ "h1xCzbr7HL7XvLyFyoiMB2JYd7aoIuGIbHpCOlpm8ulVnkOX7BNuo0Hi2F0AAHyT\n"
|
||||
+ "PwmtVMt6RZmae1Z/Pl2I06+GgWN6vufV7jcjiLT3yQPsn1kVSj+DYCf3zq+1sCkn\n"
|
||||
+ "KIvoRPMdQh9Vi3I/fqNXz00DSB7lt3v5/FQ6sPbjljqdGD/qUl4xKRW+EoDLlAUf\n"
|
||||
+ "zahomQOLXVAlxcws3Ua5cZUhaJi6U5jVfw5Ng2N7FwX/D5oX82r9o3xcFqhWpGnf\n"
|
||||
+ "SxSrAudv1X7WskXomKhUzMl/0exWpcJbdrQWB/qshzi9Et7HEDNY+xEDiwGiikj5\n"
|
||||
+ "f0Lb+QA4mBMlAhY/cmWec8NKi1gf3Dmubh6c3sNteb9OpZ/irA3AfE8jI37K1rve\n"
|
||||
+ "zDI8kbNtmYgvyhfz0lZzRT2WAfffiTe565rJglvKa8rh8eszKk2HC9DyxUb/TcyL\n"
|
||||
+ "/OjGhe2fDYO2t6brAXCqjPZAEkVJq3I30NmnPdE19SQeP7wuaUIb3U7MGxoZC/Nu\n"
|
||||
+ "JoxZh8svvZ8cyqVjG+dOQ6/UfrFY0jiswT8AsrfqBis/ZV5EFukZr+zbPtg2MH0H\n"
|
||||
+ "3tSJ14BCLduvc7FY6lAZmOcCAwEAAQ==");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull AuthCredentials getAuth() throws IOException {
|
||||
return signalServiceAccountManager.getPaymentsAuthorization();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull ClientConfig getConfig() {
|
||||
try {
|
||||
byte[] mrEnclaveConsensus = Hex.toByteArray("e66db38b8a43a33f6c1610d335a361963bb2b31e056af0dc0a895ac6c857cab9");
|
||||
byte[] mrEnclaveReport = Hex.toByteArray("709ab90621e3a8d9eb26ed9e2830e091beceebd55fb01c5d7c31d27e83b9b0d1");
|
||||
byte[] mrEnclaveLedger = Hex.toByteArray("511eab36de691ded50eb08b173304194da8b9d86bfdd7102001fe6bb279c3666");
|
||||
byte[] mrEnclaveView = Hex.toByteArray("ddd59da874fdf3239d5edb1ef251df07a8728c9ef63057dd0b50ade5a9ddb041");
|
||||
Set<X509Certificate> trustRoots = getTrustRoots(R.raw.signal_mobilecoin_authority);
|
||||
ClientConfig config = new ClientConfig();
|
||||
String[] hardeningAdvisories = {"INTEL-SA-00334"};
|
||||
|
||||
config.logAdapter = new MobileCoinLogAdapter();
|
||||
config.fogView = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveView, null, hardeningAdvisories));
|
||||
config.fogLedger = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveLedger, null, hardeningAdvisories));
|
||||
config.consensus = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveConsensus, null, hardeningAdvisories));
|
||||
config.report = new ClientConfig.Service().withVerifier(new Verifier().withMrEnclave(mrEnclaveReport, null, hardeningAdvisories));
|
||||
return config;
|
||||
} catch (AttestationException ex) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.PrintableWrapper;
|
||||
import com.mobilecoin.lib.PublicAddress;
|
||||
import com.mobilecoin.lib.exceptions.InvalidUriException;
|
||||
import com.mobilecoin.lib.exceptions.SerializationException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
public final class MobileCoinPublicAddress {
|
||||
|
||||
private static final String TAG = Log.tag(MobileCoinPublicAddress.class);
|
||||
|
||||
private final com.mobilecoin.lib.PublicAddress publicAddress;
|
||||
private final String base58;
|
||||
private final Uri uri;
|
||||
|
||||
static @NonNull MobileCoinPublicAddress fromPublicAddress(@Nullable PublicAddress publicAddress) throws AddressException {
|
||||
if (publicAddress == null) {
|
||||
throw new AddressException("Does not contain a public address");
|
||||
}
|
||||
return new MobileCoinPublicAddress(publicAddress);
|
||||
}
|
||||
|
||||
MobileCoinPublicAddress(@NonNull PublicAddress publicAddress) {
|
||||
this.publicAddress = publicAddress;
|
||||
try {
|
||||
PrintableWrapper printableWrapper = PrintableWrapper.fromPublicAddress(publicAddress);
|
||||
this.base58 = printableWrapper.toB58String();
|
||||
this.uri = printableWrapper.toUri();
|
||||
} catch (SerializationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable MobileCoinPublicAddress fromBytes(@Nullable byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new MobileCoinPublicAddress(PublicAddress.fromBytes(bytes));
|
||||
} catch (SerializationException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static MobileCoinPublicAddress fromBase58NullableOrThrow(@Nullable String base58String) {
|
||||
return base58String != null ? fromBase58OrThrow(base58String) : null;
|
||||
}
|
||||
|
||||
public static @NonNull MobileCoinPublicAddress fromBase58OrThrow(@NonNull String base58String) {
|
||||
try {
|
||||
return fromBase58(base58String);
|
||||
} catch (AddressException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static MobileCoinPublicAddress fromBase58(@NonNull String base58String) throws AddressException {
|
||||
try {
|
||||
PublicAddress publicAddress = PrintableWrapper.fromB58String(base58String).getPublicAddress();
|
||||
|
||||
return MobileCoinPublicAddress.fromPublicAddress(publicAddress);
|
||||
} catch (SerializationException e) {
|
||||
throw new AddressException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull MobileCoinPublicAddress fromQr(@NonNull String data) throws AddressException {
|
||||
try {
|
||||
PrintableWrapper printableWrapper = PrintableWrapper.fromUri(Uri.parse(data));
|
||||
return MobileCoinPublicAddress.fromPublicAddress(printableWrapper.getPublicAddress());
|
||||
} catch (SerializationException | InvalidUriException e) {
|
||||
return fromBase58(data);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull String getPaymentAddressBase58() {
|
||||
return base58;
|
||||
}
|
||||
|
||||
public @NonNull Uri getPaymentAddressUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public @NonNull byte[] serialize() {
|
||||
return publicAddress.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof MobileCoinPublicAddress)) return false;
|
||||
|
||||
return base58.equals(((MobileCoinPublicAddress) o).base58);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return base58.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return base58;
|
||||
}
|
||||
|
||||
PublicAddress getAddress() {
|
||||
return publicAddress;
|
||||
}
|
||||
|
||||
public static final class AddressException extends Exception {
|
||||
|
||||
private AddressException(Throwable e) {
|
||||
super(e);
|
||||
}
|
||||
|
||||
private AddressException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
public final class MobileCoinPublicAddressProfileUtil {
|
||||
|
||||
private MobileCoinPublicAddressProfileUtil() {}
|
||||
|
||||
/**
|
||||
* Signs the supplied address bytes with the {@link IdentityKeyPair}'s private key and returns a proto that includes it and it's signature.
|
||||
*/
|
||||
public static @NonNull SignalServiceProtos.PaymentAddress signPaymentsAddress(@NonNull byte[] publicAddressBytes,
|
||||
@NonNull IdentityKeyPair identityKeyPair)
|
||||
{
|
||||
byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(publicAddressBytes);
|
||||
|
||||
return SignalServiceProtos.PaymentAddress.newBuilder()
|
||||
.setMobileCoinAddress(SignalServiceProtos.PaymentAddress.MobileCoinAddress.newBuilder()
|
||||
.setAddress(ByteString.copyFrom(publicAddressBytes))
|
||||
.setSignature(ByteString.copyFrom(signature)))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the payments address is signed with the supplied {@link IdentityKey}.
|
||||
* <p>
|
||||
* Returns the validated bytes if so, otherwise throws.
|
||||
*/
|
||||
public static @NonNull byte[] verifyPaymentsAddress(@NonNull SignalServiceProtos.PaymentAddress paymentAddress,
|
||||
@NonNull IdentityKey identityKey)
|
||||
throws PaymentsAddressException
|
||||
{
|
||||
if (!paymentAddress.hasMobileCoinAddress()) {
|
||||
throw new PaymentsAddressException(PaymentsAddressException.Code.NO_ADDRESS);
|
||||
}
|
||||
|
||||
byte[] bytes = paymentAddress.getMobileCoinAddress().getAddress().toByteArray();
|
||||
byte[] signature = paymentAddress.getMobileCoinAddress().getSignature().toByteArray();
|
||||
|
||||
if (signature.length != 64 || !identityKey.getPublicKey().verifySignature(bytes, signature)) {
|
||||
throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS_SIGNATURE);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mobilecoin.lib.ClientConfig;
|
||||
import com.mobilecoin.lib.Verifier;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import com.mobilecoin.lib.util.Hex;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Set;
|
||||
|
||||
final class MobileCoinTestNetConfig extends MobileCoinConfig {
|
||||
|
||||
private static final short SECURITY_VERSION = 1;
|
||||
private static final short CONSENSUS_PRODUCT_ID = 1;
|
||||
private static final short FOG_LEDGER_PRODUCT_ID = 2;
|
||||
private static final short FOG_VIEW_PRODUCT_ID = 3;
|
||||
private static final short FOG_REPORT_PRODUCT_ID = 4;
|
||||
|
||||
private final SignalServiceAccountManager signalServiceAccountManager;
|
||||
|
||||
public MobileCoinTestNetConfig(@NonNull SignalServiceAccountManager signalServiceAccountManager) {
|
||||
this.signalServiceAccountManager = signalServiceAccountManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getConsensusUri() {
|
||||
return Uri.parse("mc://node1.test.mobilecoin.com");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull Uri getFogUri() {
|
||||
return Uri.parse("fog://service.fog.mob.staging.namda.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull byte[] getFogAuthoritySpki() {
|
||||
return Base64.decodeOrThrow("MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAoCMq8nnjTq5EEQ4EI7yr\n"
|
||||
+ "ABL9P4y4h1P/h0DepWgXx+w/fywcfRSZINxbaMpvcV3uSJayExrpV1KmaS2wfASe\n"
|
||||
+ "YhSj+rEzAm0XUOw3Q94NOx5A/dOQag/d1SS6/QpF3PQYZTULnRFetmM4yzEnXsXc\n"
|
||||
+ "WtzEu0hh02wYJbLeAq4CCcPTPe2qckrbUP9sD18/KOzzNeypF4p5dQ2m/ezfxtga\n"
|
||||
+ "LvdUMVDVIAs2v9a5iu6ce4bIcwTIUXgX0w3+UKRx8zqowc3HIqo9yeaGn4ZOwQHv\n"
|
||||
+ "AJZecPmb2pH1nK+BtDUvHpvf+Y3/NJxwh+IPp6Ef8aoUxs2g5oIBZ3Q31fjS2Bh2\n"
|
||||
+ "gmwoVooyytEysPAHvRPVBxXxLi36WpKfk1Vq8K7cgYh3IraOkH2/l2Pyi8EYYFkW\n"
|
||||
+ "sLYofYogaiPzVoq2ZdcizfoJWIYei5mgq+8m0ZKZYLebK1i2GdseBJNIbSt3wCNX\n"
|
||||
+ "ZxyN6uqFHOCB29gmA5cbKvs/j9mDz64PJe9LCanqcDQV1U5l9dt9UdmUt7Ab1PjB\n"
|
||||
+ "toIFaP+u473Z0hmZdCgAivuiBMMYMqt2V2EIw4IXLASE3roLOYp0p7h0IQHb+lVI\n"
|
||||
+ "uEl0ZmwAI30ZmzgcWc7RBeWD1/zNt55zzhfPRLx/DfDY5Kdp6oFHWMvI2r1/oZkd\n"
|
||||
+ "hjFp7pV6qrl7vOyR5QqmuRkCAwEAAQ==");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull AuthCredentials getAuth() throws IOException {
|
||||
return signalServiceAccountManager.getPaymentsAuthorization();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull ClientConfig getConfig() {
|
||||
try {
|
||||
byte[] mrEnclaveConsensus = Hex.toByteArray("9268c3220a5260e51e4b586f00e4677fed2b80380f1eeaf775af60f8e880fde8");
|
||||
byte[] mrEnclaveReport = Hex.toByteArray("185875464ccd67a879d58181055383505a719b364b12d56d9bef90a40bed07ca");
|
||||
byte[] mrEnclaveLedger = Hex.toByteArray("7330c9987f21b91313b39dcdeaa7da8da5ca101c929f5740c207742c762e6dcd");
|
||||
byte[] mrEnclaveView = Hex.toByteArray("4e598799faa4bb08a3bd55c0bcda7e1d22e41151d0c591f6c2a48b3562b0881e");
|
||||
byte[] mrSigner = Hex.toByteArray("bf7fa957a6a94acb588851bc8767e0ca57706c79f4fc2aa6bcb993012c3c386c");
|
||||
Set<X509Certificate> trustRoots = getTrustRoots(R.raw.signal_mobilecoin_authority);
|
||||
ClientConfig config = new ClientConfig();
|
||||
String[] hardeningAdvisories = {"INTEL-SA-00334"};
|
||||
|
||||
config.logAdapter = new MobileCoinLogAdapter();
|
||||
config.fogView = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveView, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, FOG_VIEW_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
config.fogLedger = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveLedger, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, FOG_LEDGER_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
config.consensus = new ClientConfig.Service().withTrustRoots(trustRoots)
|
||||
.withVerifier(new Verifier().withMrEnclave(mrEnclaveConsensus, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, CONSENSUS_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
config.report = new ClientConfig.Service().withVerifier(new Verifier().withMrEnclave(mrEnclaveReport, null, hardeningAdvisories)
|
||||
.withMrSigner(mrSigner, FOG_REPORT_PRODUCT_ID, SECURITY_VERSION, null, hardeningAdvisories));
|
||||
return config;
|
||||
} catch (AttestationException ex) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.whispersystems.signalservice.api.payments.Currency;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class MoneyView extends AppCompatTextView {
|
||||
private FormatterOptions formatterOptions;
|
||||
|
||||
public MoneyView(@NonNull Context context) {
|
||||
super(context);
|
||||
|
||||
init(context, null);
|
||||
}
|
||||
|
||||
public MoneyView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
public MoneyView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
public void init(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
FormatterOptions.Builder builder = FormatterOptions.builder(Locale.getDefault());
|
||||
|
||||
TypedArray styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.MoneyView, 0, 0);
|
||||
|
||||
if (styledAttributes.getBoolean(R.styleable.MoneyView_always_show_sign, false)) {
|
||||
builder.alwaysPrefixWithSign();
|
||||
}
|
||||
|
||||
formatterOptions = builder.withoutSpaceBeforeUnit().build();
|
||||
|
||||
String value = styledAttributes.getString(R.styleable.MoneyView_money);
|
||||
if (value != null) {
|
||||
try {
|
||||
setMoney(Money.parse(value));
|
||||
} catch (Money.ParseException e) {
|
||||
throw new AssertionError("Invalid money format", e);
|
||||
}
|
||||
}
|
||||
|
||||
styledAttributes.recycle();
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull String amount, @NonNull Currency currency) {
|
||||
SpannableString balanceSpan = new SpannableString(amount + currency.getCurrencyCode());
|
||||
int currencyIndex = balanceSpan.length() - currency.getCurrencyCode().length();
|
||||
balanceSpan.setSpan(new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.payment_currency_code_foreground_color)), currencyIndex, currencyIndex + currency.getCurrencyCode().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
setText(balanceSpan);
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull Money money) {
|
||||
setMoney(money, true, 0L);
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull Money money, boolean highlightCurrency) {
|
||||
setMoney(money, highlightCurrency, 0L);
|
||||
}
|
||||
|
||||
public void setMoney(@NonNull Money money, boolean highlightCurrency, long timestamp) {
|
||||
String balance = money.toString(formatterOptions);
|
||||
int currencyIndex = balance.indexOf(money.getCurrency().getCurrencyCode());
|
||||
|
||||
final SpannableString balanceSpan;
|
||||
|
||||
if (timestamp > 0L) {
|
||||
balanceSpan = new SpannableString(getResources().getString(R.string.CurrencyAmountFormatter_s_at_s,
|
||||
balance,
|
||||
DateUtils.getTimeString(ApplicationDependencies.getApplication(), Locale.getDefault(), timestamp)));
|
||||
} else {
|
||||
balanceSpan = new SpannableString(balance);
|
||||
}
|
||||
|
||||
if (highlightCurrency) {
|
||||
balanceSpan.setSpan(new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.payment_currency_code_foreground_color)), currencyIndex, currencyIndex + money.getCurrency().getCurrencyCode().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
setText(balanceSpan);
|
||||
}
|
||||
|
||||
private static @NonNull NumberFormat getMoneyFormat(int decimalPrecision) {
|
||||
NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.getDefault());
|
||||
|
||||
numberFormat.setGroupingUsed(true);
|
||||
numberFormat.setMaximumFractionDigits(decimalPrecision);
|
||||
|
||||
return numberFormat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Payee {
|
||||
private final RecipientId recipientId;
|
||||
private final MobileCoinPublicAddress publicAddress;
|
||||
|
||||
/**
|
||||
* Used for reconstructed payments from the ledger where we do not know who it was from or to.
|
||||
*/
|
||||
public static final Payee UNKNOWN = new Payee(null, null);
|
||||
|
||||
public static Payee fromRecipientAndAddress(@NonNull RecipientId recipientId, @NonNull MobileCoinPublicAddress publicAddress) {
|
||||
return new Payee(Objects.requireNonNull(recipientId), publicAddress);
|
||||
}
|
||||
|
||||
public Payee(@NonNull RecipientId recipientId) {
|
||||
this(Objects.requireNonNull(recipientId), null);
|
||||
}
|
||||
|
||||
public Payee(@NonNull MobileCoinPublicAddress publicAddress) {
|
||||
this(null, Objects.requireNonNull(publicAddress));
|
||||
}
|
||||
|
||||
private Payee(@Nullable RecipientId recipientId, @Nullable MobileCoinPublicAddress publicAddress) {
|
||||
this.recipientId = recipientId;
|
||||
this.publicAddress = publicAddress;
|
||||
}
|
||||
|
||||
public boolean hasRecipientId() {
|
||||
return recipientId != null && !recipientId.isUnknown();
|
||||
}
|
||||
|
||||
public @NonNull RecipientId requireRecipientId() {
|
||||
return Objects.requireNonNull(recipientId);
|
||||
}
|
||||
|
||||
public boolean hasPublicAddress() {
|
||||
return publicAddress != null;
|
||||
}
|
||||
|
||||
public @NonNull MobileCoinPublicAddress requirePublicAddress() {
|
||||
return Objects.requireNonNull(publicAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final Payee payee = (Payee) o;
|
||||
return Objects.equals(recipientId, payee.recipientId) &&
|
||||
Objects.equals(publicAddress, payee.publicAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(recipientId, publicAddress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.ComparatorCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Represents one payment as displayed to the user.
|
||||
* <p>
|
||||
* It could be from a sent or received Signal payment message or reconstructed.
|
||||
*/
|
||||
public interface Payment {
|
||||
Comparator<Payment> UNKNOWN_BLOCK_INDEX_FIRST = (a, b) -> Boolean.compare(b.getBlockIndex() == 0, a.getBlockIndex() == 0);
|
||||
Comparator<Payment> ASCENDING_BLOCK_INDEX = (a, b) -> Long.compare(a.getBlockIndex(), b.getBlockIndex());
|
||||
Comparator<Payment> DESCENDING_BLOCK_INDEX = ComparatorCompat.reversed(ASCENDING_BLOCK_INDEX);
|
||||
Comparator<Payment> DESCENDING_BLOCK_INDEX_UNKNOWN_FIRST = ComparatorCompat.chain(UNKNOWN_BLOCK_INDEX_FIRST)
|
||||
.thenComparing(DESCENDING_BLOCK_INDEX);
|
||||
|
||||
@NonNull UUID getUuid();
|
||||
|
||||
@NonNull Payee getPayee();
|
||||
|
||||
long getBlockIndex();
|
||||
|
||||
long getBlockTimestamp();
|
||||
|
||||
long getTimestamp();
|
||||
|
||||
default long getDisplayTimestamp() {
|
||||
long blockTimestamp = getBlockTimestamp();
|
||||
if (blockTimestamp > 0) {
|
||||
return blockTimestamp;
|
||||
} else {
|
||||
return getTimestamp();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull Direction getDirection();
|
||||
|
||||
@NonNull State getState();
|
||||
|
||||
@Nullable FailureReason getFailureReason();
|
||||
|
||||
@NonNull String getNote();
|
||||
|
||||
/**
|
||||
* Always >= 0, does not include fee
|
||||
*/
|
||||
@NonNull Money getAmount();
|
||||
|
||||
/**
|
||||
* Always >= 0
|
||||
*/
|
||||
@NonNull Money getFee();
|
||||
|
||||
@NonNull PaymentMetaData getPaymentMetaData();
|
||||
|
||||
boolean isSeen();
|
||||
|
||||
/**
|
||||
* Negative if sent, positive if received.
|
||||
*/
|
||||
default @NonNull Money getAmountWithDirection() {
|
||||
switch (getDirection()) {
|
||||
case SENT : return getAmount().negate();
|
||||
case RECEIVED: return getAmount();
|
||||
default : throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative if sent including fee, positive if received.
|
||||
*/
|
||||
default @NonNull Money getAmountPlusFeeWithDirection() {
|
||||
switch (getDirection()) {
|
||||
case SENT : return getAmount().add(getFee()).negate();
|
||||
case RECEIVED: return getAmount();
|
||||
default : throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
default boolean isDefrag() {
|
||||
return getDirection() == Direction.SENT &&
|
||||
getPayee().hasRecipientId() &&
|
||||
getPayee().requireRecipientId().equals(Recipient.self().getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public abstract class PaymentDecorator implements Payment {
|
||||
|
||||
private final Payment inner;
|
||||
|
||||
public PaymentDecorator(@NonNull Payment inner) {
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
public @NonNull Payment getInner() {
|
||||
return inner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UUID getUuid() {
|
||||
return inner.getUuid();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return inner.getPayee();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return inner.getBlockIndex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return inner.getBlockTimestamp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return inner.getTimestamp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return inner.getDirection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return inner.getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return inner.getFailureReason();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return inner.getNote();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return inner.getAmount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return inner.getFee();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return inner.getPaymentMetaData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return inner.isSeen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Wraps a Payment and enables it to be parcelized.
|
||||
*/
|
||||
public class PaymentParcelable implements Parcelable {
|
||||
|
||||
private final Payment payment;
|
||||
|
||||
public PaymentParcelable(@NonNull Payment payment) {
|
||||
this.payment = payment;
|
||||
}
|
||||
|
||||
protected PaymentParcelable(Parcel in) {
|
||||
this.payment = new ParcelPayment(in);
|
||||
}
|
||||
|
||||
public @NonNull Payment getPayment() {
|
||||
return payment;
|
||||
}
|
||||
|
||||
public static final Creator<PaymentParcelable> CREATOR = new Creator<PaymentParcelable>() {
|
||||
@Override
|
||||
public PaymentParcelable createFromParcel(Parcel in) {
|
||||
return new PaymentParcelable(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentParcelable[] newArray(int size) {
|
||||
return new PaymentParcelable[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(payment.getUuid().toString());
|
||||
dest.writeParcelable(new PayeeParcelable(payment.getPayee()), flags);
|
||||
dest.writeLong(payment.getBlockIndex());
|
||||
dest.writeLong(payment.getBlockTimestamp());
|
||||
dest.writeLong(payment.getTimestamp());
|
||||
dest.writeLong(payment.getDisplayTimestamp());
|
||||
dest.writeInt(payment.getDirection().serialize());
|
||||
dest.writeInt(payment.getState().serialize());
|
||||
|
||||
if (payment.getFailureReason() == null) {
|
||||
dest.writeInt(-1);
|
||||
} else {
|
||||
dest.writeInt(payment.getFailureReason().serialize());
|
||||
}
|
||||
|
||||
dest.writeString(payment.getNote());
|
||||
dest.writeString(payment.getAmount().serialize());
|
||||
dest.writeString(payment.getFee().serialize());
|
||||
dest.writeByteArray(payment.getPaymentMetaData().toByteArray());
|
||||
dest.writeByte(payment.isSeen() ? (byte) 1 : 0);
|
||||
dest.writeString(payment.getAmountWithDirection().serialize());
|
||||
dest.writeString(payment.getAmountPlusFeeWithDirection().serialize());
|
||||
dest.writeByte(payment.isDefrag() ? (byte) 1 : 0);
|
||||
}
|
||||
|
||||
private static final class ParcelPayment implements Payment {
|
||||
|
||||
private final UUID uuid;
|
||||
private final Payee payee;
|
||||
private final long blockIndex;
|
||||
private final long blockTimestamp;
|
||||
private final long timestamp;
|
||||
private final long displayTimestamp;
|
||||
private final Direction direction;
|
||||
private final State state;
|
||||
private final FailureReason failureReason;
|
||||
private final String note;
|
||||
private final Money amount;
|
||||
private final Money fee;
|
||||
private final PaymentMetaData paymentMetaData;
|
||||
private final boolean isSeen;
|
||||
private final Money amountWithDirection;
|
||||
private final Money amountPlusFeeWithDirection;
|
||||
private final boolean isDefrag;
|
||||
|
||||
private ParcelPayment(Parcel in) {
|
||||
try {
|
||||
uuid = UUID.fromString(in.readString());
|
||||
|
||||
PayeeParcelable payeeParcelable = in.readParcelable(PayeeParcelable.class.getClassLoader());
|
||||
payee = payeeParcelable.getPayee();
|
||||
|
||||
blockIndex = in.readLong();
|
||||
blockTimestamp = in.readLong();
|
||||
timestamp = in.readLong();
|
||||
displayTimestamp = in.readLong();
|
||||
direction = Direction.deserialize(in.readInt());
|
||||
state = State.deserialize(in.readInt());
|
||||
|
||||
int failureReasonSerialized = in.readInt();
|
||||
if (failureReasonSerialized == -1) {
|
||||
failureReason = null;
|
||||
} else {
|
||||
failureReason = FailureReason.deserialize(failureReasonSerialized);
|
||||
}
|
||||
|
||||
note = in.readString();
|
||||
amount = Money.parse(in.readString());
|
||||
fee = Money.parse(in.readString());
|
||||
paymentMetaData = PaymentMetaData.parseFrom(in.createByteArray());
|
||||
isSeen = in.readByte() == 1;
|
||||
amountWithDirection = Money.parse(in.readString());
|
||||
amountPlusFeeWithDirection = Money.parse(in.readString());
|
||||
isDefrag = in.readByte() == 1;
|
||||
} catch (Money.ParseException | InvalidProtocolBufferException e) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDisplayTimestamp() {
|
||||
return displayTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return failureReason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return paymentMetaData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return isSeen;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmountWithDirection() {
|
||||
return amountWithDirection;
|
||||
}
|
||||
|
||||
@NonNull @Override public Money getAmountPlusFeeWithDirection() {
|
||||
return amountPlusFeeWithDirection;
|
||||
}
|
||||
|
||||
@Override public boolean isDefrag() {
|
||||
return isDefrag;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A payment may be comprised of zero or more defrag transactions and the payment transaction.
|
||||
* <p>
|
||||
* Or a number of successful transactions and a failed transaction.
|
||||
*/
|
||||
public final class PaymentSubmissionResult {
|
||||
|
||||
private final List<TransactionSubmissionResult> defrags;
|
||||
private final TransactionSubmissionResult nonDefrag;
|
||||
private final TransactionSubmissionResult erroredTransaction;
|
||||
|
||||
PaymentSubmissionResult(@NonNull List<TransactionSubmissionResult> transactions) {
|
||||
if (transactions.isEmpty()) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
this.defrags = Stream.of(transactions)
|
||||
.filter(TransactionSubmissionResult::isDefrag)
|
||||
.toList();
|
||||
this.nonDefrag = Stream.of(transactions)
|
||||
.filterNot(TransactionSubmissionResult::isDefrag)
|
||||
.findSingle()
|
||||
.orElse(null);
|
||||
this.erroredTransaction = Stream.of(transactions)
|
||||
.filter(t -> t.getErrorCode() != TransactionSubmissionResult.ErrorCode.NONE)
|
||||
.findSingle()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<TransactionSubmissionResult> defrags() {
|
||||
return defrags;
|
||||
}
|
||||
|
||||
public boolean containsDefrags() {
|
||||
return defrags.size() > 0;
|
||||
}
|
||||
|
||||
public @Nullable TransactionSubmissionResult getNonDefrag() {
|
||||
return nonDefrag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could return the error that happened during a defrag or the main transaction.
|
||||
*/
|
||||
public TransactionSubmissionResult.ErrorCode getErrorCode() {
|
||||
return erroredTransaction != null ? erroredTransaction.getErrorCode()
|
||||
: TransactionSubmissionResult.ErrorCode.NONE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public abstract class PaymentTransactionId {
|
||||
|
||||
private PaymentTransactionId() {}
|
||||
|
||||
public static final class MobileCoin extends PaymentTransactionId {
|
||||
|
||||
private final byte[] transaction;
|
||||
private final byte[] receipt;
|
||||
private final Money.MobileCoin fee;
|
||||
|
||||
public MobileCoin(@NonNull byte[] transaction,
|
||||
@NonNull byte[] receipt,
|
||||
@NonNull Money.MobileCoin fee)
|
||||
{
|
||||
this.transaction = transaction;
|
||||
this.receipt = receipt;
|
||||
this.fee = fee;
|
||||
|
||||
if (transaction.length == 0 || receipt.length == 0) {
|
||||
throw new AssertionError("Both transaction and receipt must be specified");
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull byte[] getTransaction() {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public @NonNull byte[] getReceipt() {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
public @NonNull Money.MobileCoin getFee() {
|
||||
return fee;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public final class PaymentTransactionLiveData extends LiveData<PaymentDatabase.PaymentTransaction> {
|
||||
|
||||
private final UUID paymentId;
|
||||
private final PaymentDatabase paymentDatabase;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Executor executor;
|
||||
|
||||
public PaymentTransactionLiveData(@NonNull UUID paymentId) {
|
||||
this.paymentId = paymentId;
|
||||
this.paymentDatabase = DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication());
|
||||
this.observer = this::getPaymentTransaction;
|
||||
this.executor = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActive() {
|
||||
getPaymentTransaction();
|
||||
ApplicationDependencies.getDatabaseObserver().registerPaymentObserver(paymentId, observer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onInactive() {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
|
||||
}
|
||||
|
||||
private void getPaymentTransaction() {
|
||||
executor.execute(() -> postValue(paymentDatabase.getPayment(paymentId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.whispersystems.signalservice.api.payments.CurrencyConversion;
|
||||
import org.whispersystems.signalservice.api.payments.CurrencyConversions;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class Payments {
|
||||
|
||||
private static final String TAG = Log.tag(Payments.class);
|
||||
|
||||
private static final long MINIMUM_ELAPSED_TIME_BETWEEN_REFRESH = TimeUnit.MINUTES.toMillis(1);
|
||||
|
||||
private final MobileCoinConfig mobileCoinConfig;
|
||||
|
||||
private Wallet wallet;
|
||||
private CurrencyConversions currencyConversions;
|
||||
|
||||
public Payments(@NonNull MobileCoinConfig mobileCoinConfig) {
|
||||
this.mobileCoinConfig = mobileCoinConfig;
|
||||
}
|
||||
|
||||
public synchronized Wallet getWallet() {
|
||||
if (wallet != null) {
|
||||
return wallet;
|
||||
}
|
||||
Entropy paymentsEntropy = SignalStore.paymentsValues().getPaymentsEntropy();
|
||||
wallet = new Wallet(mobileCoinConfig, Objects.requireNonNull(paymentsEntropy));
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public synchronized void closeWallet() {
|
||||
wallet = null;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public synchronized @NonNull CurrencyExchange getCurrencyExchange(boolean refreshIfAble) throws IOException {
|
||||
if (currencyConversions == null || shouldRefresh(refreshIfAble, currencyConversions.getTimestamp())) {
|
||||
Log.i(TAG, "Currency conversion data is unavailable or a refresh was requested and available");
|
||||
CurrencyConversions newCurrencyConversions = ApplicationDependencies.getSignalServiceAccountManager().getCurrencyConversions();
|
||||
if (currencyConversions == null || (newCurrencyConversions != null && newCurrencyConversions.getTimestamp() > currencyConversions.getTimestamp())) {
|
||||
currencyConversions = newCurrencyConversions;
|
||||
}
|
||||
}
|
||||
|
||||
if (currencyConversions != null) {
|
||||
for (CurrencyConversion currencyConversion : currencyConversions.getCurrencies()) {
|
||||
if ("MOB".equals(currencyConversion.getBase())) {
|
||||
return new CurrencyExchange(currencyConversion.getConversions(), currencyConversions.getTimestamp());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("Unable to retrieve currency conversions");
|
||||
}
|
||||
|
||||
private boolean shouldRefresh(boolean refreshIfAble, long lastRefreshTime) {
|
||||
return refreshIfAble && System.currentTimeMillis() - lastRefreshTime >= MINIMUM_ELAPSED_TIME_BETWEEN_REFRESH;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class PaymentsAddressException extends Exception {
|
||||
|
||||
private final Code code;
|
||||
|
||||
public PaymentsAddressException(@NonNull Code code) {
|
||||
super(code.message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public @NonNull Code getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public enum Code {
|
||||
NO_PROFILE_KEY("No profile key available"),
|
||||
NOT_ENABLED("Payments not enabled"),
|
||||
COULD_NOT_DECRYPT("Payment address could not be decrypted"),
|
||||
INVALID_ADDRESS("Invalid MobileCoin address on payments address proto"),
|
||||
INVALID_ADDRESS_SIGNATURE("Invalid MobileCoin address signature on payments address proto"),
|
||||
NO_ADDRESS("No MobileCoin address on payments address proto");
|
||||
|
||||
private final String message;
|
||||
|
||||
Code(@NonNull String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ReconstructedPayment implements Payment {
|
||||
|
||||
private final long blockIndex;
|
||||
private final long blockTimestamp;
|
||||
private final Direction direction;
|
||||
private final Money amount;
|
||||
|
||||
public ReconstructedPayment(long blockIndex,
|
||||
long blockTimestamp,
|
||||
@NonNull Direction direction,
|
||||
@NonNull Money amount)
|
||||
{
|
||||
this.blockIndex = blockIndex;
|
||||
this.blockTimestamp = blockTimestamp;
|
||||
this.direction = direction;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public @Override UUID getUuid() {
|
||||
return UuidUtil.UNKNOWN_UUID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Payee getPayee() {
|
||||
return Payee.UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getBlockTimestamp() {
|
||||
return blockTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Direction getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull State getState() {
|
||||
return State.SUCCESSFUL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable FailureReason getFailureReason() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getNote() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Money getFee() {
|
||||
return amount.toZero();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PaymentMetaData getPaymentMetaData() {
|
||||
return PaymentMetaData.getDefaultInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeen() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum State {
|
||||
// These are serialized into the database, do not change the values.
|
||||
INITIAL(0),
|
||||
SUBMITTED(1),
|
||||
SUCCESSFUL(2),
|
||||
FAILED(3);
|
||||
|
||||
private final int value;
|
||||
|
||||
State(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int serialize() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static @NonNull State deserialize(int value) {
|
||||
if (value == State.INITIAL.value) return State.INITIAL;
|
||||
else if (value == State.SUBMITTED.value) return State.SUBMITTED;
|
||||
else if (value == State.SUCCESSFUL.value) return State.SUCCESSFUL;
|
||||
else if (value == State.FAILED.value) return State.FAILED;
|
||||
else throw new AssertionError("" + value);
|
||||
}
|
||||
|
||||
public boolean isInProgress() {
|
||||
return this == INITIAL || this == SUBMITTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class TransactionSubmissionResult {
|
||||
|
||||
private final PaymentTransactionId transaction;
|
||||
private final ErrorCode code;
|
||||
private final boolean defrag;
|
||||
|
||||
private TransactionSubmissionResult(@Nullable PaymentTransactionId transaction, @NonNull ErrorCode code, boolean defrag) {
|
||||
this.transaction = transaction;
|
||||
this.code = code;
|
||||
this.defrag = defrag;
|
||||
}
|
||||
|
||||
static TransactionSubmissionResult successfullySubmittedDefrag(@NonNull PaymentTransactionId transaction) {
|
||||
return new TransactionSubmissionResult(transaction, ErrorCode.NONE, true);
|
||||
}
|
||||
|
||||
static @NonNull TransactionSubmissionResult successfullySubmitted(@NonNull PaymentTransactionId transaction) {
|
||||
return new TransactionSubmissionResult(transaction, ErrorCode.NONE, false);
|
||||
}
|
||||
|
||||
static @NonNull TransactionSubmissionResult failure(@NonNull ErrorCode code, boolean defrag) {
|
||||
return new TransactionSubmissionResult(null, code, defrag);
|
||||
}
|
||||
|
||||
public @NonNull PaymentTransactionId getTransactionId() {
|
||||
if (transaction == null) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public @NonNull ErrorCode getErrorCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public boolean isDefrag() {
|
||||
return defrag;
|
||||
}
|
||||
|
||||
public enum ErrorCode {
|
||||
INSUFFICIENT_FUNDS,
|
||||
GENERIC_FAILURE,
|
||||
NETWORK_FAILURE,
|
||||
NONE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public class UnreadPaymentsRepository {
|
||||
|
||||
private static final Executor EXECUTOR = SignalExecutors.BOUNDED;
|
||||
|
||||
public void markAllPaymentsSeen() {
|
||||
EXECUTOR.execute(this::markAllPaymentsSeenInternal);
|
||||
}
|
||||
|
||||
public void markPaymentSeen(@NonNull UUID paymentId) {
|
||||
EXECUTOR.execute(() -> markPaymentSeenInternal(paymentId));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void markAllPaymentsSeenInternal() {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
DatabaseFactory.getPaymentDatabase(context).markAllSeen();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void markPaymentSeenInternal(@NonNull UUID paymentId) {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
DatabaseFactory.getPaymentDatabase(context).markPaymentSeen(paymentId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package org.thoughtcrime.securesms.payments;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.mobilecoin.lib.AccountKey;
|
||||
import com.mobilecoin.lib.AccountSnapshot;
|
||||
import com.mobilecoin.lib.DefragmentationDelegate;
|
||||
import com.mobilecoin.lib.MobileCoinClient;
|
||||
import com.mobilecoin.lib.OwnedTxOut;
|
||||
import com.mobilecoin.lib.PendingTransaction;
|
||||
import com.mobilecoin.lib.Receipt;
|
||||
import com.mobilecoin.lib.Transaction;
|
||||
import com.mobilecoin.lib.UnsignedLong;
|
||||
import com.mobilecoin.lib.exceptions.AmountDecoderException;
|
||||
import com.mobilecoin.lib.exceptions.AttestationException;
|
||||
import com.mobilecoin.lib.exceptions.BadEntropyException;
|
||||
import com.mobilecoin.lib.exceptions.FeeRejectedException;
|
||||
import com.mobilecoin.lib.exceptions.FogReportException;
|
||||
import com.mobilecoin.lib.exceptions.FragmentedAccountException;
|
||||
import com.mobilecoin.lib.exceptions.InsufficientFundsException;
|
||||
import com.mobilecoin.lib.exceptions.InvalidFogResponse;
|
||||
import com.mobilecoin.lib.exceptions.InvalidReceiptException;
|
||||
import com.mobilecoin.lib.exceptions.InvalidTransactionException;
|
||||
import com.mobilecoin.lib.exceptions.InvalidUriException;
|
||||
import com.mobilecoin.lib.exceptions.NetworkException;
|
||||
import com.mobilecoin.lib.exceptions.SerializationException;
|
||||
import com.mobilecoin.lib.exceptions.TransactionBuilderException;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.proto.MobileCoinLedger;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
import org.whispersystems.signalservice.api.util.Uint64RangeException;
|
||||
import org.whispersystems.signalservice.api.util.Uint64Util;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class Wallet {
|
||||
|
||||
private static final String TAG = Log.tag(Wallet.class);
|
||||
|
||||
private final MobileCoinConfig mobileCoinConfig;
|
||||
private final MobileCoinClient mobileCoinClient;
|
||||
private final AccountKey account;
|
||||
private final MobileCoinPublicAddress publicAddress;
|
||||
|
||||
public Wallet(@NonNull MobileCoinConfig mobileCoinConfig, @NonNull Entropy paymentsEntropy) {
|
||||
this.mobileCoinConfig = mobileCoinConfig;
|
||||
try {
|
||||
this.account = AccountKey.fromBip39Entropy(paymentsEntropy.getBytes(), 0, mobileCoinConfig.getFogUri(), "", mobileCoinConfig.getFogAuthoritySpki());
|
||||
this.publicAddress = new MobileCoinPublicAddress(account.getPublicAddress());
|
||||
|
||||
this.mobileCoinClient = new MobileCoinClient(account,
|
||||
mobileCoinConfig.getFogUri(),
|
||||
mobileCoinConfig.getConsensusUri(),
|
||||
mobileCoinConfig.getConfig());
|
||||
} catch (InvalidUriException | BadEntropyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
try {
|
||||
reauthorizeClient();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to authorize client", e);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull MobileCoinPublicAddress getMobileCoinPublicAddress() {
|
||||
return publicAddress;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull Balance getCachedBalance() {
|
||||
return SignalStore.paymentsValues().mobileCoinLatestBalance();
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public @NonNull MobileCoinLedgerWrapper getCachedLedger() {
|
||||
return SignalStore.paymentsValues().mobileCoinLatestFullLedger();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull MobileCoinLedgerWrapper getFullLedger() {
|
||||
return getFullLedger(true);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull MobileCoinLedgerWrapper getFullLedger(boolean retryOnAuthFailure) {
|
||||
PaymentsValues paymentsValues = SignalStore.paymentsValues();
|
||||
try {
|
||||
MobileCoinLedgerWrapper ledger = tryGetFullLedger(null);
|
||||
|
||||
paymentsValues.setMobileCoinFullLedger(Objects.requireNonNull(ledger));
|
||||
} catch (IOException e) {
|
||||
if ((retryOnAuthFailure && e.getCause() instanceof NetworkException) &&
|
||||
(((NetworkException) e.getCause()).statusCode == 401)) {
|
||||
Log.w(TAG, "Failed to get up to date ledger, due to temp auth failure, retrying", e);
|
||||
return getFullLedger(false);
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get up to date ledger", e);
|
||||
}
|
||||
}
|
||||
|
||||
return getCachedLedger();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @Nullable MobileCoinLedgerWrapper tryGetFullLedger(@Nullable Long minimumBlockIndex) throws IOException {
|
||||
try {
|
||||
MobileCoinLedger.Builder builder = MobileCoinLedger.newBuilder();
|
||||
BigInteger totalUnspent = BigInteger.ZERO;
|
||||
long highestBlockTimeStamp = 0;
|
||||
UnsignedLong highestBlockIndex = UnsignedLong.ZERO;
|
||||
final long asOfTimestamp = System.currentTimeMillis();
|
||||
AccountSnapshot accountSnapshot = mobileCoinClient.getAccountSnapshot();
|
||||
|
||||
if (minimumBlockIndex != null) {
|
||||
long snapshotBlockIndex = accountSnapshot.getBlockIndex().longValue();
|
||||
if (snapshotBlockIndex < minimumBlockIndex) {
|
||||
Log.d(TAG, "Waiting for block index");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (OwnedTxOut txOut : accountSnapshot.getAccountActivity().getAllTxOuts()) {
|
||||
MobileCoinLedger.OwnedTXO.Builder txoBuilder = MobileCoinLedger.OwnedTXO.newBuilder()
|
||||
.setAmount(Uint64Util.bigIntegerToUInt64(txOut.getValue()))
|
||||
.setReceivedInBlock(getBlock(txOut.getReceivedBlockIndex(), txOut.getReceivedBlockTimestamp()))
|
||||
.setKeyImage(ByteString.copyFrom(txOut.getKeyImage().getData()))
|
||||
.setPublicKey(ByteString.copyFrom(txOut.getPublicKey().getKeyBytes()));
|
||||
if (txOut.getSpentBlockIndex() != null &&
|
||||
(minimumBlockIndex == null ||
|
||||
txOut.isSpent(UnsignedLong.valueOf(minimumBlockIndex))))
|
||||
{
|
||||
txoBuilder.setSpentInBlock(getBlock(txOut.getSpentBlockIndex(), txOut.getSpentBlockTimestamp()));
|
||||
builder.addSpentTxos(txoBuilder);
|
||||
} else {
|
||||
totalUnspent = totalUnspent.add(txOut.getValue());
|
||||
builder.addUnspentTxos(txoBuilder);
|
||||
}
|
||||
|
||||
if (txOut.getSpentBlockIndex() != null && txOut.getSpentBlockIndex().compareTo(highestBlockIndex) > 0) {
|
||||
highestBlockIndex = txOut.getSpentBlockIndex();
|
||||
}
|
||||
|
||||
if (txOut.getReceivedBlockIndex().compareTo(highestBlockIndex) > 0) {
|
||||
highestBlockIndex = txOut.getReceivedBlockIndex();
|
||||
}
|
||||
|
||||
if (txOut.getSpentBlockTimestamp() != null && txOut.getSpentBlockTimestamp().getTime() > highestBlockTimeStamp) {
|
||||
highestBlockTimeStamp = txOut.getSpentBlockTimestamp().getTime();
|
||||
}
|
||||
|
||||
if (txOut.getReceivedBlockTimestamp() != null && txOut.getReceivedBlockTimestamp().getTime() > highestBlockTimeStamp) {
|
||||
highestBlockTimeStamp = txOut.getReceivedBlockTimestamp().getTime();
|
||||
}
|
||||
}
|
||||
builder.setBalance(Uint64Util.bigIntegerToUInt64(totalUnspent))
|
||||
.setTransferableBalance(Uint64Util.bigIntegerToUInt64(accountSnapshot.getTransferableAmount()))
|
||||
.setAsOfTimeStamp(asOfTimestamp)
|
||||
.setHighestBlock(MobileCoinLedger.Block.newBuilder()
|
||||
.setBlockNumber(highestBlockIndex.longValue())
|
||||
.setTimestamp(highestBlockTimeStamp));
|
||||
|
||||
return new MobileCoinLedgerWrapper(builder.build());
|
||||
} catch (InvalidFogResponse e) {
|
||||
Log.w(TAG, "Problem getting ledger", e);
|
||||
throw new IOException(e);
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Network problem getting ledger", e);
|
||||
if (e.statusCode == 401) {
|
||||
Log.d(TAG, "Reauthorizing client");
|
||||
reauthorizeClient();
|
||||
}
|
||||
throw new IOException(e);
|
||||
} catch (AttestationException e) {
|
||||
Log.w(TAG, "Attestation problem getting ledger", e);
|
||||
throw new IOException(e);
|
||||
} catch (Uint64RangeException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable MobileCoinLedger.Block getBlock(@NonNull UnsignedLong blockIndex, @Nullable Date timeStamp) throws Uint64RangeException {
|
||||
MobileCoinLedger.Block.Builder builder = MobileCoinLedger.Block.newBuilder();
|
||||
builder.setBlockNumber(Uint64Util.bigIntegerToUInt64(blockIndex.toBigInteger()));
|
||||
if (timeStamp != null) {
|
||||
builder.setTimestamp(timeStamp.getTime());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull Money.MobileCoin getFee(@NonNull Money.MobileCoin amount) throws IOException {
|
||||
try {
|
||||
BigInteger picoMob = amount.requireMobileCoin().toPicoMobBigInteger();
|
||||
return Money.picoMobileCoin(mobileCoinClient.estimateTotalFee(picoMob));
|
||||
} catch (InvalidFogResponse | AttestationException | InsufficientFundsException e) {
|
||||
Log.w(TAG, "Failed to get fee", e);
|
||||
return Money.MobileCoin.ZERO;
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Failed to get fee", e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull PaymentSubmissionResult sendPayment(@NonNull MobileCoinPublicAddress to,
|
||||
@NonNull Money.MobileCoin amount,
|
||||
@NonNull Money.MobileCoin totalFee)
|
||||
{
|
||||
List<TransactionSubmissionResult> transactionSubmissionResults = new LinkedList<>();
|
||||
sendPayment(to, amount, totalFee, false, transactionSubmissionResults);
|
||||
return new PaymentSubmissionResult(transactionSubmissionResults);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull TransactionStatusResult getSentTransactionStatus(@NonNull PaymentTransactionId transactionId) throws IOException {
|
||||
try {
|
||||
PaymentTransactionId.MobileCoin mobcoinTransaction = (PaymentTransactionId.MobileCoin) transactionId;
|
||||
Transaction transaction = Transaction.fromBytes(mobcoinTransaction.getTransaction());
|
||||
Transaction.Status status = mobileCoinClient.getAccountSnapshot()
|
||||
.getTransactionStatus(transaction);
|
||||
|
||||
switch (status) {
|
||||
case UNKNOWN:
|
||||
Log.w(TAG, "Unknown sent Transaction Status");
|
||||
return TransactionStatusResult.inProgress();
|
||||
case FAILED:
|
||||
return TransactionStatusResult.failed();
|
||||
case ACCEPTED:
|
||||
return TransactionStatusResult.complete(status.getBlockIndex().longValue());
|
||||
default:
|
||||
throw new IllegalStateException("Unknown Transaction Status: " + status);
|
||||
}
|
||||
} catch (SerializationException | InvalidFogResponse e) {
|
||||
Log.w(TAG, e);
|
||||
return TransactionStatusResult.failed();
|
||||
} catch (NetworkException | AttestationException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull ReceivedTransactionStatus getReceivedTransactionStatus(@NonNull byte[] receiptBytes) throws IOException {
|
||||
try {
|
||||
Receipt receipt = Receipt.fromBytes(receiptBytes);
|
||||
Receipt.Status status = mobileCoinClient.getReceiptStatus(receipt);
|
||||
switch (status) {
|
||||
case UNKNOWN:
|
||||
Log.w(TAG, "Unknown received Transaction Status");
|
||||
return ReceivedTransactionStatus.inProgress();
|
||||
case FAILED:
|
||||
return ReceivedTransactionStatus.failed();
|
||||
case RECEIVED:
|
||||
BigInteger amount = receipt.getAmount(account);
|
||||
return ReceivedTransactionStatus.complete(Money.picoMobileCoin(amount), status.getBlockIndex().longValue());
|
||||
default:
|
||||
throw new IllegalStateException("Unknown Transaction Status: " + status);
|
||||
}
|
||||
} catch (SerializationException | InvalidFogResponse | InvalidReceiptException e) {
|
||||
Log.w(TAG, e);
|
||||
return ReceivedTransactionStatus.failed();
|
||||
} catch (NetworkException | AttestationException e) {
|
||||
throw new IOException(e);
|
||||
} catch (AmountDecoderException e) {
|
||||
Log.w(TAG, "Failed to decode amount", e);
|
||||
return ReceivedTransactionStatus.failed();
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void sendPayment(@NonNull MobileCoinPublicAddress to,
|
||||
@NonNull Money.MobileCoin amount,
|
||||
@NonNull Money.MobileCoin totalFee,
|
||||
boolean defragmentFirst,
|
||||
@NonNull List<TransactionSubmissionResult> results)
|
||||
{
|
||||
Money.MobileCoin defragmentFees = Money.MobileCoin.ZERO;
|
||||
if (defragmentFirst) {
|
||||
try {
|
||||
defragmentFees = defragment(amount, results);
|
||||
} catch (InsufficientFundsException e) {
|
||||
Log.w(TAG, "Insufficient funds", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.INSUFFICIENT_FUNDS, true));
|
||||
return;
|
||||
} catch (TimeoutException | InvalidTransactionException | InvalidFogResponse | AttestationException | TransactionBuilderException | NetworkException | FogReportException e) {
|
||||
Log.w(TAG, "Defragment failed", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, true));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Money.MobileCoin feeMobileCoin = totalFee.subtract(defragmentFees).requireMobileCoin();
|
||||
BigInteger picoMob = amount.requireMobileCoin().toPicoMobBigInteger();
|
||||
PendingTransaction pendingTransaction = null;
|
||||
|
||||
Log.i(TAG, String.format("Total fee advised: %s\nDefrag fees: %s\nTransaction fee: %s", totalFee, defragmentFees, feeMobileCoin));
|
||||
|
||||
if (!feeMobileCoin.isPositive()) {
|
||||
Log.i(TAG, "No fee left after defrag");
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pendingTransaction = mobileCoinClient.prepareTransaction(to.getAddress(),
|
||||
picoMob,
|
||||
feeMobileCoin.toPicoMobBigInteger());
|
||||
} catch (InsufficientFundsException e) {
|
||||
Log.w(TAG, "Insufficient funds", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.INSUFFICIENT_FUNDS, false));
|
||||
} catch (FeeRejectedException e) {
|
||||
Log.w(TAG, "Fee rejected " + totalFee, e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (InvalidFogResponse | FogReportException e) {
|
||||
Log.w(TAG, "Invalid fog response", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (FragmentedAccountException e) {
|
||||
if (defragmentFirst) {
|
||||
Log.w(TAG, "Account is fragmented, but already tried to defragment", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} else {
|
||||
Log.i(TAG, "Account is fragmented, defragmenting and retrying");
|
||||
sendPayment(to, amount, totalFee, true, results);
|
||||
}
|
||||
} catch (AttestationException e) {
|
||||
Log.w(TAG, "Attestation problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Network problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (TransactionBuilderException e) {
|
||||
Log.w(TAG, "Builder problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
}
|
||||
|
||||
if (pendingTransaction == null) {
|
||||
Log.w(TAG, "Failed to create pending transaction");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Submitting transaction");
|
||||
mobileCoinClient.submitTransaction(pendingTransaction.getTransaction());
|
||||
Log.i(TAG, "Transaction submitted");
|
||||
results.add(TransactionSubmissionResult.successfullySubmitted(new PaymentTransactionId.MobileCoin(pendingTransaction.getTransaction().toByteArray(), pendingTransaction.getReceipt().toByteArray(), feeMobileCoin)));
|
||||
} catch (NetworkException e) {
|
||||
Log.w(TAG, "Network problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.NETWORK_FAILURE, false));
|
||||
} catch (InvalidTransactionException e) {
|
||||
Log.w(TAG, "Invalid transaction", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (AttestationException e) {
|
||||
Log.w(TAG, "Attestation problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
} catch (SerializationException e) {
|
||||
Log.w(TAG, "Serialization problem", e);
|
||||
results.add(TransactionSubmissionResult.failure(TransactionSubmissionResult.ErrorCode.GENERIC_FAILURE, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to defragment the account. It will at most merge 16 UTXOs to 1.
|
||||
* Therefore it may need to be called more than once before a certain payment is possible.
|
||||
*/
|
||||
@WorkerThread
|
||||
private @NonNull Money.MobileCoin defragment(@NonNull Money.MobileCoin amount, @NonNull List<TransactionSubmissionResult> results)
|
||||
throws TransactionBuilderException, NetworkException, InvalidTransactionException, AttestationException, FogReportException, InvalidFogResponse, TimeoutException, InsufficientFundsException
|
||||
{
|
||||
Log.i(TAG, "Defragmenting account");
|
||||
DefragDelegate defragDelegate = new DefragDelegate(mobileCoinClient, results);
|
||||
mobileCoinClient.defragmentAccount(amount.toPicoMobBigInteger(), defragDelegate);
|
||||
Log.i(TAG, "Account defragmented at a cost of " + defragDelegate.totalFeesSpent);
|
||||
return defragDelegate.totalFeesSpent;
|
||||
}
|
||||
|
||||
private void reauthorizeClient() throws IOException {
|
||||
AuthCredentials authorization = mobileCoinConfig.getAuth();
|
||||
mobileCoinClient.setFogBasicAuthorization(authorization.username(), authorization.password());
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
getFullLedger();
|
||||
}
|
||||
|
||||
public enum TransactionStatus {
|
||||
COMPLETE,
|
||||
IN_PROGRESS,
|
||||
FAILED;
|
||||
}
|
||||
|
||||
public static final class TransactionStatusResult {
|
||||
private final TransactionStatus transactionStatus;
|
||||
private final long blockIndex;
|
||||
|
||||
public TransactionStatusResult(@NonNull TransactionStatus transactionStatus,
|
||||
long blockIndex)
|
||||
{
|
||||
this.transactionStatus = transactionStatus;
|
||||
this.blockIndex = blockIndex;
|
||||
}
|
||||
|
||||
static TransactionStatusResult inProgress() {
|
||||
return new TransactionStatusResult(TransactionStatus.IN_PROGRESS, 0);
|
||||
}
|
||||
|
||||
static TransactionStatusResult failed() {
|
||||
return new TransactionStatusResult(TransactionStatus.FAILED, 0);
|
||||
}
|
||||
|
||||
static TransactionStatusResult complete(long blockIndex) {
|
||||
return new TransactionStatusResult(TransactionStatus.COMPLETE, blockIndex);
|
||||
}
|
||||
|
||||
public @NonNull TransactionStatus getTransactionStatus() {
|
||||
return transactionStatus;
|
||||
}
|
||||
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ReceivedTransactionStatus {
|
||||
|
||||
private final TransactionStatus status;
|
||||
private final Money amount;
|
||||
private final long blockIndex;
|
||||
|
||||
public static ReceivedTransactionStatus failed() {
|
||||
return new ReceivedTransactionStatus(TransactionStatus.FAILED, null, 0);
|
||||
}
|
||||
|
||||
public static ReceivedTransactionStatus inProgress() {
|
||||
return new ReceivedTransactionStatus(TransactionStatus.IN_PROGRESS, null, 0);
|
||||
}
|
||||
|
||||
public static ReceivedTransactionStatus complete(@NonNull Money amount, long blockIndex) {
|
||||
return new ReceivedTransactionStatus(TransactionStatus.COMPLETE, amount, blockIndex);
|
||||
}
|
||||
|
||||
private ReceivedTransactionStatus(@NonNull TransactionStatus status, @Nullable Money amount, long blockIndex) {
|
||||
this.status = status;
|
||||
this.amount = amount;
|
||||
this.blockIndex = blockIndex;
|
||||
}
|
||||
|
||||
public @NonNull TransactionStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public @NonNull Money getAmount() {
|
||||
if (status != TransactionStatus.COMPLETE || amount == null) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
public long getBlockIndex() {
|
||||
return blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DefragDelegate implements DefragmentationDelegate {
|
||||
private final MobileCoinClient mobileCoinClient;
|
||||
private final List<TransactionSubmissionResult> results;
|
||||
private Money.MobileCoin totalFeesSpent = Money.MobileCoin.ZERO;
|
||||
|
||||
DefragDelegate(@NonNull MobileCoinClient mobileCoinClient, @NonNull List<TransactionSubmissionResult> results) {
|
||||
this.mobileCoinClient = mobileCoinClient;
|
||||
this.results = results;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
Log.i(TAG, "Defragmenting start");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onStepReady(@NonNull PendingTransaction pendingTransaction, @NonNull BigInteger fee)
|
||||
throws NetworkException, InvalidTransactionException, AttestationException
|
||||
{
|
||||
Log.i(TAG, "Submitting defrag transaction");
|
||||
mobileCoinClient.submitTransaction(pendingTransaction.getTransaction());
|
||||
Log.i(TAG, "Defrag transaction submitted");
|
||||
try {
|
||||
Money.MobileCoin defragFee = Money.picoMobileCoin(fee);
|
||||
results.add(TransactionSubmissionResult.successfullySubmittedDefrag(new PaymentTransactionId.MobileCoin(pendingTransaction.getTransaction().toByteArray(), pendingTransaction.getReceipt().toByteArray(), defragFee)));
|
||||
totalFeesSpent = totalFeesSpent.add(defragFee).requireMobileCoin();
|
||||
} catch (SerializationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
Log.i(TAG, "Defragmenting complete");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel() {
|
||||
Log.w(TAG, "Defragmenting cancel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.payments.backup;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
public class PaymentsRecoveryPasteFragment extends Fragment {
|
||||
|
||||
public PaymentsRecoveryPasteFragment() {
|
||||
super(R.layout.payments_recovery_paste_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_paste_fragment_toolbar);
|
||||
EditText input = view.findViewById(R.id.payments_recovery_paste_fragment_phrase);
|
||||
View next = view.findViewById(R.id.payments_recovery_paste_fragment_next);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
next.setEnabled(false);
|
||||
}
|
||||
|
||||
input.addTextChangedListener(new AfterTextChanged(e -> {
|
||||
next.setEnabled(!e.toString().isEmpty());
|
||||
next.setAlpha(!e.toString().isEmpty() ? 1f : 0.5f);
|
||||
}));
|
||||
|
||||
next.setOnClickListener(v -> {
|
||||
String mnemonic = input.getText().toString();
|
||||
String[] words = mnemonic.split(" ");
|
||||
|
||||
if (words.length != PaymentsConstants.MNEMONIC_LENGTH) {
|
||||
showErrorDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.findNavController(v).navigate(PaymentsRecoveryPasteFragmentDirections.actionPaymentsRecoveryEntryToPaymentsRecoveryPhrase(false).setWords(words));
|
||||
});
|
||||
}
|
||||
|
||||
private void showErrorDialog() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsRecoveryPasteFragment__invalid_recovery_phrase)
|
||||
.setMessage(getString(R.string.PaymentsRecoveryPasteFragment__make_sure, PaymentsConstants.MNEMONIC_LENGTH))
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.payments.backup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
|
||||
public final class PaymentsRecoveryRepository {
|
||||
public @NonNull Mnemonic getMnemonic() {
|
||||
return SignalStore.paymentsValues().getPaymentsMnemonic();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.thoughtcrime.securesms.payments.backup;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
public class PaymentsRecoveryStartFragment extends Fragment {
|
||||
|
||||
private final OnBackPressed onBackPressed = new OnBackPressed();
|
||||
|
||||
public PaymentsRecoveryStartFragment() {
|
||||
super(R.layout.payments_recovery_start_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_start_fragment_toolbar);
|
||||
TextView title = view.findViewById(R.id.payments_recovery_start_fragment_title);
|
||||
LearnMoreTextView message = view.findViewById(R.id.payments_recovery_start_fragment_message);
|
||||
TextView startButton = view.findViewById(R.id.payments_recovery_start_fragment_start);
|
||||
TextView pasteButton = view.findViewById(R.id.payments_recovery_start_fragment_paste);
|
||||
|
||||
PaymentsRecoveryStartFragmentArgs args = PaymentsRecoveryStartFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
if (args.getIsRestore()) {
|
||||
title.setText(R.string.PaymentsRecoveryStartFragment__enter_recovery_phrase);
|
||||
message.setText(getString(R.string.PaymentsRecoveryStartFragment__your_recovery_phrase_is_a, PaymentsConstants.MNEMONIC_LENGTH));
|
||||
message.setLink(getString(R.string.PaymentsRecoveryStartFragment__learn_more__restore));
|
||||
startButton.setOnClickListener(v -> Navigation.findNavController(requireView()).navigate(PaymentsRecoveryStartFragmentDirections.actionPaymentsRecoveryStartToPaymentsRecoveryEntry()));
|
||||
startButton.setText(R.string.PaymentsRecoveryStartFragment__enter_manually);
|
||||
pasteButton.setVisibility(View.VISIBLE);
|
||||
pasteButton.setOnClickListener(v -> Navigation.findNavController(v).navigate(PaymentsRecoveryStartFragmentDirections.actionPaymentsRecoveryStartToPaymentsRecoveryPaste()));
|
||||
} else {
|
||||
title.setText(R.string.PaymentsRecoveryStartFragment__view_recovery_phrase);
|
||||
message.setText(getString(R.string.PaymentsRecoveryStartFragment__your_balance_will_automatically_restore, PaymentsConstants.MNEMONIC_LENGTH));
|
||||
message.setLink(getString(R.string.PaymentsRecoveryStartFragment__learn_more__view));
|
||||
startButton.setOnClickListener(v -> Navigation.findNavController(requireView()).navigate(PaymentsRecoveryStartFragmentDirections.actionPaymentsRecoveryStartToPaymentsRecoveryPhrase(args.getFinishOnConfirm())));
|
||||
startButton.setText(R.string.PaymentsRecoveryStartFragment__start);
|
||||
pasteButton.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> {
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
Navigation.findNavController(requireView()).popBackStack();
|
||||
}
|
||||
});
|
||||
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed);
|
||||
}
|
||||
|
||||
message.setLearnMoreVisible(true);
|
||||
}
|
||||
|
||||
private class OnBackPressed extends OnBackPressedCallback {
|
||||
|
||||
public OnBackPressed() {
|
||||
super(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
requireActivity().finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.confirm;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
|
||||
public class PaymentsRecoveryPhraseConfirmFragment extends Fragment {
|
||||
|
||||
/**
|
||||
* The minimum number of characters required to show an error mark.
|
||||
*/
|
||||
private static final int ERROR_THRESHOLD = 1;
|
||||
|
||||
private Drawable validWordCheckMark;
|
||||
private Drawable invalidWordX;
|
||||
|
||||
public PaymentsRecoveryPhraseConfirmFragment() {
|
||||
super(R.layout.payments_recovery_phrase_confirm_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_toolbar);
|
||||
EditText word1 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word_1);
|
||||
EditText word2 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word_2);
|
||||
View seePhraseAgain = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_see_again);
|
||||
View done = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_done);
|
||||
TextInputLayout wordWrapper1 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word1_wrapper);
|
||||
TextInputLayout wordWrapper2 = view.findViewById(R.id.payments_recovery_phrase_confirm_fragment_word2_wrapper);
|
||||
|
||||
PaymentsRecoveryPhraseConfirmFragmentArgs args = PaymentsRecoveryPhraseConfirmFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
validWordCheckMark = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_check_circle_24);
|
||||
invalidWordX = AppCompatResources.getDrawable(requireContext(), R.drawable.ic_circle_x_24);
|
||||
|
||||
DrawableCompat.setTint(validWordCheckMark, ContextCompat.getColor(requireContext(), R.color.signal_accent_green));
|
||||
DrawableCompat.setTint(invalidWordX, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary));
|
||||
|
||||
PaymentsRecoveryPhraseConfirmViewModel viewModel = ViewModelProviders.of(requireActivity()).get(PaymentsRecoveryPhraseConfirmViewModel.class);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(requireView()).popBackStack());
|
||||
|
||||
word1.addTextChangedListener(new AfterTextChanged(e -> viewModel.validateWord1(e.toString())));
|
||||
word2.addTextChangedListener(new AfterTextChanged(e -> viewModel.validateWord2(e.toString())));
|
||||
seePhraseAgain.setOnClickListener(v -> Navigation.findNavController(requireView()).popBackStack());
|
||||
done.setOnClickListener(v -> {
|
||||
SignalStore.paymentsValues().setUserConfirmedMnemonic(true);
|
||||
ViewUtil.hideKeyboard(requireContext(), view);
|
||||
Toast.makeText(requireContext(), R.string.PaymentRecoveryPhraseConfirmFragment__recovery_phrase_confirmed, Toast.LENGTH_SHORT).show();
|
||||
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().setResult(Activity.RESULT_OK);
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
Navigation.findNavController(view).popBackStack(R.id.paymentsHome, true);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getViewState().observe(getViewLifecycleOwner(), viewState -> {
|
||||
updateValidity(word1, viewState.isWord1Valid());
|
||||
updateValidity(word2, viewState.isWord2Valid());
|
||||
done.setEnabled(viewState.areAllWordsValid());
|
||||
|
||||
String hint1 = getString(R.string.PaymentRecoveryPhraseConfirmFragment__word_d, viewState.getWord1Index() + 1);
|
||||
String hint2 = getString(R.string.PaymentRecoveryPhraseConfirmFragment__word_d, viewState.getWord2Index() + 1);
|
||||
|
||||
wordWrapper1.setHint(hint1);
|
||||
wordWrapper2.setHint(hint2);
|
||||
});
|
||||
|
||||
viewModel.updateRandomIndices();
|
||||
}
|
||||
|
||||
private void updateValidity(TextView word, boolean isValid) {
|
||||
if (isValid) {
|
||||
setEndDrawable(word, validWordCheckMark);
|
||||
} else if (word.getText().length() >= ERROR_THRESHOLD) {
|
||||
setEndDrawable(word, invalidWordX);
|
||||
} else {
|
||||
word.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void setEndDrawable(@NonNull TextView word, @NonNull Drawable invalidWordX) {
|
||||
if (word.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR) {
|
||||
word.setCompoundDrawablesWithIntrinsicBounds(null, null, invalidWordX, null);
|
||||
} else {
|
||||
word.setCompoundDrawablesWithIntrinsicBounds(invalidWordX, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.payments.backup.PaymentsRecoveryRepository;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Random;
|
||||
|
||||
public class PaymentsRecoveryPhraseConfirmViewModel extends ViewModel {
|
||||
|
||||
private final Random random;
|
||||
private final Mnemonic mnemonic;
|
||||
private final Store<PaymentsRecoveryPhraseConfirmViewState> viewState;
|
||||
|
||||
public PaymentsRecoveryPhraseConfirmViewModel() {
|
||||
PaymentsRecoveryRepository repository = new PaymentsRecoveryRepository();
|
||||
|
||||
random = new SecureRandom();
|
||||
mnemonic = repository.getMnemonic();
|
||||
|
||||
this.viewState = new Store<>(PaymentsRecoveryPhraseConfirmViewState.init(-1, -1));
|
||||
}
|
||||
|
||||
public void updateRandomIndices() {
|
||||
int[] indices = getRandomIndices();
|
||||
|
||||
this.viewState.update(unused -> PaymentsRecoveryPhraseConfirmViewState.init(indices[0], indices[1]));
|
||||
}
|
||||
|
||||
private int[] getRandomIndices() {
|
||||
int firstIndex = random.nextInt(mnemonic.getWordCount());
|
||||
int secondIndex = random.nextInt(mnemonic.getWordCount());
|
||||
|
||||
while (firstIndex == secondIndex) {
|
||||
secondIndex = random.nextInt(mnemonic.getWordCount());
|
||||
}
|
||||
|
||||
return new int[]{firstIndex, secondIndex};
|
||||
}
|
||||
|
||||
@NonNull LiveData<PaymentsRecoveryPhraseConfirmViewState> getViewState() {
|
||||
return viewState.getStateLiveData();
|
||||
}
|
||||
|
||||
void validateWord1(@NonNull String entry) {
|
||||
viewState.update(s -> s.buildUpon().withValidWord1(mnemonic.getWords().get(s.getWord1Index()).equals(entry.toLowerCase())).build());
|
||||
}
|
||||
|
||||
void validateWord2(@NonNull String entry) {
|
||||
viewState.update(s -> s.buildUpon().withValidWord2(mnemonic.getWords().get(s.getWord2Index()).equals(entry.toLowerCase())).build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
final class PaymentsRecoveryPhraseConfirmViewState {
|
||||
|
||||
private final int word1Index;
|
||||
private final int word2Index;
|
||||
private final boolean isWord1Valid;
|
||||
private final boolean isWord2Valid;
|
||||
|
||||
private PaymentsRecoveryPhraseConfirmViewState(@NonNull Builder builder) {
|
||||
this.word1Index = builder.word1Index;
|
||||
this.word2Index = builder.word2Index;
|
||||
this.isWord1Valid = builder.isWord1Valid;
|
||||
this.isWord2Valid = builder.isWord2Valid;
|
||||
}
|
||||
|
||||
int getWord1Index() {
|
||||
return word1Index;
|
||||
}
|
||||
|
||||
int getWord2Index() {
|
||||
return word2Index;
|
||||
}
|
||||
|
||||
boolean isWord1Valid() {
|
||||
return isWord1Valid;
|
||||
}
|
||||
|
||||
boolean isWord2Valid() {
|
||||
return isWord2Valid;
|
||||
}
|
||||
|
||||
boolean areAllWordsValid() {
|
||||
return isWord1Valid() && isWord2Valid();
|
||||
}
|
||||
|
||||
@NonNull Builder buildUpon() {
|
||||
return new Builder(word1Index, word2Index).withValidWord1(isWord1Valid())
|
||||
.withValidWord2(isWord2Valid());
|
||||
}
|
||||
|
||||
static @NonNull PaymentsRecoveryPhraseConfirmViewState init(int word1Index, int word2Index) {
|
||||
return new Builder(word1Index, word2Index).build();
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
private final int word1Index;
|
||||
private final int word2Index;
|
||||
|
||||
private boolean isWord1Valid;
|
||||
private boolean isWord2Valid;
|
||||
|
||||
private Builder(int word1Index, int word2Index) {
|
||||
this.word1Index = word1Index;
|
||||
this.word2Index = word2Index;
|
||||
}
|
||||
|
||||
@NonNull Builder withValidWord1(boolean isValid) {
|
||||
this.isWord1Valid = isValid;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withValidWord2(boolean isValid) {
|
||||
this.isWord2Valid = isValid;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull PaymentsRecoveryPhraseConfirmViewState build() {
|
||||
return new PaymentsRecoveryPhraseConfirmViewState(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.entry;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView;
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
|
||||
public class PaymentsRecoveryEntryFragment extends Fragment {
|
||||
|
||||
public PaymentsRecoveryEntryFragment() {
|
||||
super(R.layout.payments_recovery_entry_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_entry_fragment_toolbar);
|
||||
TextView message = view.findViewById(R.id.payments_recovery_entry_fragment_message);
|
||||
TextInputLayout wrapper = view.findViewById(R.id.payments_recovery_entry_fragment_word_wrapper);
|
||||
MaterialAutoCompleteTextView word = view.findViewById(R.id.payments_recovery_entry_fragment_word);
|
||||
View next = view.findViewById(R.id.payments_recovery_entry_fragment_next);
|
||||
PaymentsRecoveryEntryViewModel viewModel = ViewModelProviders.of(this).get(PaymentsRecoveryEntryViewModel.class);
|
||||
|
||||
toolbar.setNavigationOnClickListener(t -> Navigation.findNavController(view).popBackStack(R.id.paymentsHome, false));
|
||||
|
||||
viewModel.getState().observe(getViewLifecycleOwner(), state -> {
|
||||
message.setText(getString(R.string.PaymentsRecoveryEntryFragment__enter_word_d, state.getWordIndex() + 1));
|
||||
word.setHint(getString(R.string.PaymentsRecoveryEntryFragment__word_d, state.getWordIndex() + 1));
|
||||
wrapper.setError(state.canMoveToNext() || TextUtils.isEmpty(state.getCurrentEntry()) ? null : getString(R.string.PaymentsRecoveryEntryFragment__invalid_word));
|
||||
next.setEnabled(state.canMoveToNext());
|
||||
|
||||
String inTextView = word.getText().toString();
|
||||
String inState = Util.firstNonNull(state.getCurrentEntry(), "");
|
||||
|
||||
if (!inTextView.equals(inState)) {
|
||||
word.setText(inState);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getEvents().observe(getViewLifecycleOwner(), event -> {
|
||||
if (event == PaymentsRecoveryEntryViewModel.Events.GO_TO_CONFIRM) {
|
||||
Navigation.findNavController(view).navigate(PaymentsRecoveryEntryFragmentDirections.actionPaymentsRecoveryEntryToPaymentsRecoveryPhrase(false)
|
||||
.setWords(viewModel.getWords()));
|
||||
}
|
||||
});
|
||||
|
||||
ArrayAdapter<String> wordAdapter = new ArrayAdapter<>(requireContext(), R.layout.support_simple_spinner_dropdown_item, Mnemonic.BIP39_WORDS_ENGLISH);
|
||||
|
||||
word.setAdapter(wordAdapter);
|
||||
word.addTextChangedListener(new AfterTextChanged(e -> viewModel.onWordChanged(e.toString())));
|
||||
next.setOnClickListener(v -> viewModel.onNextClicked());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.entry;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
public class PaymentsRecoveryEntryViewModel extends ViewModel {
|
||||
|
||||
private Store<PaymentsRecoveryEntryViewState> state = new Store<>(new PaymentsRecoveryEntryViewState());
|
||||
private SingleLiveEvent<Events> events = new SingleLiveEvent<>();
|
||||
private String[] words = new String[PaymentsConstants.MNEMONIC_LENGTH];
|
||||
|
||||
@NonNull LiveData<PaymentsRecoveryEntryViewState> getState() {
|
||||
return state.getStateLiveData();
|
||||
}
|
||||
|
||||
@NonNull LiveData<Events> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
@NonNull String[] getWords() {
|
||||
return words;
|
||||
}
|
||||
|
||||
void onWordChanged(@NonNull String entry) {
|
||||
state.update(s -> new PaymentsRecoveryEntryViewState(s.getWordIndex(), isValid(entry), entry));
|
||||
}
|
||||
|
||||
void onNextClicked() {
|
||||
state.update(s -> {
|
||||
words[s.getWordIndex()] = s.getCurrentEntry();
|
||||
|
||||
if (s.getWordIndex() == PaymentsConstants.MNEMONIC_LENGTH - 1) {
|
||||
events.postValue(Events.GO_TO_CONFIRM);
|
||||
return new PaymentsRecoveryEntryViewState(0, isValid(words[0]), words[0]);
|
||||
} else {
|
||||
int newIndex = s.getWordIndex() + 1;
|
||||
return new PaymentsRecoveryEntryViewState(newIndex, isValid(words[newIndex]), words[newIndex]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isValid(@Nullable String string) {
|
||||
if (string == null) return false;
|
||||
else return Mnemonic.BIP39_WORDS_ENGLISH.contains(string.toLowerCase());
|
||||
}
|
||||
|
||||
enum Events {
|
||||
GO_TO_CONFIRM
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.entry;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
class PaymentsRecoveryEntryViewState {
|
||||
|
||||
private final int wordIndex;
|
||||
private final boolean canMoveToNext;
|
||||
private final String currentEntry;
|
||||
|
||||
PaymentsRecoveryEntryViewState() {
|
||||
this.wordIndex = 0;
|
||||
this.canMoveToNext = false;
|
||||
this.currentEntry = null;
|
||||
}
|
||||
|
||||
PaymentsRecoveryEntryViewState(int wordIndex, boolean canMoveToNext, String currentEntry) {
|
||||
this.wordIndex = wordIndex;
|
||||
this.canMoveToNext = canMoveToNext;
|
||||
this.currentEntry = currentEntry;
|
||||
}
|
||||
|
||||
int getWordIndex() {
|
||||
return wordIndex;
|
||||
}
|
||||
|
||||
boolean canMoveToNext() {
|
||||
return canMoveToNext;
|
||||
}
|
||||
|
||||
@Nullable String getCurrentEntry() {
|
||||
return currentEntry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
public class ClearClipboardAlarmReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = Log.tag(ClearClipboardAlarmReceiver.class);
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "onReceive: clearing clipboard");
|
||||
|
||||
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(context);
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.app_name), " "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
final class MnemonicPart {
|
||||
|
||||
private final String word;
|
||||
private final int index;
|
||||
|
||||
MnemonicPart(int index, @NonNull String word) {
|
||||
this.word = word;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public String getWord() {
|
||||
return word;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
|
||||
|
||||
final class MnemonicPartAdapter extends ListAdapter<MnemonicPart, MnemonicPartAdapter.ViewHolder> {
|
||||
|
||||
protected MnemonicPartAdapter() {
|
||||
super(new AlwaysChangedDiffUtil<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
return new ViewHolder((TextView) LayoutInflater.from(parent.getContext()).inflate(R.layout.mnemonic_part_adapter_item, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
holder.bind(getItem(position));
|
||||
}
|
||||
|
||||
final static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final TextView view;
|
||||
|
||||
ViewHolder(@NonNull TextView itemView) {
|
||||
super(itemView);
|
||||
|
||||
this.view = itemView;
|
||||
}
|
||||
|
||||
void bind(@NonNull MnemonicPart mnemonicPart) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
|
||||
builder.append(SpanUtil.color(ContextCompat.getColor(view.getContext(), R.color.payment_currency_code_foreground_color),
|
||||
String.valueOf(mnemonicPart.getIndex() + 1)))
|
||||
.append(" ")
|
||||
.append(SpanUtil.bold(mnemonicPart.getWord()));
|
||||
|
||||
view.setText(builder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Mnemonic;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class PaymentsRecoveryPhraseFragment extends Fragment {
|
||||
|
||||
private static final int SPAN_COUNT = 2;
|
||||
|
||||
public PaymentsRecoveryPhraseFragment() {
|
||||
super(R.layout.payments_recovery_phrase_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_recovery_phrase_fragment_toolbar);
|
||||
RecyclerView recyclerView = view.findViewById(R.id.payments_recovery_phrase_fragment_recycler);
|
||||
TextView message = view.findViewById(R.id.payments_recovery_phrase_fragment_message);
|
||||
View next = view.findViewById(R.id.payments_recovery_phrase_fragment_next);
|
||||
View edit = view.findViewById(R.id.payments_recovery_phrase_fragment_edit);
|
||||
View copy = view.findViewById(R.id.payments_recovery_phrase_fragment_copy);
|
||||
GridLayoutManager gridLayoutManager = new GridLayoutManager(requireContext(), SPAN_COUNT);
|
||||
PaymentsRecoveryPhraseFragmentArgs args = PaymentsRecoveryPhraseFragmentArgs.fromBundle(requireArguments());
|
||||
|
||||
final List<String> words;
|
||||
|
||||
if (args.getWords() != null) {
|
||||
words = Arrays.asList(args.getWords());
|
||||
|
||||
setUpForConfirmation(message, next, edit, copy, words);
|
||||
} else {
|
||||
Mnemonic mnemonic = SignalStore.paymentsValues().getPaymentsMnemonic();
|
||||
|
||||
words = mnemonic.getWords();
|
||||
|
||||
setUpForDisplay(message, next, edit, copy, words, args);
|
||||
}
|
||||
|
||||
List<MnemonicPart> parts = Stream.of(words)
|
||||
.mapIndexed(MnemonicPart::new)
|
||||
.sorted(new MnemonicPartComparator(words.size(), SPAN_COUNT))
|
||||
.toList();
|
||||
|
||||
MnemonicPartAdapter adapter = new MnemonicPartAdapter();
|
||||
|
||||
recyclerView.setLayoutManager(gridLayoutManager);
|
||||
recyclerView.setAdapter(adapter);
|
||||
recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> {
|
||||
if (args.getFinishOnConfirm()) {
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
toolbar.setNavigationOnClickListener(t -> Navigation.findNavController(view).popBackStack(R.id.paymentsHome, false));
|
||||
}
|
||||
});
|
||||
|
||||
adapter.submitList(parts);
|
||||
}
|
||||
|
||||
private void copyWordsToClipboard(List<String> words) {
|
||||
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(requireContext());
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(getString(R.string.app_name), Util.join(words, " ")));
|
||||
|
||||
AlarmManager alarmManager = ServiceUtil.getAlarmManager(requireContext());
|
||||
Intent alarmIntent = new Intent(requireContext(), ClearClipboardAlarmReceiver.class);
|
||||
PendingIntent pendingAlarmIntent = PendingIntent.getBroadcast(requireContext(), 0, alarmIntent, 0);
|
||||
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30), pendingAlarmIntent);
|
||||
}
|
||||
|
||||
private void setUpForConfirmation(@NonNull TextView message,
|
||||
@NonNull View next,
|
||||
@NonNull View edit,
|
||||
@NonNull View copy,
|
||||
@NonNull List<String> words)
|
||||
{
|
||||
message.setText(R.string.PaymentsRecoveryPhraseFragment__make_sure_youve_entered);
|
||||
edit.setVisibility(View.VISIBLE);
|
||||
copy.setVisibility(View.GONE);
|
||||
|
||||
PaymentsRecoveryPhraseViewModel viewModel = ViewModelProviders.of(this).get(PaymentsRecoveryPhraseViewModel.class);
|
||||
|
||||
next.setOnClickListener(v -> viewModel.onSubmit(words));
|
||||
edit.setOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
viewModel.getSubmitResult().observe(getViewLifecycleOwner(), this::onSubmitResult);
|
||||
}
|
||||
|
||||
private void setUpForDisplay(@NonNull TextView message,
|
||||
@NonNull View next,
|
||||
@NonNull View edit,
|
||||
@NonNull View copy,
|
||||
@NonNull List<String> words,
|
||||
@NonNull PaymentsRecoveryPhraseFragmentArgs args)
|
||||
{
|
||||
message.setText(getString(R.string.PaymentsRecoveryPhraseFragment__write_down_the_following_d_words, words.size()));
|
||||
next.setOnClickListener(v -> Navigation.findNavController(v).navigate(PaymentsRecoveryPhraseFragmentDirections.actionPaymentsRecoveryPhraseToPaymentsRecoveryPhraseConfirm(args.getFinishOnConfirm())));
|
||||
edit.setVisibility(View.GONE);
|
||||
copy.setVisibility(View.VISIBLE);
|
||||
copy.setOnClickListener(v -> confirmCopy(words));
|
||||
}
|
||||
|
||||
private void confirmCopy(@NonNull List<String> words) {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsRecoveryPhraseFragment__copy_to_clipboard)
|
||||
.setMessage(R.string.PaymentsRecoveryPhraseFragment__if_you_choose_to_store)
|
||||
.setPositiveButton(R.string.PaymentsRecoveryPhraseFragment__copy, (dialog, which) -> copyWordsToClipboard(words))
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
|
||||
private void onSubmitResult(@NonNull PaymentsRecoveryPhraseViewModel.SubmitResult submitResult) {
|
||||
switch (submitResult) {
|
||||
case SUCCESS:
|
||||
Toast.makeText(requireContext(), R.string.PaymentsRecoveryPhraseFragment__payments_account_restored, Toast.LENGTH_LONG).show();
|
||||
Navigation.findNavController(requireView()).popBackStack(R.id.paymentsHome, false);
|
||||
break;
|
||||
case ERROR:
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsRecoveryPhraseFragment__invalid_recovery_phrase)
|
||||
.setMessage(R.string.PaymentsRecoveryPhraseFragment__make_sure_youve_entered_your_phrase_correctly_and_try_again)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zips together a list of MnemonicParts with itself, based off the part count and desired span count.
|
||||
*
|
||||
* For example, for two spans, 1..12 becomes 1, 7, 2, 8, 3, 9...12
|
||||
*/
|
||||
private static class MnemonicPartComparator implements Comparator<MnemonicPart> {
|
||||
|
||||
private final int partsPerSpan;
|
||||
|
||||
private MnemonicPartComparator(int partCount, int spanCount) {
|
||||
this.partsPerSpan = partCount / spanCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(MnemonicPart o1, MnemonicPart o2) {
|
||||
int span1 = o1.getIndex() % partsPerSpan;
|
||||
int span2 = o2.getIndex() % partsPerSpan;
|
||||
|
||||
if (span1 != span2) {
|
||||
return Integer.compare(span1, span2);
|
||||
}
|
||||
|
||||
return Integer.compare(o1.getIndex(), o2.getIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
class PaymentsRecoveryPhraseRepository {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsRecoveryPhraseRepository.class);
|
||||
|
||||
void restoreMnemonic(@NonNull List<String> words,
|
||||
@NonNull Consumer<PaymentsValues.WalletRestoreResult> resultConsumer)
|
||||
{
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
String mnemonic = Util.join(words, " ");
|
||||
PaymentsValues.WalletRestoreResult result = SignalStore.paymentsValues().restoreWallet(mnemonic);
|
||||
|
||||
switch (result) {
|
||||
case ENTROPY_CHANGED:
|
||||
Log.i(TAG, "restoreMnemonic: mnemonic resulted in entropy mismatch, flushing cached values");
|
||||
DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication()).deleteAll();
|
||||
ApplicationDependencies.getPayments().closeWallet();
|
||||
updateProfileAndFetchLedger();
|
||||
break;
|
||||
case ENTROPY_UNCHANGED:
|
||||
Log.i(TAG, "restoreMnemonic: mnemonic resulted in entropy match, no flush needed.");
|
||||
updateProfileAndFetchLedger();
|
||||
break;
|
||||
case MNEMONIC_ERROR:
|
||||
Log.w(TAG, "restoreMnemonic: failed to restore wallet from given mnemonic.");
|
||||
break;
|
||||
}
|
||||
|
||||
resultConsumer.accept(result);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateProfileAndFetchLedger() {
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(new ProfileUploadJob())
|
||||
.then(PaymentLedgerUpdateJob.updateLedger())
|
||||
.enqueue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.payments.backup.phrase;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class PaymentsRecoveryPhraseViewModel extends ViewModel {
|
||||
|
||||
private final SingleLiveEvent<SubmitResult> submitResult = new SingleLiveEvent<>();
|
||||
private final PaymentsRecoveryPhraseRepository repository = new PaymentsRecoveryPhraseRepository();
|
||||
|
||||
public LiveData<SubmitResult> getSubmitResult() {
|
||||
return submitResult;
|
||||
}
|
||||
|
||||
void onSubmit(List<String> words) {
|
||||
repository.restoreMnemonic(words, result -> {
|
||||
switch (result) {
|
||||
case ENTROPY_CHANGED:
|
||||
case ENTROPY_UNCHANGED:
|
||||
submitResult.postValue(SubmitResult.SUCCESS);
|
||||
break;
|
||||
case MNEMONIC_ERROR:
|
||||
submitResult.postValue(SubmitResult.ERROR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum SubmitResult {
|
||||
SUCCESS,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.confirm.ConfirmPaymentState.Status;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public class ConfirmPaymentAdapter extends MappingAdapter {
|
||||
public ConfirmPaymentAdapter(@NonNull Callbacks callbacks) {
|
||||
registerFactory(LoadingItem.class, LoadingItemViewHolder::new, R.layout.confirm_payment_loading_item);
|
||||
registerFactory(LineItem.class, LineItemViewHolder::new, R.layout.confirm_payment_line_item);
|
||||
registerFactory(Divider.class, MappingViewHolder.SimpleViewHolder::new, R.layout.confirm_payment_divider);
|
||||
registerFactory(TotalLineItem.class, TotalLineItemViewHolder::new, R.layout.confirm_payment_total_line_item);
|
||||
registerFactory(ConfirmPaymentStatus.class, p -> new ConfirmPaymentViewHolder(p, callbacks), R.layout.confirm_payment_status);
|
||||
}
|
||||
|
||||
public interface Callbacks {
|
||||
void onConfirmPayment();
|
||||
}
|
||||
|
||||
public static class LoadingItem implements MappingModel<LoadingItem> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull LoadingItem newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull LoadingItem newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LineItem implements MappingModel<LineItem> {
|
||||
|
||||
private final CharSequence description;
|
||||
private final String value;
|
||||
|
||||
public LineItem(@NonNull CharSequence description, @NonNull String value) {
|
||||
this.description = description;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public @NonNull CharSequence getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public @NonNull String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull LineItem newItem) {
|
||||
return description.toString().equals(newItem.description.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull LineItem newItem) {
|
||||
return value.equals(newItem.value);
|
||||
}
|
||||
}
|
||||
|
||||
public static class TotalLineItem implements MappingModel<TotalLineItem> {
|
||||
private final LineItem lineItem;
|
||||
|
||||
public TotalLineItem(@NonNull String description, @NonNull String value) {
|
||||
this.lineItem = new LineItem(description, value);
|
||||
}
|
||||
|
||||
public @NonNull LineItem getLineItem() {
|
||||
return lineItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull TotalLineItem newItem) {
|
||||
return lineItem.areItemsTheSame(newItem.lineItem);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull TotalLineItem newItem) {
|
||||
return lineItem.areContentsTheSame(newItem.lineItem);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ConfirmPaymentStatus implements MappingModel<ConfirmPaymentStatus> {
|
||||
private final Status status;
|
||||
private final ConfirmPaymentState.FeeStatus feeStatus;
|
||||
private final Money balance;
|
||||
|
||||
public ConfirmPaymentStatus(@NonNull Status status, @NonNull ConfirmPaymentState.FeeStatus feeStatus, @NonNull Money balance) {
|
||||
this.status = status;
|
||||
this.feeStatus = feeStatus;
|
||||
this.balance = balance;
|
||||
}
|
||||
|
||||
public int getConfirmPaymentVisibility() {
|
||||
return status == Status.CONFIRM ? View.VISIBLE : View.INVISIBLE;
|
||||
}
|
||||
|
||||
public boolean getConfirmPaymentEnabled() {
|
||||
return status == Status.CONFIRM &&
|
||||
feeStatus == ConfirmPaymentState.FeeStatus.SET;
|
||||
}
|
||||
|
||||
public @NonNull Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public @NonNull CharSequence getInfoText(@NonNull Context context) {
|
||||
switch (status) {
|
||||
case CONFIRM: return context.getString(R.string.ConfirmPayment__balance_s, balance.toString(FormatterOptions.defaults()));
|
||||
case SUBMITTING: return context.getString(R.string.ConfirmPayment__submitting_payment);
|
||||
case PROCESSING: return context.getString(R.string.ConfirmPayment__processing_payment);
|
||||
case DONE: return context.getString(R.string.ConfirmPayment__payment_complete);
|
||||
case ERROR: return SpanUtil.color(ContextCompat.getColor(context, R.color.signal_alert_primary), context.getString(R.string.ConfirmPayment__payment_failed));
|
||||
case TIMEOUT: return context.getString(R.string.ConfirmPayment__payment_will_continue_processing);
|
||||
}
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull ConfirmPaymentStatus newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull ConfirmPaymentStatus newItem) {
|
||||
return status == newItem.status &&
|
||||
feeStatus == newItem.feeStatus &&
|
||||
balance.equals(newItem.balance);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Divider implements MappingModel<Divider> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull Divider newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull Divider newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class LoadingItemViewHolder extends MappingViewHolder<LoadingItem> {
|
||||
|
||||
public LoadingItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@Override public void bind(@NonNull LoadingItem model) {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class LineItemViewHolder extends MappingViewHolder<LineItem> {
|
||||
private final TextView description;
|
||||
private final TextView value;
|
||||
|
||||
public LineItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.description = findViewById(R.id.confirm_payment_line_item_description);
|
||||
this.value = findViewById(R.id.confirm_payment_line_item_value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull LineItem model) {
|
||||
description.setText(model.getDescription());
|
||||
value.setText(model.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static class TotalLineItemViewHolder extends MappingViewHolder<TotalLineItem> {
|
||||
private final LineItemViewHolder delegate;
|
||||
|
||||
public TotalLineItemViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.delegate = new LineItemViewHolder(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull TotalLineItem model) {
|
||||
delegate.bind(model.getLineItem());
|
||||
}
|
||||
}
|
||||
|
||||
private static class ConfirmPaymentViewHolder extends MappingViewHolder<ConfirmPaymentStatus> {
|
||||
|
||||
private final View confirmPayment;
|
||||
private final LottieAnimationView inProgress;
|
||||
private final LottieAnimationView completed;
|
||||
private final LottieAnimationView failed;
|
||||
private final LottieAnimationView timeout;
|
||||
private final TextView infoText;
|
||||
private final Callbacks callbacks;
|
||||
|
||||
public ConfirmPaymentViewHolder(@NonNull View itemView, @NonNull Callbacks callbacks) {
|
||||
super(itemView);
|
||||
this.callbacks = callbacks;
|
||||
this.confirmPayment = findViewById(R.id.confirm_payment_status_confirm);
|
||||
this.inProgress = findViewById(R.id.confirm_payment_spinner_lottie);
|
||||
this.completed = findViewById(R.id.confirm_payment_success_lottie);
|
||||
this.failed = findViewById(R.id.confirm_payment_error_lottie);
|
||||
this.timeout = findViewById(R.id.confirm_payment_timeout_lottie);
|
||||
this.infoText = findViewById(R.id.confirm_payment_status_info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull ConfirmPaymentStatus model) {
|
||||
confirmPayment.setOnClickListener(v -> callbacks.onConfirmPayment());
|
||||
confirmPayment.setVisibility(model.getConfirmPaymentVisibility());
|
||||
confirmPayment.setEnabled(model.getConfirmPaymentEnabled());
|
||||
infoText.setText(model.getInfoText(getContext()));
|
||||
|
||||
switch (model.getStatus()) {
|
||||
case CONFIRM:
|
||||
break;
|
||||
case SUBMITTING:
|
||||
case PROCESSING:
|
||||
playNextAnimation(inProgress, completed, failed, timeout);
|
||||
break;
|
||||
case DONE:
|
||||
playNextAnimation(completed, inProgress, failed, timeout);
|
||||
break;
|
||||
case ERROR:
|
||||
playNextAnimation(failed, inProgress, completed, timeout);
|
||||
break;
|
||||
case TIMEOUT:
|
||||
playNextAnimation(timeout, inProgress, completed, failed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void playNextAnimation(@NonNull LottieAnimationView next,
|
||||
@NonNull LottieAnimationView ... hide)
|
||||
{
|
||||
for (LottieAnimationView lottieAnimationView : hide) {
|
||||
lottieAnimationView.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
next.setVisibility(View.VISIBLE);
|
||||
next.post(() -> {
|
||||
if (!next.isAnimating()) {
|
||||
next.playAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.style.TextAppearanceSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog;
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ConfirmPaymentFragment extends BottomSheetDialogFragment {
|
||||
|
||||
private ConfirmPaymentViewModel viewModel;
|
||||
private final Runnable dismiss = () -> {
|
||||
dismissAllowingStateLoss();
|
||||
|
||||
if (ConfirmPaymentFragmentArgs.fromBundle(requireArguments()).getFinishOnConfirm()) {
|
||||
requireActivity().setResult(Activity.RESULT_OK);
|
||||
requireActivity().finish();
|
||||
} else {
|
||||
NavHostFragment.findNavController(this).navigate(R.id.action_directly_to_paymentsHome);
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
|
||||
BottomSheetUtil.show(manager, tag, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
setStyle(DialogFragment.STYLE_NORMAL, R.style.Signal_DayNight_BottomSheet_Rounded);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
BottomSheetDialog dialog = (BottomSheetDialog) super.onCreateDialog(savedInstanceState);
|
||||
dialog.getBehavior().setHideable(false);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.confirm_payment_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
ConfirmPaymentViewModel.Factory factory = new ConfirmPaymentViewModel.Factory(ConfirmPaymentFragmentArgs.fromBundle(requireArguments()).getCreatePaymentDetails());
|
||||
viewModel = ViewModelProviders.of(this, factory).get(ConfirmPaymentViewModel.class);
|
||||
|
||||
RecyclerView list = view.findViewById(R.id.confirm_payment_fragment_list);
|
||||
ConfirmPaymentAdapter adapter = new ConfirmPaymentAdapter(new Callbacks());
|
||||
list.setAdapter(adapter);
|
||||
|
||||
viewModel.getState().observe(getViewLifecycleOwner(), state -> adapter.submitList(createList(state)));
|
||||
viewModel.isPaymentDone().observe(getViewLifecycleOwner(), isDone -> {
|
||||
if (isDone) {
|
||||
ThreadUtil.runOnMainDelayed(dismiss, TimeUnit.SECONDS.toMillis(2));
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getErrorTypeEvents().observe(getViewLifecycleOwner(), error -> {
|
||||
switch (error) {
|
||||
case NO_PROFILE_KEY:
|
||||
CanNotSendPaymentDialog.show(requireContext());
|
||||
break;
|
||||
case NO_ADDRESS:
|
||||
RecipientHasNotEnabledPaymentsDialog.show(requireContext());
|
||||
break;
|
||||
case CAN_NOT_GET_FEE:
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.ConfirmPaymentFragment__unable_to_request_a_network_fee)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
viewModel.refreshFee();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
dismiss();
|
||||
})
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull DialogInterface dialog) {
|
||||
super.onDismiss(dialog);
|
||||
ThreadUtil.cancelRunnableOnMain(dismiss);
|
||||
}
|
||||
|
||||
private @NonNull MappingModelList createList(@NonNull ConfirmPaymentState state) {
|
||||
MappingModelList list = new MappingModelList();
|
||||
FormatterOptions options = FormatterOptions.defaults();
|
||||
|
||||
switch (state.getFeeStatus()) {
|
||||
case STILL_LOADING:
|
||||
case ERROR:
|
||||
list.add(new ConfirmPaymentAdapter.LoadingItem());
|
||||
break;
|
||||
case NOT_SET:
|
||||
case SET:
|
||||
list.add(new ConfirmPaymentAdapter.LineItem(getToPayeeDescription(requireContext(), state), state.getAmount().toString(options)));
|
||||
list.add(new ConfirmPaymentAdapter.LineItem(getString(R.string.ConfirmPayment__network_fee), state.getFee().toString(options)));
|
||||
|
||||
if (state.getExchange() != null) {
|
||||
list.add(new ConfirmPaymentAdapter.LineItem(getString(R.string.ConfirmPayment__estimated_s, state.getExchange().getCurrency().getCurrencyCode()),
|
||||
FiatMoneyUtil.format(getResources(), state.getExchange(), FiatMoneyUtil.formatOptions().withDisplayTime(false))));
|
||||
}
|
||||
|
||||
list.add(new ConfirmPaymentAdapter.Divider());
|
||||
list.add(new ConfirmPaymentAdapter.TotalLineItem(getString(R.string.ConfirmPayment__total_amount), state.getTotal().toString(options)));
|
||||
}
|
||||
|
||||
list.add(new ConfirmPaymentAdapter.ConfirmPaymentStatus(state.getStatus(), state.getFeeStatus(), state.getBalance()));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static CharSequence getToPayeeDescription(Context context, @NonNull ConfirmPaymentState state) {
|
||||
return new SpannableStringBuilder().append(context.getString(R.string.ConfirmPayment__to))
|
||||
.append(' ')
|
||||
.append(getPayeeDescription(context, state.getPayee()));
|
||||
}
|
||||
|
||||
private static CharSequence getPayeeDescription(Context context, @NonNull Payee payee) {
|
||||
return payee.hasRecipientId() ? Recipient.resolved(payee.requireRecipientId()).getDisplayName(context)
|
||||
: mono(context, StringUtil.abbreviateInMiddle(payee.requirePublicAddress().getPaymentAddressBase58(), 17));
|
||||
}
|
||||
|
||||
private static CharSequence mono(Context context, CharSequence address) {
|
||||
SpannableString spannable = new SpannableString(address);
|
||||
spannable.setSpan(new TextAppearanceSpan(context, R.style.TextAppearance_Signal_Mono),
|
||||
0,
|
||||
address.length(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
private class Callbacks implements ConfirmPaymentAdapter.Callbacks {
|
||||
@Override
|
||||
public void onConfirmPayment() {
|
||||
setCancelable(false);
|
||||
viewModel.confirmPayment();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentSendJob;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
|
||||
import org.thoughtcrime.securesms.payments.Wallet;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
final class ConfirmPaymentRepository {
|
||||
|
||||
private static final String TAG = Log.tag(ConfirmPaymentRepository.class);
|
||||
|
||||
private final Wallet wallet;
|
||||
|
||||
ConfirmPaymentRepository(@NonNull Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
void confirmPayment(@NonNull ConfirmPaymentState state, @NonNull Consumer<ConfirmPaymentResult> consumer) {
|
||||
Log.i(TAG, "confirmPayment");
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
Balance balance = wallet.getCachedBalance();
|
||||
|
||||
if (state.getTotal().requireMobileCoin().greaterThan(balance.getFullAmount().requireMobileCoin())) {
|
||||
Log.w(TAG, "The total was greater than the wallet's balance");
|
||||
consumer.accept(new ConfirmPaymentResult.Error());
|
||||
return;
|
||||
}
|
||||
|
||||
Payee payee = state.getPayee();
|
||||
RecipientId recipientId;
|
||||
MobileCoinPublicAddress mobileCoinPublicAddress;
|
||||
|
||||
if (payee.hasRecipientId()) {
|
||||
recipientId = payee.requireRecipientId();
|
||||
try {
|
||||
mobileCoinPublicAddress = ProfileUtil.getAddressForRecipient(Recipient.resolved(recipientId));
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
Log.w(TAG, "Failed to get address for recipient " + recipientId);
|
||||
consumer.accept(new ConfirmPaymentResult.Error());
|
||||
return;
|
||||
} catch (PaymentsAddressException e) {
|
||||
Log.w(TAG, "Failed to get address for recipient " + recipientId);
|
||||
consumer.accept(new ConfirmPaymentResult.Error(e.getCode()));
|
||||
return;
|
||||
}
|
||||
} else if (payee.hasPublicAddress()) {
|
||||
recipientId = null;
|
||||
mobileCoinPublicAddress = payee.requirePublicAddress();
|
||||
} else throw new AssertionError();
|
||||
|
||||
UUID paymentUuid = PaymentSendJob.enqueuePayment(recipientId,
|
||||
mobileCoinPublicAddress,
|
||||
Util.emptyIfNull(state.getNote()),
|
||||
state.getAmount().requireMobileCoin(),
|
||||
state.getFee().requireMobileCoin());
|
||||
|
||||
Log.i(TAG, "confirmPayment: PaymentSendJob enqueued");
|
||||
consumer.accept(new ConfirmPaymentResult.Success(paymentUuid));
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GetFeeResult getFee(@NonNull Money amount) {
|
||||
try {
|
||||
return new GetFeeResult.Success(wallet.getFee(amount.requireMobileCoin()));
|
||||
} catch (IOException e) {
|
||||
return new GetFeeResult.Error();
|
||||
}
|
||||
}
|
||||
|
||||
static class ConfirmPaymentResult {
|
||||
|
||||
static class Success extends ConfirmPaymentResult {
|
||||
private final UUID paymentId;
|
||||
|
||||
Success(@NonNull UUID paymentId) {
|
||||
this.paymentId = paymentId;
|
||||
}
|
||||
|
||||
@NonNull UUID getPaymentId() {
|
||||
return paymentId;
|
||||
}
|
||||
}
|
||||
|
||||
static class Error extends ConfirmPaymentResult {
|
||||
private final PaymentsAddressException.Code code;
|
||||
|
||||
Error() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
Error(@Nullable PaymentsAddressException.Code code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public @Nullable PaymentsAddressException.Code getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class GetFeeResult {
|
||||
|
||||
static class Success extends GetFeeResult {
|
||||
|
||||
private final Money fee;
|
||||
|
||||
Success(@NonNull Money fee) {
|
||||
this.fee = fee;
|
||||
}
|
||||
|
||||
@NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
}
|
||||
|
||||
static class Error extends GetFeeResult {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class ConfirmPaymentState {
|
||||
private final Payee payee;
|
||||
private final Money balance;
|
||||
private final Money amount;
|
||||
private final String note;
|
||||
private final Money fee;
|
||||
private final FeeStatus feeStatus;
|
||||
private final FiatMoney exchange;
|
||||
private final Status status;
|
||||
private final Money total;
|
||||
private final UUID paymentId;
|
||||
|
||||
public ConfirmPaymentState(@NonNull Payee payee,
|
||||
@NonNull Money amount,
|
||||
@Nullable String note)
|
||||
{
|
||||
this(payee,
|
||||
amount.toZero(),
|
||||
amount,
|
||||
note,
|
||||
amount.toZero(),
|
||||
FeeStatus.NOT_SET,
|
||||
null,
|
||||
Status.CONFIRM,
|
||||
null);
|
||||
}
|
||||
|
||||
private ConfirmPaymentState(@NonNull Payee payee,
|
||||
@NonNull Money balance,
|
||||
@NonNull Money amount,
|
||||
@Nullable String note,
|
||||
@NonNull Money fee,
|
||||
@NonNull FeeStatus feeStatus,
|
||||
@NonNull FiatMoney exchange,
|
||||
@NonNull Status status,
|
||||
@Nullable UUID paymentId)
|
||||
{
|
||||
this.payee = payee;
|
||||
this.balance = balance;
|
||||
this.amount = amount;
|
||||
this.note = note;
|
||||
this.fee = fee;
|
||||
this.feeStatus = feeStatus;
|
||||
this.exchange = exchange;
|
||||
this.status = status;
|
||||
this.paymentId = paymentId;
|
||||
this.total = amount.add(fee);
|
||||
}
|
||||
|
||||
public @NonNull Payee getPayee() {
|
||||
return payee;
|
||||
}
|
||||
|
||||
public @NonNull Money getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
public @NonNull Money getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public @Nullable String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
public @NonNull Money getFee() {
|
||||
return fee;
|
||||
}
|
||||
|
||||
public @NonNull FeeStatus getFeeStatus() {
|
||||
return feeStatus;
|
||||
}
|
||||
|
||||
public @Nullable FiatMoney getExchange() {
|
||||
return exchange;
|
||||
}
|
||||
|
||||
public @NonNull Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public @NonNull Money getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public @Nullable UUID getPaymentId() {
|
||||
return paymentId;
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateStatus(@NonNull Status status) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateBalance(@NonNull Money balance) {
|
||||
return new ConfirmPaymentState(this.payee, balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateFee(@NonNull Money fee) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, fee, FeeStatus.SET, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateFeeStillLoading() {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.amount.toZero(), FeeStatus.STILL_LOADING, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateFeeError() {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.amount.toZero(), FeeStatus.ERROR, this.exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updatePaymentId(@Nullable UUID paymentId) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, this.status, paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState updateExchange(@Nullable FiatMoney exchange) {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, exchange, this.status, this.paymentId);
|
||||
}
|
||||
|
||||
public @NonNull ConfirmPaymentState timeout() {
|
||||
return new ConfirmPaymentState(this.payee, this.balance, this.amount, this.note, this.fee, this.feeStatus, this.exchange, Status.TIMEOUT, this.paymentId);
|
||||
}
|
||||
|
||||
enum Status {
|
||||
CONFIRM,
|
||||
SUBMITTING,
|
||||
PROCESSING,
|
||||
DONE,
|
||||
ERROR,
|
||||
TIMEOUT;
|
||||
|
||||
boolean isTerminalStatus() {
|
||||
return this == DONE || this == ERROR || this == TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
enum FeeStatus {
|
||||
NOT_SET,
|
||||
STILL_LOADING,
|
||||
SET,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.thoughtcrime.securesms.payments.confirm;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase.PaymentTransaction;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.CreatePaymentDetails;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.PaymentTransactionLiveData;
|
||||
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
|
||||
import org.thoughtcrime.securesms.payments.confirm.ConfirmPaymentRepository.ConfirmPaymentResult;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
final class ConfirmPaymentViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(ConfirmPaymentViewModel.class);
|
||||
|
||||
private final Store<ConfirmPaymentState> store;
|
||||
private final ConfirmPaymentRepository confirmPaymentRepository;
|
||||
private final LiveData<Boolean> paymentDone;
|
||||
private final SingleLiveEvent<ErrorType> errorEvents;
|
||||
private final MutableLiveData<Boolean> feeRetry;
|
||||
|
||||
ConfirmPaymentViewModel(@NonNull ConfirmPaymentState confirmPaymentState,
|
||||
@NonNull ConfirmPaymentRepository confirmPaymentRepository)
|
||||
{
|
||||
this.store = new Store<>(confirmPaymentState);
|
||||
this.confirmPaymentRepository = confirmPaymentRepository;
|
||||
this.errorEvents = new SingleLiveEvent<>();
|
||||
this.feeRetry = new DefaultValueLiveData<>(true);
|
||||
|
||||
this.store.update(SignalStore.paymentsValues().liveMobileCoinBalance(), (balance, state) -> state.updateBalance(balance.getFullAmount()));
|
||||
|
||||
LiveData<Boolean> longLoadTime = LiveDataUtil.delay(1000, true);
|
||||
this.store.update(longLoadTime, (l, s) -> {
|
||||
if (s.getFeeStatus() == ConfirmPaymentState.FeeStatus.NOT_SET) return s.updateFeeStillLoading();
|
||||
else return s;
|
||||
});
|
||||
|
||||
LiveData<Money> amount = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getAmount));
|
||||
this.store.update(LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(amount, feeRetry, (a, f) -> a), this::getFee), (feeResult, state) -> {
|
||||
if (feeResult instanceof ConfirmPaymentRepository.GetFeeResult.Success) return state.updateFee(((ConfirmPaymentRepository.GetFeeResult.Success) feeResult).getFee());
|
||||
else if (feeResult instanceof ConfirmPaymentRepository.GetFeeResult.Error) return state.updateFeeError();
|
||||
else throw new AssertionError();
|
||||
});
|
||||
|
||||
LiveData<UUID> paymentId = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getPaymentId));
|
||||
LiveData<PaymentTransaction> transactionLiveData = Transformations.switchMap(paymentId, id -> (id != null) ? new PaymentTransactionLiveData(id) : new MutableLiveData<>());
|
||||
this.store.update(transactionLiveData, this::handlePaymentTransactionChanged);
|
||||
|
||||
this.paymentDone = Transformations.distinctUntilChanged(Transformations.map(store.getStateLiveData(), state -> state.getStatus().isTerminalStatus()));
|
||||
|
||||
LiveData<Optional<FiatMoney>> exchange = FiatMoneyUtil.getExchange(amount);
|
||||
this.store.update(exchange, (exchange1, confirmPaymentState1) -> confirmPaymentState1.updateExchange(exchange1.orNull()));
|
||||
|
||||
LiveData<ConfirmPaymentState.Status> statusLiveData = Transformations.map(store.getStateLiveData(), ConfirmPaymentState::getStatus);
|
||||
LiveData<ConfirmPaymentState.Status> timeoutSignal = Transformations.switchMap(statusLiveData,
|
||||
s -> {
|
||||
if (s == ConfirmPaymentState.Status.PROCESSING) {
|
||||
Log.i(TAG, "Beginning timeout timer");
|
||||
return LiveDataUtil.delay(TimeUnit.SECONDS.toMillis(20), s);
|
||||
} else {
|
||||
return LiveDataUtil.never();
|
||||
}
|
||||
});
|
||||
|
||||
this.store.update(timeoutSignal, this::handleTimeout);
|
||||
}
|
||||
|
||||
@NonNull LiveData<ConfirmPaymentState> getState() {
|
||||
return store.getStateLiveData();
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> isPaymentDone() {
|
||||
return paymentDone;
|
||||
}
|
||||
|
||||
@NonNull LiveData<ErrorType> getErrorTypeEvents() {
|
||||
return errorEvents;
|
||||
}
|
||||
|
||||
void confirmPayment() {
|
||||
store.update(state -> state.updateStatus(ConfirmPaymentState.Status.SUBMITTING));
|
||||
confirmPaymentRepository.confirmPayment(store.getState(), this::handleConfirmPaymentResult);
|
||||
}
|
||||
|
||||
void refreshFee() {
|
||||
feeRetry.setValue(true);
|
||||
}
|
||||
|
||||
private @NonNull ConfirmPaymentRepository.GetFeeResult getFee(@NonNull Money amount) {
|
||||
ConfirmPaymentRepository.GetFeeResult result = confirmPaymentRepository.getFee(amount);
|
||||
|
||||
if (result instanceof ConfirmPaymentRepository.GetFeeResult.Error) {
|
||||
errorEvents.postValue(ErrorType.CAN_NOT_GET_FEE);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void handleConfirmPaymentResult(@NonNull ConfirmPaymentResult result) {
|
||||
if (result instanceof ConfirmPaymentResult.Success) {
|
||||
ConfirmPaymentResult.Success success = (ConfirmPaymentResult.Success) result;
|
||||
store.update(state -> state.updatePaymentId(success.getPaymentId()));
|
||||
} else if (result instanceof ConfirmPaymentResult.Error) {
|
||||
ConfirmPaymentResult.Error error = (ConfirmPaymentResult.Error) result;
|
||||
PaymentsAddressException.Code code = error.getCode();
|
||||
|
||||
store.update(state -> state.updateStatus(ConfirmPaymentState.Status.ERROR));
|
||||
if (code != null) {
|
||||
errorEvents.postValue(getErrorType(code));
|
||||
}
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull ErrorType getErrorType(@NonNull PaymentsAddressException.Code code) {
|
||||
switch (code) {
|
||||
case NO_PROFILE_KEY:
|
||||
return ErrorType.NO_PROFILE_KEY;
|
||||
case COULD_NOT_DECRYPT:
|
||||
case NOT_ENABLED:
|
||||
case INVALID_ADDRESS:
|
||||
case INVALID_ADDRESS_SIGNATURE:
|
||||
case NO_ADDRESS:
|
||||
return ErrorType.NO_ADDRESS;
|
||||
}
|
||||
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
private @NonNull ConfirmPaymentState handlePaymentTransactionChanged(@Nullable PaymentTransaction paymentTransaction, @NonNull ConfirmPaymentState state) {
|
||||
if (paymentTransaction == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.getStatus().isTerminalStatus()) {
|
||||
Log.w(TAG, "Payment already in a final state on transaction change");
|
||||
return state;
|
||||
}
|
||||
|
||||
switch (paymentTransaction.getState()) {
|
||||
case INITIAL: return state.updateStatus(ConfirmPaymentState.Status.SUBMITTING);
|
||||
case SUBMITTED: return state.updateStatus(ConfirmPaymentState.Status.PROCESSING);
|
||||
case SUCCESSFUL: return state.updateStatus(ConfirmPaymentState.Status.DONE);
|
||||
case FAILED: return state.updateStatus(ConfirmPaymentState.Status.ERROR);
|
||||
default: throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull ConfirmPaymentState handleTimeout(@NonNull ConfirmPaymentState.Status status, @NonNull ConfirmPaymentState state) {
|
||||
if (state.getStatus().isTerminalStatus()) {
|
||||
Log.w(TAG, "Payment already in a final state on timeout");
|
||||
return state;
|
||||
}
|
||||
|
||||
Log.w(TAG, "Timed out while in " + status);
|
||||
return state.timeout();
|
||||
}
|
||||
|
||||
enum ErrorType {
|
||||
NO_PROFILE_KEY,
|
||||
NO_ADDRESS,
|
||||
CAN_NOT_GET_FEE
|
||||
}
|
||||
|
||||
static final class Factory implements ViewModelProvider.Factory {
|
||||
private final CreatePaymentDetails createPaymentDetails;
|
||||
|
||||
public Factory(@NonNull CreatePaymentDetails createPaymentDetails) {
|
||||
this.createPaymentDetails = createPaymentDetails;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ConfirmPaymentViewModel(new ConfirmPaymentState(createPaymentDetails.getPayee(),
|
||||
createPaymentDetails.getAmount(),
|
||||
createPaymentDetails.getNote()),
|
||||
new ConfirmPaymentRepository(ApplicationDependencies.getPayments().getWallet())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
enum AmountKeyboardGlyph {
|
||||
NONE(-1),
|
||||
ZERO(R.string.CreatePaymentFragment__0),
|
||||
ONE(R.string.CreatePaymentFragment__1),
|
||||
TWO(R.string.CreatePaymentFragment__2),
|
||||
THREE(R.string.CreatePaymentFragment__3),
|
||||
FOUR(R.string.CreatePaymentFragment__4),
|
||||
FIVE(R.string.CreatePaymentFragment__5),
|
||||
SIX(R.string.CreatePaymentFragment__6),
|
||||
SEVEN(R.string.CreatePaymentFragment__7),
|
||||
EIGHT(R.string.CreatePaymentFragment__8),
|
||||
NINE(R.string.CreatePaymentFragment__9),
|
||||
DECIMAL(R.string.CreatePaymentFragment__decimal),
|
||||
BACK(R.string.CreatePaymentFragment__lt);
|
||||
|
||||
private final @StringRes int glyphRes;
|
||||
|
||||
AmountKeyboardGlyph(int glyphRes) {
|
||||
this.glyphRes = glyphRes;
|
||||
}
|
||||
|
||||
public int getGlyphRes() {
|
||||
return glyphRes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.LayerDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.NavDirections;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.MoneyView;
|
||||
import org.thoughtcrime.securesms.payments.preferences.RecipientHasNotEnabledPaymentsDialog;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.Currency;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class CreatePaymentFragment extends LoggingFragment {
|
||||
|
||||
private static final Map<Integer,AmountKeyboardGlyph> ID_TO_GLYPH = new HashMap<Integer, AmountKeyboardGlyph>() {{
|
||||
put(R.id.create_payment_fragment_keyboard_decimal, AmountKeyboardGlyph.DECIMAL);
|
||||
put(R.id.create_payment_fragment_keyboard_lt, AmountKeyboardGlyph.BACK);
|
||||
put(R.id.create_payment_fragment_keyboard_0, AmountKeyboardGlyph.ZERO);
|
||||
put(R.id.create_payment_fragment_keyboard_1, AmountKeyboardGlyph.ONE);
|
||||
put(R.id.create_payment_fragment_keyboard_2, AmountKeyboardGlyph.TWO);
|
||||
put(R.id.create_payment_fragment_keyboard_3, AmountKeyboardGlyph.THREE);
|
||||
put(R.id.create_payment_fragment_keyboard_4, AmountKeyboardGlyph.FOUR);
|
||||
put(R.id.create_payment_fragment_keyboard_5, AmountKeyboardGlyph.FIVE);
|
||||
put(R.id.create_payment_fragment_keyboard_6, AmountKeyboardGlyph.SIX);
|
||||
put(R.id.create_payment_fragment_keyboard_7, AmountKeyboardGlyph.SEVEN);
|
||||
put(R.id.create_payment_fragment_keyboard_8, AmountKeyboardGlyph.EIGHT);
|
||||
put(R.id.create_payment_fragment_keyboard_9, AmountKeyboardGlyph.NINE);
|
||||
}};
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView balance;
|
||||
private MoneyView amount;
|
||||
private TextView exchange;
|
||||
private View pay;
|
||||
private View request;
|
||||
private EmojiTextView note;
|
||||
private View addNote;
|
||||
private View toggle;
|
||||
private Drawable infoIcon;
|
||||
|
||||
private ConstraintSet cryptoConstraintSet;
|
||||
private ConstraintSet fiatConstraintSet;
|
||||
|
||||
public CreatePaymentFragment() {
|
||||
super(R.layout.create_payment_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.create_payment_fragment_toolbar);
|
||||
|
||||
toolbar.setNavigationOnClickListener(this::goBack);
|
||||
|
||||
CreatePaymentFragmentArgs arguments = CreatePaymentFragmentArgs.fromBundle(requireArguments());
|
||||
CreatePaymentViewModel.Factory factory = new CreatePaymentViewModel.Factory(arguments.getPayee(), arguments.getNote());
|
||||
CreatePaymentViewModel viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_create), factory).get(CreatePaymentViewModel.class);
|
||||
|
||||
constraintLayout = view.findViewById(R.id.create_payment_fragment_amount_header);
|
||||
request = view.findViewById(R.id.create_payment_fragment_request);
|
||||
amount = view.findViewById(R.id.create_payment_fragment_amount);
|
||||
exchange = view.findViewById(R.id.create_payment_fragment_exchange);
|
||||
pay = view.findViewById(R.id.create_payment_fragment_pay);
|
||||
balance = view.findViewById(R.id.create_payment_fragment_balance);
|
||||
note = view.findViewById(R.id.create_payment_fragment_note);
|
||||
addNote = view.findViewById(R.id.create_payment_fragment_add_note);
|
||||
toggle = view.findViewById(R.id.create_payment_fragment_toggle);
|
||||
|
||||
View infoTapTarget = view.findViewById(R.id.create_payment_fragment_info_tap_region);
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
infoTapTarget.setOnClickListener(v -> {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.CreatePaymentFragment__conversions_are_just_estimates)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.setNegativeButton(R.string.LearnMoreTextView_learn_more, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.CreatePaymentFragment__learn_more__conversions));
|
||||
})
|
||||
.show();
|
||||
});
|
||||
|
||||
initializeInfoIcon();
|
||||
|
||||
note.setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_createPaymentFragment_to_editPaymentNoteFragment));
|
||||
addNote.setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_createPaymentFragment_to_editPaymentNoteFragment));
|
||||
|
||||
pay.setOnClickListener(v -> {
|
||||
NavDirections directions = CreatePaymentFragmentDirections.actionCreatePaymentFragmentToConfirmPaymentFragment(viewModel.getCreatePaymentDetails())
|
||||
.setFinishOnConfirm(arguments.getFinishOnConfirm());
|
||||
Navigation.findNavController(v).navigate(directions);
|
||||
});
|
||||
|
||||
toggle.setOnClickListener(v -> viewModel.toggleMoneyInputTarget(requireContext()));
|
||||
|
||||
initializeConstraintSets();
|
||||
initializeKeyboardButtons(view, viewModel);
|
||||
|
||||
viewModel.getInputState().observe(getViewLifecycleOwner(), inputState -> {
|
||||
updateAmount(inputState);
|
||||
updateExchange(inputState);
|
||||
updateMoneyInputTarget(inputState.getInputTarget());
|
||||
});
|
||||
|
||||
viewModel.getIsPaymentsSupportedByPayee().observe(getViewLifecycleOwner(), isSupported -> {
|
||||
if (!isSupported) RecipientHasNotEnabledPaymentsDialog.show(requireContext(), () -> goBack(requireView()));
|
||||
});
|
||||
|
||||
viewModel.isValidAmount().observe(getViewLifecycleOwner(), this::updateRequestAmountButtons);
|
||||
viewModel.getNote().observe(getViewLifecycleOwner(), this::updateNote);
|
||||
viewModel.getSpendableBalance().observe(getViewLifecycleOwner(), this::updateBalance);
|
||||
viewModel.getCanSendPayment().observe(getViewLifecycleOwner(), this::updatePayAmountButtons);
|
||||
}
|
||||
|
||||
private void goBack(View v) {
|
||||
if (!Navigation.findNavController(v).popBackStack()) {
|
||||
requireActivity().finish();
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeInfoIcon() {
|
||||
Drawable pad = Objects.requireNonNull(AppCompatResources.getDrawable(requireContext(), R.drawable.payment_info_pad));
|
||||
Drawable info = Objects.requireNonNull(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_update_info_16));
|
||||
|
||||
DrawableCompat.setTint(info, exchange.getCurrentTextColor());
|
||||
|
||||
pad.setBounds(0, 0, ViewUtil.dpToPx(29), ViewUtil.dpToPx(16));
|
||||
info.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16));
|
||||
|
||||
infoIcon = new LayerDrawable(new Drawable[]{pad, info});
|
||||
infoIcon.setBounds(0, 0, ViewUtil.dpToPx(29), ViewUtil.dpToPx(16));
|
||||
}
|
||||
|
||||
private void updateNote(@Nullable CharSequence note) {
|
||||
boolean hasNote = !TextUtils.isEmpty(note);
|
||||
addNote.setVisibility(hasNote ? View.GONE : View.VISIBLE);
|
||||
this.note.setVisibility(hasNote ? View.VISIBLE : View.GONE);
|
||||
this.note.setText(note);
|
||||
}
|
||||
|
||||
private void initializeKeyboardButtons(@NonNull View view, @NonNull CreatePaymentViewModel viewModel) {
|
||||
for (Map.Entry<Integer, AmountKeyboardGlyph> entry : ID_TO_GLYPH.entrySet()) {
|
||||
view.findViewById(entry.getKey()).setOnClickListener(v -> viewModel.updateAmount(requireContext(), entry.getValue()));
|
||||
}
|
||||
|
||||
view.findViewById(R.id.create_payment_fragment_keyboard_lt).setOnLongClickListener(v -> {
|
||||
viewModel.clearAmount();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void updateAmount(@NonNull InputState inputState) {
|
||||
switch (inputState.getInputTarget()) {
|
||||
case MONEY:
|
||||
amount.setMoney(inputState.getMoneyAmount(), inputState.getMoney().getCurrency());
|
||||
break;
|
||||
case FIAT_MONEY:
|
||||
amount.setMoney(inputState.getMoney(), false, inputState.getExchangeRate().get().getTimestamp());
|
||||
amount.append(SpanUtil.buildImageSpan(infoIcon));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateExchange(@NonNull InputState inputState) {
|
||||
switch (inputState.getInputTarget()) {
|
||||
case MONEY:
|
||||
if (inputState.getFiatMoney().isPresent()) {
|
||||
exchange.setVisibility(View.VISIBLE);
|
||||
exchange.setText(FiatMoneyUtil.format(getResources(), inputState.getFiatMoney().get(), FiatMoneyUtil.formatOptions().withDisplayTime(true)));
|
||||
exchange.append(SpanUtil.buildImageSpan(infoIcon));
|
||||
toggle.setVisibility(View.VISIBLE);
|
||||
toggle.setEnabled(true);
|
||||
} else {
|
||||
exchange.setVisibility(View.INVISIBLE);
|
||||
toggle.setVisibility(View.INVISIBLE);
|
||||
toggle.setEnabled(false);
|
||||
}
|
||||
break;
|
||||
case FIAT_MONEY:
|
||||
Currency currency = inputState.getFiatMoney().get().getCurrency();
|
||||
exchange.setText(new SpannableStringBuilder().append(currency.getSymbol())
|
||||
.append(inputState.getFiatAmount())
|
||||
.append(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.payment_currency_code_foreground_color),
|
||||
currency.getCurrencyCode())));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateRequestAmountButtons(boolean isValidAmount) {
|
||||
request.setEnabled(isValidAmount);
|
||||
}
|
||||
|
||||
private void updatePayAmountButtons(boolean isValidAmount) {
|
||||
pay.setEnabled(isValidAmount);
|
||||
}
|
||||
|
||||
private void updateBalance(@NonNull Money balance) {
|
||||
this.balance.setText(getString(R.string.CreatePaymentFragment__available_balance_s, balance.toString(FormatterOptions.defaults())));
|
||||
}
|
||||
|
||||
private void initializeConstraintSets() {
|
||||
cryptoConstraintSet = new ConstraintSet();
|
||||
cryptoConstraintSet.clone(constraintLayout);
|
||||
|
||||
fiatConstraintSet = new ConstraintSet();
|
||||
fiatConstraintSet.clone(getContext(), R.layout.create_payment_fragment_amount_toggle);
|
||||
}
|
||||
|
||||
private void updateMoneyInputTarget(@NonNull InputTarget target) {
|
||||
TransitionManager.endTransitions(constraintLayout);
|
||||
TransitionManager.beginDelayedTransition(constraintLayout);
|
||||
|
||||
switch (target) {
|
||||
case FIAT_MONEY:
|
||||
fiatConstraintSet.applyTo(constraintLayout);
|
||||
amount.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_secondary));
|
||||
exchange.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_primary));
|
||||
break;
|
||||
case MONEY:
|
||||
cryptoConstraintSet.applyTo(constraintLayout);
|
||||
exchange.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_secondary));
|
||||
amount.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_primary));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.payments.CreatePaymentDetails;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.thoughtcrime.securesms.payments.create.InputTarget.FIAT_MONEY;
|
||||
|
||||
public class CreatePaymentViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(CreatePaymentViewModel.class);
|
||||
|
||||
private static final Money.MobileCoin AMOUNT_LOWER_BOUND_EXCLUSIVE = Money.mobileCoin(BigDecimal.ZERO);
|
||||
|
||||
private final LiveData<Money> spendableBalance;
|
||||
private final LiveData<Boolean> isValidAmount;
|
||||
private final Store<InputState> inputState;
|
||||
private final LiveData<Boolean> isPaymentsSupportedByPayee;
|
||||
|
||||
private final PayeeParcelable payee;
|
||||
private final MutableLiveData<CharSequence> note;
|
||||
|
||||
private CreatePaymentViewModel(@NonNull PayeeParcelable payee, @Nullable CharSequence note) {
|
||||
this.payee = payee;
|
||||
this.spendableBalance = Transformations.map(SignalStore.paymentsValues().liveMobileCoinBalance(), Balance::getTransferableAmount);
|
||||
this.note = new MutableLiveData<>(note);
|
||||
this.inputState = new Store<>(new InputState());
|
||||
this.isValidAmount = LiveDataUtil.combineLatest(spendableBalance, inputState.getStateLiveData(), (b, s) -> validateAmount(b, s.getMoney()));
|
||||
|
||||
if (payee.getPayee().hasRecipientId()) {
|
||||
isPaymentsSupportedByPayee = LiveDataUtil.mapAsync(new DefaultValueLiveData<>(payee.getPayee().requireRecipientId()), r -> {
|
||||
try {
|
||||
ProfileUtil.getAddressForRecipient(Recipient.resolved(r));
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Could not get address for recipient: ", e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
isPaymentsSupportedByPayee = new DefaultValueLiveData<>(true);
|
||||
}
|
||||
|
||||
LiveData<Optional<CurrencyExchange.ExchangeRate>> liveExchangeRate = LiveDataUtil.mapAsync(SignalStore.paymentsValues().liveCurrentCurrency(),
|
||||
currency -> {
|
||||
try {
|
||||
return Optional.fromNullable(ApplicationDependencies.getPayments()
|
||||
.getCurrencyExchange(true)
|
||||
.getExchangeRate(currency));
|
||||
} catch (IOException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
});
|
||||
|
||||
inputState.update(liveExchangeRate, (rate, state) -> updateAmount(ApplicationDependencies.getApplication(), state.updateExchangeRate(rate), AmountKeyboardGlyph.NONE));
|
||||
}
|
||||
|
||||
@NonNull LiveData<InputState> getInputState() {
|
||||
return inputState.getStateLiveData();
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getIsPaymentsSupportedByPayee() {
|
||||
return isPaymentsSupportedByPayee;
|
||||
}
|
||||
|
||||
@NonNull LiveData<CharSequence> getNote() {
|
||||
return Transformations.distinctUntilChanged(note);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> isValidAmount() {
|
||||
return isValidAmount;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getCanSendPayment() {
|
||||
return isValidAmount;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Money> getSpendableBalance() {
|
||||
return spendableBalance;
|
||||
}
|
||||
|
||||
void clearAmount() {
|
||||
inputState.update(s -> {
|
||||
final Money money = Money.MobileCoin.ZERO;
|
||||
final Optional<FiatMoney> fiat = OptionalUtil.flatMap(s.getExchangeRate(), r -> r.exchange(money));
|
||||
|
||||
return s.updateAmount("0", "0", Money.MobileCoin.ZERO, fiat);
|
||||
});
|
||||
}
|
||||
|
||||
void toggleMoneyInputTarget(@NonNull Context context) {
|
||||
inputState.update(s -> trimFiatAfterToggle(updateAmount(context, s.updateInputTarget(s.getInputTarget().next()), AmountKeyboardGlyph.NONE)));
|
||||
}
|
||||
|
||||
void setNote(@Nullable CharSequence note) {
|
||||
this.note.setValue(note);
|
||||
}
|
||||
|
||||
void updateAmount(@NonNull Context context, @NonNull AmountKeyboardGlyph glyph) {
|
||||
inputState.update(s -> updateAmount(context, s, glyph));
|
||||
}
|
||||
|
||||
private @NonNull InputState updateAmount(@NonNull Context context, @NonNull InputState inputState, @NonNull AmountKeyboardGlyph glyph) {
|
||||
switch (inputState.getInputTarget()) {
|
||||
case FIAT_MONEY:
|
||||
return updateFiatAmount(context, inputState, glyph, SignalStore.paymentsValues().currentCurrency());
|
||||
case MONEY:
|
||||
return updateMoneyAmount(context, inputState, glyph);
|
||||
default:
|
||||
throw new IllegalStateException("Unexpected input target " + inputState.getInputTarget().name());
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull InputState trimFiatAfterToggle(@NonNull InputState inputState) {
|
||||
if (inputState.getInputTarget() == FIAT_MONEY) {
|
||||
String fiatAmount = inputState.getFiatAmount();
|
||||
FiatMoney fiatMoney = inputState.getFiatMoney().get();
|
||||
|
||||
if (fiatMoney.getAmount().equals(BigDecimal.ZERO)) {
|
||||
return inputState.updateFiatAmount("0");
|
||||
} else if (fiatAmount.contains(ApplicationDependencies.getApplication().getString(AmountKeyboardGlyph.DECIMAL.getGlyphRes()))) {
|
||||
return inputState.updateFiatAmount(inputState.getFiatAmount().replaceFirst("\\.*0+$", ""));
|
||||
} else {
|
||||
return inputState;
|
||||
}
|
||||
} else {
|
||||
return inputState;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull InputState updateFiatAmount(@NonNull Context context,
|
||||
@NonNull InputState inputState,
|
||||
@NonNull AmountKeyboardGlyph glyph,
|
||||
@NonNull Currency currency)
|
||||
{
|
||||
String newFiatAmount = updateAmountString(context, inputState.getFiatAmount(), glyph, currency.getDefaultFractionDigits());
|
||||
FiatMoney newFiat = stringToFiatValueOrZero(newFiatAmount, currency);
|
||||
Money newMoney = OptionalUtil.flatMap(inputState.getExchangeRate(), e -> e.exchange(newFiat)).get();
|
||||
String newMoneyAmount = newMoney.toString(FormatterOptions.builder().withoutUnit().build());
|
||||
|
||||
return inputState.updateAmount(newMoneyAmount, newFiatAmount, newMoney, Optional.of(newFiat));
|
||||
}
|
||||
|
||||
private @NonNull InputState updateMoneyAmount(@NonNull Context context,
|
||||
@NonNull InputState inputState,
|
||||
@NonNull AmountKeyboardGlyph glyph)
|
||||
{
|
||||
String newMoneyAmount = updateAmountString(context, inputState.getMoneyAmount(), glyph, inputState.getMoney().getCurrency().getDecimalPrecision());
|
||||
Money newMoney = stringToMobileCoinValueOrZero(newMoneyAmount);
|
||||
Optional<FiatMoney> newFiat = OptionalUtil.flatMap(inputState.getExchangeRate(), e -> e.exchange(newMoney));
|
||||
String newFiatAmount = newFiat.transform(f -> FiatMoneyUtil.format(context.getResources(), f, FiatMoneyUtil.formatOptions().withDisplayTime(false).withoutSymbol())).or("0");
|
||||
|
||||
return inputState.updateAmount(newMoneyAmount, newFiatAmount, newMoney, newFiat);
|
||||
}
|
||||
|
||||
private boolean validateAmount(@NonNull Money spendableBalance, @NonNull Money amount) {
|
||||
try {
|
||||
return amount.requireMobileCoin().greaterThan(AMOUNT_LOWER_BOUND_EXCLUSIVE) &&
|
||||
!amount.requireMobileCoin().greaterThan(spendableBalance.requireMobileCoin());
|
||||
} catch (NumberFormatException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull FiatMoney stringToFiatValueOrZero(@Nullable String string, @NonNull Currency currency) {
|
||||
try {
|
||||
if (string != null) return new FiatMoney(new BigDecimal(string), currency);
|
||||
} catch (NumberFormatException ignored) { }
|
||||
|
||||
return new FiatMoney(BigDecimal.ZERO, currency);
|
||||
}
|
||||
|
||||
private @NonNull Money stringToMobileCoinValueOrZero(@Nullable String string) {
|
||||
try {
|
||||
if (string != null) return Money.mobileCoin(new BigDecimal(string));
|
||||
} catch (NumberFormatException ignored) { }
|
||||
|
||||
return Money.mobileCoin(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
public @NonNull CreatePaymentDetails getCreatePaymentDetails() {
|
||||
CharSequence noteLocal = this.note.getValue();
|
||||
String note = noteLocal != null ? noteLocal.toString() : null;
|
||||
return new CreatePaymentDetails(payee, Objects.requireNonNull(inputState.getState().getMoney()), note);
|
||||
}
|
||||
|
||||
private static @NonNull String updateAmountString(@NonNull Context context, @NonNull String oldAmount, @NonNull AmountKeyboardGlyph glyph, int maxPrecision) {
|
||||
if (glyph == AmountKeyboardGlyph.NONE) {
|
||||
return oldAmount;
|
||||
}
|
||||
|
||||
if (glyph == AmountKeyboardGlyph.BACK) {
|
||||
if (!oldAmount.isEmpty()) {
|
||||
String newAmount = oldAmount.substring(0, oldAmount.length() - 1);
|
||||
if (newAmount.isEmpty()) {
|
||||
return context.getString(AmountKeyboardGlyph.ZERO.getGlyphRes());
|
||||
} else {
|
||||
return newAmount;
|
||||
}
|
||||
}
|
||||
|
||||
return oldAmount;
|
||||
}
|
||||
|
||||
boolean oldAmountIsZero = context.getString(AmountKeyboardGlyph.ZERO.getGlyphRes()).equals(oldAmount);
|
||||
int decimalIndex = oldAmount.indexOf(context.getString(AmountKeyboardGlyph.DECIMAL.getGlyphRes()));
|
||||
|
||||
if (glyph == AmountKeyboardGlyph.DECIMAL) {
|
||||
if (oldAmountIsZero) {
|
||||
return context.getString(AmountKeyboardGlyph.ZERO.getGlyphRes()) + context.getString(glyph.getGlyphRes());
|
||||
} else if (decimalIndex > -1) {
|
||||
return oldAmount;
|
||||
}
|
||||
}
|
||||
|
||||
if (decimalIndex > -1 && oldAmount.length() - 1 - decimalIndex >= maxPrecision) {
|
||||
return oldAmount;
|
||||
}
|
||||
|
||||
if (oldAmountIsZero) {
|
||||
return context.getString(glyph.getGlyphRes());
|
||||
} else {
|
||||
return oldAmount + context.getString(glyph.getGlyphRes());
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final PayeeParcelable payee;
|
||||
private final CharSequence note;
|
||||
|
||||
public Factory(@NonNull PayeeParcelable payee, @Nullable CharSequence note) {
|
||||
this.payee = payee;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection unchecked
|
||||
return (T) new CreatePaymentViewModel(payee, note);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class EditNoteFragment extends LoggingFragment {
|
||||
|
||||
private CreatePaymentViewModel viewModel;
|
||||
private EmojiEditText noteEditText;
|
||||
|
||||
public EditNoteFragment() {
|
||||
super(R.layout.edit_note_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_create)).get(CreatePaymentViewModel.class);
|
||||
|
||||
Toolbar toolbar = view.findViewById(R.id.edit_note_fragment_toolbar);
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
noteEditText = view.findViewById(R.id.edit_note_fragment_edit_text);
|
||||
viewModel.getNote().observe(getViewLifecycleOwner(), note -> {
|
||||
noteEditText.setText(note);
|
||||
if (!TextUtils.isEmpty(note)) {
|
||||
noteEditText.setSelection(note.length());
|
||||
}
|
||||
});
|
||||
|
||||
noteEditText.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
saveNote();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
View fab = view.findViewById(R.id.edit_note_fragment_fab);
|
||||
fab.setOnClickListener(v -> saveNote());
|
||||
|
||||
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(noteEditText);
|
||||
}
|
||||
|
||||
private void saveNote() {
|
||||
ViewUtil.hideKeyboard(requireView().getContext(), requireView());
|
||||
viewModel.setNote(noteEditText.getText() != null ? noteEditText.getText().toString() : null);
|
||||
Navigation.findNavController(requireView()).popBackStack();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
class InputState {
|
||||
private final InputTarget inputTarget;
|
||||
private final String moneyAmount;
|
||||
private final String fiatAmount;
|
||||
private final Optional<FiatMoney> fiatMoney;
|
||||
private final Money money;
|
||||
private final Optional<CurrencyExchange.ExchangeRate> exchangeRate;
|
||||
|
||||
InputState() {
|
||||
this(InputTarget.MONEY, "0", "0", Money.mobileCoin(BigDecimal.ZERO), Optional.absent(), Optional.absent());
|
||||
}
|
||||
|
||||
private InputState(@NonNull InputTarget inputTarget,
|
||||
@NonNull String moneyAmount,
|
||||
@NonNull String fiatAmount,
|
||||
@NonNull Money money,
|
||||
@NonNull Optional<FiatMoney> fiatMoney,
|
||||
@NonNull Optional<CurrencyExchange.ExchangeRate> exchangeRate)
|
||||
{
|
||||
this.inputTarget = inputTarget;
|
||||
this.moneyAmount = moneyAmount;
|
||||
this.fiatAmount = fiatAmount;
|
||||
this.money = money;
|
||||
this.fiatMoney = fiatMoney;
|
||||
this.exchangeRate = exchangeRate;
|
||||
}
|
||||
|
||||
@NonNull String getFiatAmount() {
|
||||
return fiatAmount;
|
||||
}
|
||||
|
||||
@NonNull Optional<FiatMoney> getFiatMoney() {
|
||||
return fiatMoney;
|
||||
}
|
||||
|
||||
@NonNull String getMoneyAmount() {
|
||||
return moneyAmount;
|
||||
}
|
||||
|
||||
@NonNull Money getMoney() {
|
||||
return money;
|
||||
}
|
||||
|
||||
@NonNull InputTarget getInputTarget() {
|
||||
return inputTarget;
|
||||
}
|
||||
|
||||
@NonNull Optional<CurrencyExchange.ExchangeRate> getExchangeRate() {
|
||||
return exchangeRate;
|
||||
}
|
||||
|
||||
@NonNull InputState updateInputTarget(@NonNull InputTarget inputTarget) {
|
||||
return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate);
|
||||
}
|
||||
|
||||
@NonNull InputState updateFiatAmount(@NonNull String fiatAmount) {
|
||||
return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate);
|
||||
}
|
||||
|
||||
@NonNull InputState updateAmount(@NonNull String moneyAmount, @NonNull String fiatAmount, @NonNull Money money, @NonNull Optional<FiatMoney> fiatMoney) {
|
||||
return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate);
|
||||
}
|
||||
|
||||
@NonNull InputState updateExchangeRate(@NonNull Optional<CurrencyExchange.ExchangeRate> exchangeRate) {
|
||||
return new InputState(inputTarget, moneyAmount, fiatAmount, money, fiatMoney, exchangeRate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.payments.create;
|
||||
|
||||
enum InputTarget {
|
||||
MONEY() {
|
||||
@Override
|
||||
InputTarget next() {
|
||||
return FIAT_MONEY;
|
||||
}
|
||||
},
|
||||
FIAT_MONEY {
|
||||
@Override
|
||||
InputTarget next() {
|
||||
return MONEY;
|
||||
}
|
||||
};
|
||||
|
||||
abstract InputTarget next();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.payments.currency;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Currency;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class CurrencyExchange {
|
||||
private final Map<String, BigDecimal> conversions;
|
||||
private final List<Currency> supportedCurrencies;
|
||||
private final long timestamp;
|
||||
|
||||
public CurrencyExchange(@NonNull Map<String, Double> conversions, long timestamp) {
|
||||
this.conversions = new HashMap<>(conversions.size());
|
||||
this.supportedCurrencies = new ArrayList<>(conversions.size());
|
||||
this.timestamp = timestamp;
|
||||
|
||||
for (Map.Entry<String, Double> entry : conversions.entrySet()) {
|
||||
if (entry.getValue() != null) {
|
||||
this.conversions.put(entry.getKey(), BigDecimal.valueOf(entry.getValue()));
|
||||
|
||||
Currency currency = CurrencyUtil.getCurrencyByCurrencyCode(entry.getKey());
|
||||
if (currency != null && SupportedCurrencies.ALL.contains(currency.getCurrencyCode())) {
|
||||
supportedCurrencies.add(currency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull ExchangeRate getExchangeRate(@NonNull Currency currency) {
|
||||
return new ExchangeRate(currency, conversions.get(currency.getCurrencyCode()), timestamp);
|
||||
}
|
||||
|
||||
public @NonNull List<Currency> getSupportedCurrencies() {
|
||||
return supportedCurrencies;
|
||||
}
|
||||
|
||||
public static final class ExchangeRate {
|
||||
|
||||
private final Currency currency;
|
||||
private final BigDecimal rate;
|
||||
private final long timestamp;
|
||||
|
||||
@VisibleForTesting ExchangeRate(@NonNull Currency currency, @Nullable BigDecimal rate, long timestamp) {
|
||||
this.currency = currency;
|
||||
this.rate = rate;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public @NonNull Optional<FiatMoney> exchange(@NonNull Money money) {
|
||||
BigDecimal amount = money.requireMobileCoin().toBigDecimal();
|
||||
|
||||
if (rate != null) {
|
||||
return Optional.of(new FiatMoney(amount.multiply(rate).setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN), currency, timestamp));
|
||||
}
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
public @NonNull Optional<Money> exchange(@NonNull FiatMoney fiatMoney) {
|
||||
if (rate != null) {
|
||||
return Optional.of(Money.mobileCoin(fiatMoney.getAmount().setScale(12, RoundingMode.HALF_EVEN).divide(rate, RoundingMode.HALF_EVEN)));
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.payments.currency;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.payments.Payments;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class CurrencyExchangeRepository {
|
||||
|
||||
private static final String TAG = Log.tag(CurrencyExchangeRepository.class);
|
||||
|
||||
private final Payments payments;
|
||||
|
||||
public CurrencyExchangeRepository(@NonNull Payments payments) {
|
||||
this.payments = payments;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public void getCurrencyExchange(@NonNull AsynchronousCallback.WorkerThread<CurrencyExchange, Throwable> callback, boolean refreshIfAble) {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try {
|
||||
callback.onComplete(payments.getCurrencyExchange(refreshIfAble));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
callback.onError(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.payments.currency;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
|
||||
import java.util.Currency;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Utility class for interacting with currencies.
|
||||
*/
|
||||
public final class CurrencyUtil {
|
||||
|
||||
public static @Nullable Currency getCurrencyByCurrencyCode(@NonNull String currencyCode) {
|
||||
try {
|
||||
return Currency.getInstance(currencyCode);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable Currency getCurrencyByE164(@NonNull String e164) {
|
||||
PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
|
||||
Phonenumber.PhoneNumber number;
|
||||
|
||||
try {
|
||||
number = PhoneNumberUtil.getInstance().parse(e164, "");
|
||||
} catch (NumberParseException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String regionCodeForNumber = phoneNumberUtil.getRegionCodeForNumber(number);
|
||||
|
||||
for (Locale l : Locale.getAvailableLocales()) {
|
||||
if (l.getCountry().equals(regionCodeForNumber)) {
|
||||
return getCurrencyByLocale(l);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @Nullable Currency getCurrencyByLocale(@Nullable Locale locale) {
|
||||
if (locale == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Currency.getInstance(locale);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.payments.currency;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
|
||||
public class FiatMoney {
|
||||
private final BigDecimal amount;
|
||||
private final Currency currency;
|
||||
private final long timestamp;
|
||||
|
||||
public FiatMoney(@NonNull BigDecimal amount, @NonNull Currency currency) {
|
||||
this(amount, currency, 0);
|
||||
}
|
||||
|
||||
public FiatMoney(@NonNull BigDecimal amount, @NonNull Currency currency, long timestamp) {
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public @NonNull BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public @NonNull Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package org.thoughtcrime.securesms.payments.currency;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
final class SupportedCurrencies {
|
||||
private SupportedCurrencies() {}
|
||||
|
||||
static final Set<String> ALL = new HashSet<>(Arrays.asList(
|
||||
"ADP",
|
||||
"AED",
|
||||
"AFA",
|
||||
"AFN",
|
||||
"ALK",
|
||||
"ALL",
|
||||
"AMD",
|
||||
"ANG",
|
||||
"AOA",
|
||||
"AOK",
|
||||
"AON",
|
||||
"AOR",
|
||||
"ARA",
|
||||
"ARL",
|
||||
"ARM",
|
||||
"ARP",
|
||||
"ARS",
|
||||
"ATS",
|
||||
"AUD",
|
||||
"AWG",
|
||||
"AZM",
|
||||
"AZN",
|
||||
"BAD",
|
||||
"BAM",
|
||||
"BAN",
|
||||
"BBD",
|
||||
"BDT",
|
||||
"BEC",
|
||||
"BEF",
|
||||
"BEL",
|
||||
"BGL",
|
||||
"BGM",
|
||||
"BGN",
|
||||
"BGO",
|
||||
"BHD",
|
||||
"BIF",
|
||||
"BMD",
|
||||
"BND",
|
||||
"BOB",
|
||||
"BOL",
|
||||
"BOP",
|
||||
"BOV",
|
||||
"BRB",
|
||||
"BRC",
|
||||
"BRE",
|
||||
"BRL",
|
||||
"BRN",
|
||||
"BRR",
|
||||
"BRZ",
|
||||
"BSD",
|
||||
"BTN",
|
||||
"BUK",
|
||||
"BWP",
|
||||
"BYB",
|
||||
"BYN",
|
||||
"BYR",
|
||||
"BZD",
|
||||
"CAD",
|
||||
"CDF",
|
||||
"CHE",
|
||||
"CHF",
|
||||
"CHW",
|
||||
"CLE",
|
||||
"CLF",
|
||||
"CLP",
|
||||
"CNH",
|
||||
"CNX",
|
||||
"CNY",
|
||||
"COP",
|
||||
"COU",
|
||||
"CRC",
|
||||
"CSD",
|
||||
"CSK",
|
||||
"CVE",
|
||||
"CYP",
|
||||
"CZK",
|
||||
"DDM",
|
||||
"DEM",
|
||||
"DJF",
|
||||
"DKK",
|
||||
"DOP",
|
||||
"DZD",
|
||||
"ECS",
|
||||
"ECV",
|
||||
"EEK",
|
||||
"EGP",
|
||||
"EQE",
|
||||
"ERN",
|
||||
"ESA",
|
||||
"ESB",
|
||||
"ESP",
|
||||
"ETB",
|
||||
"EUR",
|
||||
"FIM",
|
||||
"FJD",
|
||||
"FKP",
|
||||
"FRF",
|
||||
"GBP",
|
||||
"GEK",
|
||||
"GEL",
|
||||
"GHC",
|
||||
"GHS",
|
||||
"GIP",
|
||||
"GMD",
|
||||
"GNF",
|
||||
"GNS",
|
||||
"GQE",
|
||||
"GRD",
|
||||
"GTQ",
|
||||
"GWE",
|
||||
"GWP",
|
||||
"GYD",
|
||||
"HKD",
|
||||
"HNL",
|
||||
"HRD",
|
||||
"HRK",
|
||||
"HTG",
|
||||
"HUF",
|
||||
"IDR",
|
||||
"IEP",
|
||||
"ILP",
|
||||
"ILR",
|
||||
"ILS",
|
||||
"INR",
|
||||
"IQD",
|
||||
"ISJ",
|
||||
"ISK",
|
||||
"ITL",
|
||||
"JMD",
|
||||
"JOD",
|
||||
"JPY",
|
||||
"KES",
|
||||
"KGS",
|
||||
"KHR",
|
||||
"KMF",
|
||||
"KRH",
|
||||
"KRO",
|
||||
"KRW",
|
||||
"KWD",
|
||||
"KYD",
|
||||
"KZT",
|
||||
"LAK",
|
||||
"LBP",
|
||||
"LKR",
|
||||
"LRD",
|
||||
"LSL",
|
||||
"LSM",
|
||||
"LTL",
|
||||
"LTT",
|
||||
"LUC",
|
||||
"LUF",
|
||||
"LUL",
|
||||
"LVL",
|
||||
"LVR",
|
||||
"LYD",
|
||||
"MAD",
|
||||
"MAF",
|
||||
"MCF",
|
||||
"MDC",
|
||||
"MDL",
|
||||
"MGA",
|
||||
"MGF",
|
||||
"MKD",
|
||||
"MKN",
|
||||
"MLF",
|
||||
"MMK",
|
||||
"MNT",
|
||||
"MOP",
|
||||
"MRO",
|
||||
"MRU",
|
||||
"MTL",
|
||||
"MTP",
|
||||
"MUR",
|
||||
"MVP",
|
||||
"MVR",
|
||||
"MWK",
|
||||
"MXN",
|
||||
"MXP",
|
||||
"MXV",
|
||||
"MYR",
|
||||
"MZE",
|
||||
"MZM",
|
||||
"MZN",
|
||||
"NAD",
|
||||
"NGN",
|
||||
"NIC",
|
||||
"NIO",
|
||||
"NLG",
|
||||
"NOK",
|
||||
"NPR",
|
||||
"NZD",
|
||||
"OMR",
|
||||
"PAB",
|
||||
"PEI",
|
||||
"PEN",
|
||||
"PES",
|
||||
"PGK",
|
||||
"PHP",
|
||||
"PKR",
|
||||
"PLN",
|
||||
"PLZ",
|
||||
"PTE",
|
||||
"PYG",
|
||||
"QAR",
|
||||
"RHD",
|
||||
"ROL",
|
||||
"RON",
|
||||
"RSD",
|
||||
"RUB",
|
||||
"RUR",
|
||||
"RWF",
|
||||
"SAR",
|
||||
"SBD",
|
||||
"SCR",
|
||||
"SDD",
|
||||
"SDG",
|
||||
"SDP",
|
||||
"SEK",
|
||||
"SGD",
|
||||
"SHP",
|
||||
"SIT",
|
||||
"SKK",
|
||||
"SLL",
|
||||
"SOS",
|
||||
"SRD",
|
||||
"SRG",
|
||||
"SSP",
|
||||
"STD",
|
||||
"STN",
|
||||
"SUR",
|
||||
"SVC",
|
||||
"SZL",
|
||||
"THB",
|
||||
"TJR",
|
||||
"TJS",
|
||||
"TMM",
|
||||
"TMT",
|
||||
"TND",
|
||||
"TOP",
|
||||
"TPE",
|
||||
"TRL",
|
||||
"TRY",
|
||||
"TTD",
|
||||
"TWD",
|
||||
"TZS",
|
||||
"UAH",
|
||||
"UAK",
|
||||
"UGS",
|
||||
"UGX",
|
||||
"USD",
|
||||
"USN",
|
||||
"USS",
|
||||
"UYI",
|
||||
"UYP",
|
||||
"UYU",
|
||||
"UZS",
|
||||
"VEB",
|
||||
"VEF",
|
||||
"VND",
|
||||
"VNN",
|
||||
"VUV",
|
||||
"WST",
|
||||
"XAF",
|
||||
"XAG",
|
||||
"XAU",
|
||||
"XBA",
|
||||
"XBB",
|
||||
"XBC",
|
||||
"XBD",
|
||||
"XCD",
|
||||
"XDR",
|
||||
"XEU",
|
||||
"XFO",
|
||||
"XFU",
|
||||
"XOF",
|
||||
"XPD",
|
||||
"XPF",
|
||||
"XPT",
|
||||
"XRE",
|
||||
"XSU",
|
||||
"XTS",
|
||||
"XUA",
|
||||
"XXX",
|
||||
"YDD",
|
||||
"YER",
|
||||
"YUD",
|
||||
"YUM",
|
||||
"YUN",
|
||||
"YUR",
|
||||
"ZAL",
|
||||
"ZAR",
|
||||
"ZMK",
|
||||
"ZMW",
|
||||
"ZRN",
|
||||
"ZRZ",
|
||||
"ZWD",
|
||||
"ZWL",
|
||||
"ZWR"));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.payments.deactivate;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.MoneyView;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
|
||||
|
||||
public class DeactivateWalletFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.deactivate_wallet_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.deactivate_wallet_fragment_toolbar);
|
||||
MoneyView balance = view.findViewById(R.id.deactivate_wallet_fragment_balance);
|
||||
View transferRemainingBalance = view.findViewById(R.id.deactivate_wallet_fragment_transfer);
|
||||
View deactivateWithoutTransfer = view.findViewById(R.id.deactivate_wallet_fragment_deactivate);
|
||||
LearnMoreTextView notice = view.findViewById(R.id.deactivate_wallet_fragment_notice);
|
||||
|
||||
notice.setLearnMoreVisible(true);
|
||||
notice.setLink(getString(R.string.DeactivateWalletFragment__learn_more__we_recommend_transferring_your_funds));
|
||||
|
||||
DeactivateWalletViewModel viewModel = ViewModelProviders.of(this).get(DeactivateWalletViewModel.class);
|
||||
|
||||
viewModel.getBalance().observe(getViewLifecycleOwner(), balance::setMoney);
|
||||
viewModel.getDeactivationResults().observe(getViewLifecycleOwner(), r -> {
|
||||
if (r == DeactivateWalletViewModel.Result.SUCCESS) {
|
||||
Navigation.findNavController(requireView()).popBackStack();
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.DeactivateWalletFragment__error_deactivating_wallet, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
|
||||
transferRemainingBalance.setOnClickListener(v -> Navigation.findNavController(requireView()).navigate(R.id.action_deactivateWallet_to_paymentsTransfer));
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(requireView()).popBackStack());
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
deactivateWithoutTransfer.setOnClickListener(v -> {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.DeactivateWalletFragment__deactivate_without_transferring_question)
|
||||
.setMessage(R.string.DeactivateWalletFragment__your_balance_will_remain)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary),
|
||||
getString(R.string.DeactivateWalletFragment__deactivate)),
|
||||
(dialog, which) -> {
|
||||
viewModel.deactivateWallet();
|
||||
dialog.dismiss();
|
||||
})
|
||||
.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.payments.deactivate;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeRepository;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
public class DeactivateWalletViewModel extends ViewModel {
|
||||
|
||||
private final LiveData<Money> balance;
|
||||
private final PaymentsHomeRepository paymentsHomeRepository = new PaymentsHomeRepository();
|
||||
private final SingleLiveEvent<Result> deactivatePaymentResults = new SingleLiveEvent<>();
|
||||
|
||||
public DeactivateWalletViewModel() {
|
||||
balance = Transformations.map(SignalStore.paymentsValues().liveMobileCoinBalance(), Balance::getFullAmount);
|
||||
}
|
||||
|
||||
void deactivateWallet() {
|
||||
paymentsHomeRepository.deactivatePayments(isDisabled -> deactivatePaymentResults.postValue(isDisabled ? Result.SUCCESS : Result.FAILED));
|
||||
}
|
||||
|
||||
LiveData<Result> getDeactivationResults() {
|
||||
return deactivatePaymentResults;
|
||||
}
|
||||
|
||||
LiveData<Money> getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
enum Result {
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.thoughtcrime.securesms.payments.history;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.ComparatorCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Direction;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class TransactionReconstruction {
|
||||
private final List<Transaction> received;
|
||||
@NonNull private final List<Transaction> allTransactions;
|
||||
private final List<Transaction> sent;
|
||||
|
||||
/**
|
||||
* Given some unaccounted for TXO values within the same block separated into {@param spent} and {@param unspent}, estimates a sensible grouping for display
|
||||
* to the user.
|
||||
*/
|
||||
public static TransactionReconstruction estimateBlockLevelActivity(@NonNull List<Money.MobileCoin> spent,
|
||||
@NonNull List<Money.MobileCoin> unspent)
|
||||
{
|
||||
Money.MobileCoin totalSpent = Money.MobileCoin.sum(spent);
|
||||
|
||||
List<Money.MobileCoin> unspentDescending = new ArrayList<>(unspent);
|
||||
Collections.sort(unspentDescending, Money.MobileCoin.DESCENDING);
|
||||
|
||||
List<Transaction> received = new ArrayList<>(unspent.size());
|
||||
|
||||
for (Money.MobileCoin unspentValue : unspentDescending) {
|
||||
if (unspentValue.lessThan(totalSpent)) {
|
||||
totalSpent = totalSpent.subtract(unspentValue).requireMobileCoin();
|
||||
} else if (unspentValue.isPositive()) {
|
||||
received.add(new Transaction(unspentValue, Direction.RECEIVED));
|
||||
}
|
||||
}
|
||||
|
||||
List<Transaction> sent = totalSpent.isPositive() ? Collections.singletonList(new Transaction(totalSpent, Direction.SENT))
|
||||
: Collections.emptyList();
|
||||
|
||||
Collections.sort(sent, Transaction.ORDER);
|
||||
Collections.sort(received, Transaction.ORDER);
|
||||
|
||||
List<Transaction> allTransactions = new ArrayList<>(sent.size() + received.size());
|
||||
allTransactions.addAll(sent);
|
||||
allTransactions.addAll(received);
|
||||
Collections.sort(allTransactions, Transaction.ORDER);
|
||||
|
||||
return new TransactionReconstruction(sent, received, allTransactions);
|
||||
}
|
||||
|
||||
private TransactionReconstruction(@NonNull List<Transaction> sent,
|
||||
@NonNull List<Transaction> received,
|
||||
@NonNull List<Transaction> allTransactions)
|
||||
{
|
||||
this.sent = sent;
|
||||
this.received = received;
|
||||
this.allTransactions = allTransactions;
|
||||
}
|
||||
|
||||
public @NonNull List<Transaction> received() {
|
||||
return new ArrayList<>(received);
|
||||
}
|
||||
|
||||
public @NonNull List<Transaction> sent() {
|
||||
return new ArrayList<>(sent);
|
||||
}
|
||||
|
||||
public @NonNull List<Transaction> getAllTransactions() {
|
||||
return new ArrayList<>(allTransactions);
|
||||
}
|
||||
|
||||
public static final class Transaction {
|
||||
private static final Comparator<Transaction> RECEIVED_FIRST = (a, b) -> b.getDirection().compareTo(a.direction);
|
||||
private static final Comparator<Transaction> ABSOLUTE_SIZE = (a, b) -> Money.MobileCoin.ASCENDING.compare(a.value, b.value);
|
||||
|
||||
/**
|
||||
* Received first so that if going through a list and keeping a running balance, the order of transactions will not cause that balance to go into negative.
|
||||
* <p>
|
||||
* Then smaller first is just to show more important ones higher on a reversed list.
|
||||
*/
|
||||
public static final Comparator<Transaction> ORDER = ComparatorCompat.chain(RECEIVED_FIRST)
|
||||
.thenComparing(ABSOLUTE_SIZE);
|
||||
|
||||
private final Money.MobileCoin value;
|
||||
private final Direction direction;
|
||||
|
||||
private Transaction(@NonNull Money.MobileCoin value,
|
||||
@NonNull Direction direction)
|
||||
{
|
||||
this.value = value;
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
public @NonNull Money.MobileCoin getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public @NonNull Direction getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public @NonNull Money.MobileCoin getValueWithDirection() {
|
||||
return direction == Direction.SENT ? value.negate() : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return "Transaction{" +
|
||||
value +
|
||||
", " + direction +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
public enum LoadState {
|
||||
INITIAL,
|
||||
LOADING,
|
||||
LOADED,
|
||||
ERROR
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public enum PaymentCategory {
|
||||
ALL("all"),
|
||||
SENT("sent"),
|
||||
RECEIVED("received");
|
||||
|
||||
private final String code;
|
||||
|
||||
PaymentCategory(@NonNull String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@NonNull String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
static @NonNull PaymentCategory forCode(@Nullable String code) {
|
||||
for (PaymentCategory type : values()) {
|
||||
if (type.code.equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return ALL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
|
||||
public class PaymentRecipientSelectionFragment extends LoggingFragment implements ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.ScrollCallback {
|
||||
|
||||
private ContactFilterToolbar toolbar;
|
||||
private ContactSelectionListFragment contactsFragment;
|
||||
|
||||
public PaymentRecipientSelectionFragment() {
|
||||
super(R.layout.payment_recipient_selection_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
toolbar = view.findViewById(R.id.payment_recipient_selection_fragment_toolbar);
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putBoolean(ContactSelectionListFragment.REFRESHABLE, false);
|
||||
arguments.putInt(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_PUSH | DisplayMode.FLAG_HIDE_NEW);
|
||||
arguments.putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, false);
|
||||
|
||||
Fragment child = getChildFragmentManager().findFragmentById(R.id.contact_selection_list_fragment_holder);
|
||||
if (child == null) {
|
||||
FragmentTransaction transaction = getChildFragmentManager().beginTransaction();
|
||||
contactsFragment = new ContactSelectionListFragment();
|
||||
contactsFragment.setArguments(arguments);
|
||||
transaction.add(R.id.contact_selection_list_fragment_holder, contactsFragment);
|
||||
transaction.commit();
|
||||
} else {
|
||||
contactsFragment = (ContactSelectionListFragment) child;
|
||||
}
|
||||
|
||||
initializeSearch();
|
||||
}
|
||||
|
||||
private void initializeSearch() {
|
||||
toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number) {
|
||||
if (recipientId.isPresent()) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(recipientId.get()),
|
||||
this::createPaymentOrShowWarningDialog);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number) { }
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
hideKeyboard();
|
||||
}
|
||||
|
||||
private void hideKeyboard() {
|
||||
ViewUtil.hideKeyboard(requireContext(), toolbar);
|
||||
toolbar.clearFocus();
|
||||
}
|
||||
|
||||
private void createPaymentOrShowWarningDialog(@NonNull Recipient recipient) {
|
||||
if (recipient.hasProfileKeyCredential()) {
|
||||
createPayment(recipient.getId());
|
||||
} else {
|
||||
showWarningDialog(recipient.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private void createPayment(@NonNull RecipientId recipientId) {
|
||||
hideKeyboard();
|
||||
Navigation.findNavController(requireView()).navigate(PaymentRecipientSelectionFragmentDirections.actionPaymentRecipientSelectionToCreatePayment(new PayeeParcelable(recipientId)));
|
||||
}
|
||||
|
||||
private void showWarningDialog(@NonNull RecipientId recipientId) {
|
||||
CanNotSendPaymentDialog.show(requireContext(),
|
||||
() -> openConversation(recipientId));
|
||||
}
|
||||
|
||||
private void openConversation(@NonNull RecipientId recipientId) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> DatabaseFactory.getThreadDatabase(requireContext()).getThreadIdIfExistsFor(recipientId),
|
||||
threadId -> startActivity(ConversationIntents.createBuilder(requireContext(), recipientId, threadId).build()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
enum PaymentStateEvent {
|
||||
NO_BALANCE,
|
||||
DEACTIVATE_WITHOUT_BALANCE,
|
||||
DEACTIVATE_WITH_BALANCE,
|
||||
DEACTIVATED,
|
||||
ACTIVATED
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public enum PaymentType {
|
||||
REQUEST("request"),
|
||||
PAYMENT("payment");
|
||||
|
||||
private final String code;
|
||||
|
||||
PaymentType(@NonNull String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@NonNull String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
public class PaymentsActivity extends PassphraseRequiredActivity {
|
||||
|
||||
public static final String EXTRA_PAYMENTS_STARTING_ACTION = "payments_starting_action";
|
||||
public static final String EXTRA_STARTING_ARGUMENTS = "payments_starting_arguments";
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState, boolean ready) {
|
||||
dynamicTheme.onCreate(this);
|
||||
|
||||
setContentView(R.layout.payments_activity);
|
||||
|
||||
NavController controller = Navigation.findNavController(this, R.id.nav_host_fragment);
|
||||
controller.setGraph(R.navigation.payments_preferences);
|
||||
|
||||
int startingAction = getIntent().getIntExtra(EXTRA_PAYMENTS_STARTING_ACTION, R.id.paymentsHome);
|
||||
if (startingAction != R.id.paymentsHome) {
|
||||
controller.navigate(startingAction, getIntent().getBundleExtra(EXTRA_STARTING_ARGUMENTS));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
dynamicTheme.onResume(this);
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(PaymentLedgerUpdateJob.updateLedger());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class PaymentsAllActivityFragment extends LoggingFragment {
|
||||
|
||||
public PaymentsAllActivityFragment() {
|
||||
super(R.layout.payments_activity_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
ViewPager viewPager = view.findViewById(R.id.payments_all_activity_fragment_view_pager);
|
||||
TabLayout tabLayout = view.findViewById(R.id.payments_all_activity_fragment_tabs);
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_all_activity_fragment_toolbar);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
viewPager.setAdapter(new Adapter(getChildFragmentManager()));
|
||||
tabLayout.setupWithViewPager(viewPager);
|
||||
}
|
||||
|
||||
private final class Adapter extends FragmentStatePagerAdapter {
|
||||
|
||||
Adapter(@NonNull FragmentManager fm) {
|
||||
super(fm, FragmentStatePagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull CharSequence getPageTitle(int position) {
|
||||
switch (position) {
|
||||
case 0 : return getString(R.string.PaymentsAllActivityFragment__all);
|
||||
case 1 : return getString(R.string.PaymentsAllActivityFragment__sent);
|
||||
case 2 : return getString(R.string.PaymentsAllActivityFragment__received);
|
||||
default: throw new IllegalStateException("Unknown position: " + position);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Fragment getItem(int position) {
|
||||
switch (position) {
|
||||
case 0 : return PaymentsPagerItemFragment.getFragmentForAllPayments();
|
||||
case 1 : return PaymentsPagerItemFragment.getFragmentForSentPayments();
|
||||
case 2 : return PaymentsPagerItemFragment.getFragmentForReceivedPayments();
|
||||
default: throw new IllegalStateException("Unknown position: " + position);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.settings.SettingHeader;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.InProgress;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.InfoCard;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.IntroducingPayments;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.NoRecentActivity;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.SeeAll;
|
||||
import org.thoughtcrime.securesms.payments.preferences.viewholder.InProgressViewHolder;
|
||||
import org.thoughtcrime.securesms.payments.preferences.viewholder.InfoCardViewHolder;
|
||||
import org.thoughtcrime.securesms.payments.preferences.viewholder.IntroducingPaymentViewHolder;
|
||||
import org.thoughtcrime.securesms.payments.preferences.viewholder.NoRecentActivityViewHolder;
|
||||
import org.thoughtcrime.securesms.payments.preferences.viewholder.PaymentItemViewHolder;
|
||||
import org.thoughtcrime.securesms.payments.preferences.viewholder.SeeAllViewHolder;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
|
||||
public class PaymentsHomeAdapter extends MappingAdapter {
|
||||
|
||||
public PaymentsHomeAdapter(@NonNull Callbacks callbacks) {
|
||||
registerFactory(IntroducingPayments.class, p -> new IntroducingPaymentViewHolder(p, callbacks), R.layout.payments_home_introducing_payments_item);
|
||||
registerFactory(NoRecentActivity.class, NoRecentActivityViewHolder::new, R.layout.payments_home_no_recent_activity_item);
|
||||
registerFactory(InProgress.class, InProgressViewHolder::new, R.layout.payments_home_in_progress);
|
||||
registerFactory(PaymentItem.class, p -> new PaymentItemViewHolder(p, callbacks), R.layout.payments_home_payment_item);
|
||||
registerFactory(SettingHeader.Item.class, SettingHeader.ViewHolder::new, R.layout.base_settings_header_item);
|
||||
registerFactory(SeeAll.class, p -> new SeeAllViewHolder(p, callbacks), R.layout.payments_home_see_all_item);
|
||||
registerFactory(InfoCard.class, p -> new InfoCardViewHolder(p, callbacks), R.layout.payment_info_card);
|
||||
}
|
||||
|
||||
public interface Callbacks {
|
||||
default void onActivatePayments() {}
|
||||
default void onRestorePaymentsAccount() {}
|
||||
default void onSeeAll(@NonNull PaymentType paymentType) {}
|
||||
default void onPaymentItem(@NonNull PaymentItem model) {}
|
||||
default void onInfoCardDismissed() {}
|
||||
default void onViewRecoveryPhrase() {}
|
||||
default void onUpdatePin() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.PaymentPreferencesDirections;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.help.HelpFragment;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil;
|
||||
import org.thoughtcrime.securesms.payments.MoneyView;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
|
||||
public class PaymentsHomeFragment extends LoggingFragment {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsHomeFragment.class);
|
||||
|
||||
private PaymentsHomeViewModel viewModel;
|
||||
|
||||
private final OnBackPressed onBackPressed = new OnBackPressed();
|
||||
|
||||
public PaymentsHomeFragment() {
|
||||
super(R.layout.payments_home_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_home_fragment_toolbar);
|
||||
RecyclerView recycler = view.findViewById(R.id.payments_home_fragment_recycler);
|
||||
View header = view.findViewById(R.id.payments_home_fragment_header);
|
||||
MoneyView balance = view.findViewById(R.id.payments_home_fragment_header_balance);
|
||||
TextView exchange = view.findViewById(R.id.payments_home_fragment_header_exchange);
|
||||
View addMoney = view.findViewById(R.id.button_start_frame);
|
||||
View sendMoney = view.findViewById(R.id.button_end_frame);
|
||||
View refresh = view.findViewById(R.id.payments_home_fragment_header_refresh);
|
||||
LottieAnimationView refreshAnimation = view.findViewById(R.id.payments_home_fragment_header_refresh_animation);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> {
|
||||
viewModel.markAllPaymentsSeen();
|
||||
requireActivity().finish();
|
||||
});
|
||||
|
||||
toolbar.setOnMenuItemClickListener(this::onMenuItemSelected);
|
||||
|
||||
addMoney.setOnClickListener(v -> {
|
||||
if (SignalStore.paymentsValues().getPaymentsAvailability().isSendAllowed()) {
|
||||
Navigation.findNavController(v).navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentsAddMoney());
|
||||
} else {
|
||||
showPaymentsDisabledDialog();
|
||||
}
|
||||
});
|
||||
sendMoney.setOnClickListener(v -> {
|
||||
if (SignalStore.paymentsValues().getPaymentsAvailability().isSendAllowed()) {
|
||||
Navigation.findNavController(v).navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentRecipientSelectionFragment());
|
||||
} else {
|
||||
showPaymentsDisabledDialog();
|
||||
}
|
||||
});
|
||||
|
||||
PaymentsHomeAdapter adapter = new PaymentsHomeAdapter(new HomeCallbacks());
|
||||
recycler.setAdapter(adapter);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, new PaymentsHomeViewModel.Factory()).get(PaymentsHomeViewModel.class);
|
||||
|
||||
viewModel.getList().observe(getViewLifecycleOwner(), list -> {
|
||||
// TODO [alex] -- this is a bit of a hack
|
||||
boolean hadPaymentItems = Stream.of(adapter.getCurrentList()).anyMatch(model -> model instanceof PaymentItem);
|
||||
|
||||
if (!hadPaymentItems) {
|
||||
adapter.submitList(list, () -> recycler.scrollToPosition(0));
|
||||
} else {
|
||||
adapter.submitList(list);
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getPaymentsEnabled().observe(getViewLifecycleOwner(), enabled -> {
|
||||
if (enabled) {
|
||||
toolbar.inflateMenu(R.menu.payments_home_fragment_menu);
|
||||
} else {
|
||||
toolbar.getMenu().clear();
|
||||
}
|
||||
header.setVisibility(enabled ? View.VISIBLE : View.GONE);
|
||||
});
|
||||
viewModel.getBalance().observe(getViewLifecycleOwner(), balance::setMoney);
|
||||
viewModel.getExchange().observe(getViewLifecycleOwner(), amount -> {
|
||||
if (amount != null) {
|
||||
exchange.setText(FiatMoneyUtil.format(getResources(), amount));
|
||||
} else {
|
||||
exchange.setText(R.string.PaymentsHomeFragment__unknown_amount);
|
||||
}
|
||||
});
|
||||
|
||||
refresh.setOnClickListener(v -> viewModel.refreshExchangeRates(true));
|
||||
exchange.setOnClickListener(v -> viewModel.refreshExchangeRates(true));
|
||||
|
||||
viewModel.getExchangeLoadState().observe(getViewLifecycleOwner(), loadState -> {
|
||||
switch (loadState) {
|
||||
case INITIAL:
|
||||
case LOADED:
|
||||
refresh.setVisibility(View.VISIBLE);
|
||||
refreshAnimation.cancelAnimation();
|
||||
refreshAnimation.setVisibility(View.GONE);
|
||||
break;
|
||||
case LOADING:
|
||||
refresh.setVisibility(View.INVISIBLE);
|
||||
refreshAnimation.playAnimation();
|
||||
refreshAnimation.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
case ERROR:
|
||||
refresh.setVisibility(View.VISIBLE);
|
||||
refreshAnimation.cancelAnimation();
|
||||
refreshAnimation.setVisibility(View.GONE);
|
||||
exchange.setText(R.string.PaymentsHomeFragment__currency_conversion_not_available);
|
||||
Toast.makeText(view.getContext(), R.string.PaymentsHomeFragment__cant_display_currency_conversion, Toast.LENGTH_SHORT).show();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getPaymentStateEvents().observe(getViewLifecycleOwner(), paymentStateEvent -> {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
|
||||
|
||||
builder.setTitle(R.string.PaymentsHomeFragment__deactivate_payments_question);
|
||||
builder.setMessage(R.string.PaymentsHomeFragment__you_will_not_be_able_to_send);
|
||||
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss());
|
||||
|
||||
switch (paymentStateEvent) {
|
||||
case NO_BALANCE:
|
||||
Toast.makeText(requireContext(), R.string.PaymentsHomeFragment__balance_is_not_currently_available, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
case DEACTIVATED:
|
||||
Snackbar.make(requireView(), R.string.PaymentsHomeFragment__payments_deactivated, Snackbar.LENGTH_SHORT)
|
||||
.setTextColor(Color.WHITE)
|
||||
.show();
|
||||
return;
|
||||
case DEACTIVATE_WITHOUT_BALANCE:
|
||||
builder.setPositiveButton(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary),
|
||||
getString(R.string.PaymentsHomeFragment__deactivate)),
|
||||
(dialog, which) -> {
|
||||
viewModel.confirmDeactivatePayments();
|
||||
dialog.dismiss();
|
||||
});
|
||||
break;
|
||||
case DEACTIVATE_WITH_BALANCE:
|
||||
builder.setPositiveButton(getString(R.string.PaymentsHomeFragment__continue), (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
NavHostFragment.findNavController(this).navigate(R.id.deactivateWallet);
|
||||
});
|
||||
break;
|
||||
case ACTIVATED:
|
||||
return;
|
||||
default:
|
||||
throw new IllegalStateException("Unsupported event type: " + paymentStateEvent.name());
|
||||
}
|
||||
|
||||
builder.show();
|
||||
});
|
||||
|
||||
viewModel.getErrorEnablingPayments().observe(getViewLifecycleOwner(), errorEnabling -> {
|
||||
switch (errorEnabling) {
|
||||
case REGION:
|
||||
Toast.makeText(view.getContext(), R.string.PaymentsHomeFragment__payments_is_not_available_in_your_region, Toast.LENGTH_LONG).show();
|
||||
break;
|
||||
case NETWORK:
|
||||
Toast.makeText(view.getContext(), R.string.PaymentsHomeFragment__could_not_enable_payments, Toast.LENGTH_SHORT).show();
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
});
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(onBackPressed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
viewModel.checkPaymentActivationState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
onBackPressed.setEnabled(false);
|
||||
}
|
||||
|
||||
private boolean onMenuItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.payments_home_fragment_menu_transfer_to_exchange) {
|
||||
NavHostFragment.findNavController(this).navigate(R.id.action_paymentsHome_to_paymentsTransfer);
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.payments_home_fragment_menu_set_currency) {
|
||||
NavHostFragment.findNavController(this).navigate(R.id.action_paymentsHome_to_setCurrency);
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.payments_home_fragment_menu_deactivate_wallet) {
|
||||
viewModel.deactivatePayments();
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.payments_home_fragment_menu_view_recovery_phrase) {
|
||||
NavHostFragment.findNavController(this).navigate(R.id.action_paymentsHome_to_paymentsBackup);
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.payments_home_fragment_menu_help) {
|
||||
Intent intent = new Intent(requireContext(), ApplicationPreferencesActivity.class);
|
||||
intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_HELP_FRAGMENT, true);
|
||||
intent.putExtra(HelpFragment.START_CATEGORY_INDEX, HelpFragment.PAYMENT_INDEX);
|
||||
|
||||
startActivity(intent);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void showPaymentsDisabledDialog() {
|
||||
new AlertDialog.Builder(requireActivity())
|
||||
.setMessage(R.string.PaymentsHomeFragment__payments_not_available)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
class HomeCallbacks implements PaymentsHomeAdapter.Callbacks {
|
||||
@Override
|
||||
public void onActivatePayments() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.PaymentsHomeFragment__you_can_use_signal_to_send)
|
||||
.setPositiveButton(R.string.PaymentsHomeFragment__activate, (dialog, which) -> {
|
||||
viewModel.activatePayments();
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton(R.string.PaymentsHomeFragment__view_mobile_coin_terms, (dialog, which) -> {
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PaymentsHomeFragment__mobile_coin_terms_url));
|
||||
})
|
||||
.setNeutralButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestorePaymentsAccount() {
|
||||
NavHostFragment.findNavController(PaymentsHomeFragment.this)
|
||||
.navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentsBackup().setIsRestore(true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSeeAll(@NonNull PaymentType paymentType) {
|
||||
NavHostFragment.findNavController(PaymentsHomeFragment.this)
|
||||
.navigate(PaymentsHomeFragmentDirections.actionPaymentsHomeToPaymentsAllActivity(paymentType));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPaymentItem(@NonNull PaymentItem model) {
|
||||
NavHostFragment.findNavController(PaymentsHomeFragment.this)
|
||||
.navigate(PaymentPreferencesDirections.actionDirectlyToPaymentDetails(model.getPaymentDetailsParcelable()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInfoCardDismissed() {
|
||||
viewModel.onInfoCardDismissed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpdatePin() {
|
||||
startActivityForResult(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateKbsPinActivity.REQUEST_NEW_PIN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecoveryPhrase() {
|
||||
NavHostFragment.findNavController(PaymentsHomeFragment.this).navigate(R.id.action_paymentsHome_to_paymentsBackup);
|
||||
}
|
||||
}
|
||||
|
||||
private class OnBackPressed extends OnBackPressedCallback {
|
||||
|
||||
public OnBackPressed() {
|
||||
super(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
viewModel.markAllPaymentsSeen();
|
||||
requireActivity().finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class PaymentsHomeRepository {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsHomeRepository.class);
|
||||
|
||||
public void activatePayments(@NonNull AsynchronousCallback.WorkerThread<Void, Error> callback) {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(true);
|
||||
try {
|
||||
ProfileUtil.uploadProfile(ApplicationDependencies.getApplication());
|
||||
ApplicationDependencies.getJobManager().add(PaymentLedgerUpdateJob.updateLedger());
|
||||
callback.onComplete(null);
|
||||
} catch (PaymentsRegionException e) {
|
||||
SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false);
|
||||
Log.w(TAG, "Problem enabling payments in region", e);
|
||||
callback.onError(Error.RegionError);
|
||||
} catch (NonSuccessfulResponseCodeException e) {
|
||||
SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false);
|
||||
Log.w(TAG, "Problem enabling payments", e);
|
||||
callback.onError(Error.NetworkError);
|
||||
} catch (IOException e) {
|
||||
SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false);
|
||||
Log.w(TAG, "Problem enabling payments", e);
|
||||
tryToRestoreProfile();
|
||||
callback.onError(Error.NetworkError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void tryToRestoreProfile() {
|
||||
try {
|
||||
ProfileUtil.uploadProfile(ApplicationDependencies.getApplication());
|
||||
Log.i(TAG, "Restored profile");
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Problem uploading profile", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deactivatePayments(@NonNull Consumer<Boolean> consumer) {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
SignalStore.paymentsValues().setMobileCoinPaymentsEnabled(false);
|
||||
ApplicationDependencies.getJobManager().add(new ProfileUploadJob());
|
||||
consumer.accept(!SignalStore.paymentsValues().mobileCoinPaymentsEnabled());
|
||||
});
|
||||
}
|
||||
|
||||
public enum Error {
|
||||
NetworkError,
|
||||
RegionError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class PaymentsHomeState {
|
||||
private final PaymentsState paymentsState;
|
||||
private final FiatMoney exchangeAmount;
|
||||
private final List<PaymentItem> requests;
|
||||
private final List<PaymentItem> payments;
|
||||
private final int totalPayments;
|
||||
private final CurrencyExchange currencyExchange;
|
||||
private final LoadState exchangeRateLoadState;
|
||||
private final boolean recentPaymentsLoaded;
|
||||
|
||||
public PaymentsHomeState(@NonNull PaymentsState paymentsState) {
|
||||
this(paymentsState,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
0,
|
||||
new CurrencyExchange(Collections.emptyMap(), 0),
|
||||
LoadState.INITIAL,
|
||||
false);
|
||||
}
|
||||
|
||||
public PaymentsHomeState(@NonNull PaymentsState paymentsState,
|
||||
@Nullable FiatMoney exchangeAmount,
|
||||
@NonNull List<PaymentItem> requests,
|
||||
@NonNull List<PaymentItem> payments,
|
||||
int totalPayments,
|
||||
@NonNull CurrencyExchange currencyExchange,
|
||||
@NonNull LoadState exchangeRateLoadState,
|
||||
boolean recentPaymentsLoaded)
|
||||
{
|
||||
this.paymentsState = paymentsState;
|
||||
this.exchangeAmount = exchangeAmount;
|
||||
this.requests = requests;
|
||||
this.payments = payments;
|
||||
this.totalPayments = totalPayments;
|
||||
this.currencyExchange = currencyExchange;
|
||||
this.exchangeRateLoadState = exchangeRateLoadState;
|
||||
this.recentPaymentsLoaded = recentPaymentsLoaded;
|
||||
}
|
||||
|
||||
public @NonNull PaymentsState getPaymentsState() {
|
||||
return paymentsState;
|
||||
}
|
||||
|
||||
public @Nullable FiatMoney getExchangeAmount() {
|
||||
return exchangeAmount;
|
||||
}
|
||||
|
||||
public @NonNull List<PaymentItem> getRequests() {
|
||||
return requests;
|
||||
}
|
||||
|
||||
public @NonNull List<PaymentItem> getPayments() {
|
||||
return payments;
|
||||
}
|
||||
|
||||
public int getTotalPayments() {
|
||||
return totalPayments;
|
||||
}
|
||||
|
||||
public @NonNull CurrencyExchange getCurrencyExchange() {
|
||||
return currencyExchange;
|
||||
}
|
||||
|
||||
public @NonNull LoadState getExchangeRateLoadState() {
|
||||
return exchangeRateLoadState;
|
||||
}
|
||||
|
||||
public boolean isRecentPaymentsLoaded() {
|
||||
return recentPaymentsLoaded;
|
||||
}
|
||||
|
||||
public @NonNull PaymentsHomeState updatePaymentsEnabled(@NonNull PaymentsState paymentsEnabled) {
|
||||
return new PaymentsHomeState(paymentsEnabled,
|
||||
this.exchangeAmount,
|
||||
this.requests,
|
||||
this.payments,
|
||||
this.totalPayments,
|
||||
this.currencyExchange,
|
||||
this.exchangeRateLoadState,
|
||||
this.recentPaymentsLoaded);
|
||||
}
|
||||
|
||||
public @NonNull PaymentsHomeState updatePayments(@NonNull List<PaymentItem> payments, int totalPayments) {
|
||||
return new PaymentsHomeState(this.paymentsState,
|
||||
this.exchangeAmount,
|
||||
this.requests,
|
||||
payments,
|
||||
totalPayments,
|
||||
this.currencyExchange,
|
||||
this.exchangeRateLoadState,
|
||||
true);
|
||||
}
|
||||
|
||||
public @NonNull PaymentsHomeState updateCurrencyAmount(@Nullable FiatMoney exchangeAmount) {
|
||||
return new PaymentsHomeState(this.paymentsState,
|
||||
exchangeAmount,
|
||||
this.requests,
|
||||
this.payments,
|
||||
this.totalPayments,
|
||||
this.currencyExchange,
|
||||
this.exchangeRateLoadState,
|
||||
this.recentPaymentsLoaded);
|
||||
}
|
||||
|
||||
public @NonNull PaymentsHomeState updateExchangeRateLoadState(@NonNull LoadState exchangeRateLoadState) {
|
||||
return new PaymentsHomeState(this.paymentsState,
|
||||
this.exchangeAmount,
|
||||
this.requests,
|
||||
this.payments,
|
||||
this.totalPayments,
|
||||
this.currencyExchange,
|
||||
exchangeRateLoadState,
|
||||
this.recentPaymentsLoaded);
|
||||
}
|
||||
|
||||
public @NonNull PaymentsHomeState updateCurrencyExchange(@NonNull CurrencyExchange currencyExchange, @NonNull LoadState exchangeRateLoadState) {
|
||||
return new PaymentsHomeState(this.paymentsState,
|
||||
this.exchangeAmount,
|
||||
this.requests,
|
||||
this.payments,
|
||||
this.totalPayments,
|
||||
currencyExchange,
|
||||
exchangeRateLoadState,
|
||||
this.recentPaymentsLoaded);
|
||||
}
|
||||
|
||||
public enum PaymentsState {
|
||||
NOT_ACTIVATED,
|
||||
ACTIVATING,
|
||||
ACTIVATED,
|
||||
DEACTIVATING,
|
||||
ACTIVATE_NOT_ALLOWED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.settings.SettingHeader;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsAvailability;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Balance;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchangeRepository;
|
||||
import org.thoughtcrime.securesms.payments.currency.FiatMoney;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.InProgress;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.InfoCard;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.IntroducingPayments;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.NoRecentActivity;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.SeeAll;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class PaymentsHomeViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsHomeViewModel.class);
|
||||
|
||||
private static final int MAX_PAYMENT_ITEMS = 4;
|
||||
|
||||
private final Store<PaymentsHomeState> store;
|
||||
private final LiveData<MappingModelList> list;
|
||||
private final LiveData<Boolean> paymentsEnabled;
|
||||
private final LiveData<Money> balance;
|
||||
private final LiveData<FiatMoney> exchange;
|
||||
private final SingleLiveEvent<PaymentStateEvent> paymentStateEvents;
|
||||
private final SingleLiveEvent<ErrorEnabling> errorEnablingPayments;
|
||||
|
||||
private final PaymentsHomeRepository paymentsHomeRepository;
|
||||
private final CurrencyExchangeRepository currencyExchangeRepository;
|
||||
private final UnreadPaymentsRepository unreadPaymentsRepository;
|
||||
private final LiveData<LoadState> exchangeLoadState;
|
||||
|
||||
PaymentsHomeViewModel(@NonNull PaymentsHomeRepository paymentsHomeRepository,
|
||||
@NonNull PaymentsRepository paymentsRepository,
|
||||
@NonNull CurrencyExchangeRepository currencyExchangeRepository)
|
||||
{
|
||||
this.paymentsHomeRepository = paymentsHomeRepository;
|
||||
this.currencyExchangeRepository = currencyExchangeRepository;
|
||||
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
|
||||
this.store = new Store<>(new PaymentsHomeState(getPaymentsState()));
|
||||
this.balance = LiveDataUtil.mapDistinct(SignalStore.paymentsValues().liveMobileCoinBalance(), Balance::getFullAmount);
|
||||
this.list = Transformations.map(store.getStateLiveData(), this::createList);
|
||||
this.paymentsEnabled = LiveDataUtil.mapDistinct(store.getStateLiveData(), state -> state.getPaymentsState() == PaymentsHomeState.PaymentsState.ACTIVATED);
|
||||
this.exchange = LiveDataUtil.mapDistinct(store.getStateLiveData(), PaymentsHomeState::getExchangeAmount);
|
||||
this.exchangeLoadState = LiveDataUtil.mapDistinct(store.getStateLiveData(), PaymentsHomeState::getExchangeRateLoadState);
|
||||
this.paymentStateEvents = new SingleLiveEvent<>();
|
||||
this.errorEnablingPayments = new SingleLiveEvent<>();
|
||||
|
||||
this.store.update(paymentsRepository.getRecentPayments(), this::updateRecentPayments);
|
||||
|
||||
LiveData<CurrencyExchange.ExchangeRate> liveExchangeRate = LiveDataUtil.combineLatest(SignalStore.paymentsValues().liveCurrentCurrency(),
|
||||
LiveDataUtil.mapDistinct(store.getStateLiveData(), PaymentsHomeState::getCurrencyExchange),
|
||||
(currency, exchange) -> exchange.getExchangeRate(currency));
|
||||
|
||||
LiveData<Optional<FiatMoney>> liveExchangeAmount = LiveDataUtil.combineLatest(this.balance,
|
||||
liveExchangeRate,
|
||||
(balance, exchangeRate) -> exchangeRate.exchange(balance));
|
||||
this.store.update(liveExchangeAmount, (amount, state) -> state.updateCurrencyAmount(amount.orNull()));
|
||||
|
||||
refreshExchangeRates(true);
|
||||
}
|
||||
|
||||
private static PaymentsHomeState.PaymentsState getPaymentsState() {
|
||||
PaymentsValues paymentsValues = SignalStore.paymentsValues();
|
||||
|
||||
PaymentsAvailability paymentsAvailability = paymentsValues.getPaymentsAvailability();
|
||||
|
||||
if (paymentsAvailability.canRegister()) {
|
||||
return PaymentsHomeState.PaymentsState.NOT_ACTIVATED;
|
||||
} else if (paymentsAvailability.isEnabled()) {
|
||||
return PaymentsHomeState.PaymentsState.ACTIVATED;
|
||||
} else {
|
||||
return PaymentsHomeState.PaymentsState.ACTIVATE_NOT_ALLOWED;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull LiveData<PaymentStateEvent> getPaymentStateEvents() {
|
||||
return paymentStateEvents;
|
||||
}
|
||||
|
||||
@NonNull LiveData<ErrorEnabling> getErrorEnablingPayments() {
|
||||
return errorEnablingPayments;
|
||||
}
|
||||
|
||||
@NonNull LiveData<MappingModelList> getList() {
|
||||
return list;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getPaymentsEnabled() {
|
||||
return paymentsEnabled;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Money> getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
@NonNull LiveData<FiatMoney> getExchange() {
|
||||
return exchange;
|
||||
}
|
||||
|
||||
@NonNull LiveData<LoadState> getExchangeLoadState() {
|
||||
return exchangeLoadState;
|
||||
}
|
||||
|
||||
void markAllPaymentsSeen() {
|
||||
unreadPaymentsRepository.markAllPaymentsSeen();
|
||||
}
|
||||
|
||||
void checkPaymentActivationState() {
|
||||
PaymentsHomeState.PaymentsState storedState = store.getState().getPaymentsState();
|
||||
boolean paymentsEnabled = SignalStore.paymentsValues().mobileCoinPaymentsEnabled();
|
||||
|
||||
if (storedState.equals(PaymentsHomeState.PaymentsState.ACTIVATED) && !paymentsEnabled) {
|
||||
store.update(s -> s.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.NOT_ACTIVATED));
|
||||
paymentStateEvents.setValue(PaymentStateEvent.DEACTIVATED);
|
||||
} else if (storedState.equals(PaymentsHomeState.PaymentsState.NOT_ACTIVATED) && paymentsEnabled) {
|
||||
store.update(s -> s.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.ACTIVATED));
|
||||
paymentStateEvents.setValue(PaymentStateEvent.ACTIVATED);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull MappingModelList createList(@NonNull PaymentsHomeState state) {
|
||||
MappingModelList list = new MappingModelList();
|
||||
|
||||
if (state.getPaymentsState() == PaymentsHomeState.PaymentsState.ACTIVATED) {
|
||||
if (state.getTotalPayments() > 0) {
|
||||
list.add(new SettingHeader.Item(R.string.PaymentsHomeFragment__recent_activity));
|
||||
list.addAll(state.getPayments());
|
||||
if (state.getTotalPayments() > MAX_PAYMENT_ITEMS) {
|
||||
list.add(new SeeAll(PaymentType.PAYMENT));
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.isRecentPaymentsLoaded()) {
|
||||
list.add(new InProgress());
|
||||
} else if (state.getRequests().isEmpty() &&
|
||||
state.getPayments().isEmpty() &&
|
||||
state.isRecentPaymentsLoaded())
|
||||
{
|
||||
list.add(new NoRecentActivity());
|
||||
}
|
||||
} else if (state.getPaymentsState() == PaymentsHomeState.PaymentsState.ACTIVATE_NOT_ALLOWED) {
|
||||
Log.w(TAG, "Payments remotely disabled or not in region");
|
||||
} else {
|
||||
list.add(new IntroducingPayments(state.getPaymentsState()));
|
||||
}
|
||||
|
||||
list.addAll(InfoCard.getInfoCards());
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private @NonNull PaymentsHomeState updateRecentPayments(@NonNull List<Payment> payments,
|
||||
@NonNull PaymentsHomeState state)
|
||||
{
|
||||
List<PaymentItem> paymentItems = Stream.of(payments)
|
||||
.limit(MAX_PAYMENT_ITEMS)
|
||||
.map(PaymentItem::fromPayment)
|
||||
.toList();
|
||||
|
||||
return state.updatePayments(paymentItems, payments.size());
|
||||
}
|
||||
|
||||
public void onInfoCardDismissed() {
|
||||
store.update(s -> s);
|
||||
}
|
||||
|
||||
public void activatePayments() {
|
||||
if (store.getState().getPaymentsState() != PaymentsHomeState.PaymentsState.NOT_ACTIVATED) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.ACTIVATING));
|
||||
|
||||
paymentsHomeRepository.activatePayments(new AsynchronousCallback.WorkerThread<Void, PaymentsHomeRepository.Error>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable Void result) {
|
||||
store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.ACTIVATED));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable PaymentsHomeRepository.Error error) {
|
||||
store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.NOT_ACTIVATED));
|
||||
if (error == PaymentsHomeRepository.Error.NetworkError) {
|
||||
errorEnablingPayments.postValue(ErrorEnabling.NETWORK);
|
||||
} else if (error == PaymentsHomeRepository.Error.RegionError) {
|
||||
errorEnablingPayments.postValue(ErrorEnabling.REGION);
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void deactivatePayments() {
|
||||
Money money = balance.getValue();
|
||||
if (money == null) {
|
||||
paymentStateEvents.setValue(PaymentStateEvent.NO_BALANCE);
|
||||
} else if (money.isPositive()) {
|
||||
paymentStateEvents.setValue(PaymentStateEvent.DEACTIVATE_WITH_BALANCE);
|
||||
} else {
|
||||
paymentStateEvents.setValue(PaymentStateEvent.DEACTIVATE_WITHOUT_BALANCE);
|
||||
}
|
||||
}
|
||||
|
||||
public void confirmDeactivatePayments() {
|
||||
if (store.getState().getPaymentsState() != PaymentsHomeState.PaymentsState.ACTIVATED) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.update(state -> state.updatePaymentsEnabled(PaymentsHomeState.PaymentsState.DEACTIVATING));
|
||||
|
||||
paymentsHomeRepository.deactivatePayments(result -> {
|
||||
store.update(state -> state.updatePaymentsEnabled(result ? PaymentsHomeState.PaymentsState.NOT_ACTIVATED : PaymentsHomeState.PaymentsState.ACTIVATED));
|
||||
|
||||
if (result) {
|
||||
paymentStateEvents.postValue(PaymentStateEvent.DEACTIVATED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void refreshExchangeRates(boolean refreshIfAble) {
|
||||
store.update(state -> state.updateExchangeRateLoadState(LoadState.LOADING));
|
||||
currencyExchangeRepository.getCurrencyExchange(new AsynchronousCallback.WorkerThread<CurrencyExchange, Throwable>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable CurrencyExchange result) {
|
||||
store.update(state -> state.updateCurrencyExchange(result, LoadState.LOADED));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable Throwable error) {
|
||||
Log.w(TAG, error);
|
||||
store.update(state -> state.updateExchangeRateLoadState(LoadState.ERROR));
|
||||
}
|
||||
}, refreshIfAble);
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new PaymentsHomeViewModel(new PaymentsHomeRepository(),
|
||||
new PaymentsRepository(),
|
||||
new CurrencyExchangeRepository(ApplicationDependencies.getPayments())));
|
||||
}
|
||||
}
|
||||
|
||||
public enum ErrorEnabling {
|
||||
REGION,
|
||||
NETWORK
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.PaymentPreferencesDirections;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
|
||||
|
||||
public class PaymentsPagerItemFragment extends LoggingFragment {
|
||||
|
||||
private static final String PAYMENT_CATEGORY = "payment_category";
|
||||
|
||||
private PaymentsPagerItemViewModel viewModel;
|
||||
|
||||
static @NonNull Fragment getFragmentForAllPayments() {
|
||||
return getFragment(PaymentCategory.ALL);
|
||||
}
|
||||
|
||||
static @NonNull Fragment getFragmentForSentPayments() {
|
||||
return getFragment(PaymentCategory.SENT);
|
||||
}
|
||||
|
||||
static @NonNull Fragment getFragmentForReceivedPayments() {
|
||||
return getFragment(PaymentCategory.RECEIVED);
|
||||
}
|
||||
|
||||
private static @NonNull Fragment getFragment(@NonNull PaymentCategory paymentCategory) {
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putString(PAYMENT_CATEGORY, paymentCategory.getCode());
|
||||
|
||||
Fragment fragment = new PaymentsPagerItemFragment();
|
||||
fragment.setArguments(arguments);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
public PaymentsPagerItemFragment() {
|
||||
super(R.layout.payment_preferences_all_pager_item_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
PaymentsPagerItemViewModel.Factory factory = new PaymentsPagerItemViewModel.Factory(PaymentCategory.forCode(requireArguments().getString(PAYMENT_CATEGORY)));
|
||||
viewModel = ViewModelProviders.of(this, factory).get(PaymentsPagerItemViewModel.class);
|
||||
|
||||
RecyclerView recycler = view.findViewById(R.id.payments_activity_pager_item_fragment_recycler);
|
||||
PaymentsHomeAdapter adapter = new PaymentsHomeAdapter(new Callbacks());
|
||||
|
||||
recycler.setAdapter(adapter);
|
||||
|
||||
viewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList);
|
||||
}
|
||||
|
||||
private class Callbacks implements PaymentsHomeAdapter.Callbacks {
|
||||
@Override
|
||||
public void onPaymentItem(@NonNull PaymentItem model) {
|
||||
NavHostFragment.findNavController(PaymentsPagerItemFragment.this)
|
||||
.navigate(PaymentPreferencesDirections.actionDirectlyToPaymentDetails(model.getPaymentDetailsParcelable()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PaymentItem;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class PaymentsPagerItemViewModel extends ViewModel {
|
||||
|
||||
private final LiveData<MappingModelList> list;
|
||||
|
||||
PaymentsPagerItemViewModel(@NonNull PaymentCategory paymentCategory, @NonNull PaymentsRepository paymentsRepository) {
|
||||
LiveData<List<Payment>> payments;
|
||||
|
||||
switch (paymentCategory) {
|
||||
case ALL:
|
||||
payments = paymentsRepository.getRecentPayments();
|
||||
break;
|
||||
case SENT:
|
||||
payments = paymentsRepository.getRecentSentPayments();
|
||||
break;
|
||||
case RECEIVED:
|
||||
payments = paymentsRepository.getRecentReceivedPayments();
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
this.list = LiveDataUtil.mapAsync(payments, PaymentItem::fromPayment);
|
||||
}
|
||||
|
||||
@NonNull LiveData<MappingModelList> getList() {
|
||||
return list;
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
private final PaymentCategory paymentCategory;
|
||||
|
||||
public Factory(@NonNull PaymentCategory paymentCategory) {
|
||||
this.paymentCategory = paymentCategory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new PaymentsPagerItemViewModel(paymentCategory, new PaymentsRepository()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.Direction;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinLedgerWrapper;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.reconciliation.LedgerReconcile;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* General repository for accessing payment information.
|
||||
*/
|
||||
public class PaymentsRepository {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsRepository.class);
|
||||
|
||||
private final PaymentDatabase paymentDatabase;
|
||||
private final LiveData<List<Payment>> recentPayments;
|
||||
private final LiveData<List<Payment>> recentSentPayments;
|
||||
private final LiveData<List<Payment>> recentReceivedPayments;
|
||||
|
||||
public PaymentsRepository() {
|
||||
paymentDatabase = DatabaseFactory.getPaymentDatabase(ApplicationDependencies.getApplication());
|
||||
|
||||
LiveData<List<PaymentDatabase.PaymentTransaction>> localPayments = paymentDatabase.getAllLive();
|
||||
LiveData<MobileCoinLedgerWrapper> ledger = SignalStore.paymentsValues().liveMobileCoinLedger();
|
||||
|
||||
//noinspection NullableProblems
|
||||
this.recentPayments = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(localPayments, ledger, Pair::create), p -> reconcile(p.first, p.second));
|
||||
this.recentSentPayments = LiveDataUtil.mapAsync(this.recentPayments, p -> filterPayments(p, Direction.SENT));
|
||||
this.recentReceivedPayments = LiveDataUtil.mapAsync(this.recentPayments, p -> filterPayments(p, Direction.RECEIVED));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<Payment> reconcile(@NonNull Collection<PaymentDatabase.PaymentTransaction> paymentTransactions, @NonNull MobileCoinLedgerWrapper ledger) {
|
||||
List<Payment> reconcile = LedgerReconcile.reconcile(paymentTransactions, ledger);
|
||||
|
||||
updateDatabaseWithNewBlockInformation(reconcile);
|
||||
|
||||
return reconcile;
|
||||
}
|
||||
|
||||
private void updateDatabaseWithNewBlockInformation(@NonNull List<Payment> reconcileOutput) {
|
||||
List<LedgerReconcile.BlockOverridePayment> blockOverridePayments = Stream.of(reconcileOutput)
|
||||
.select(LedgerReconcile.BlockOverridePayment.class)
|
||||
.toList();
|
||||
|
||||
if (blockOverridePayments.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Log.i(TAG, String.format(Locale.US, "%d payments have new block index or timestamp information", blockOverridePayments.size()));
|
||||
|
||||
for (LedgerReconcile.BlockOverridePayment blockOverridePayment : blockOverridePayments) {
|
||||
Payment inner = blockOverridePayment.getInner();
|
||||
boolean override = false;
|
||||
if (inner.getBlockIndex() != blockOverridePayment.getBlockIndex()) {
|
||||
override = true;
|
||||
}
|
||||
if (inner.getBlockTimestamp() != blockOverridePayment.getBlockTimestamp()) {
|
||||
override = true;
|
||||
}
|
||||
if (!override) {
|
||||
Log.w(TAG, " Unnecessary");
|
||||
} else {
|
||||
if (paymentDatabase.updateBlockDetails(inner.getUuid(), blockOverridePayment.getBlockIndex(), blockOverridePayment.getBlockTimestamp())) {
|
||||
Log.d(TAG, " Updated block details for " + inner.getUuid());
|
||||
} else {
|
||||
Log.w(TAG, " Failed to update block details for " + inner.getUuid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull LiveData<List<Payment>> getRecentPayments() {
|
||||
return recentPayments;
|
||||
}
|
||||
|
||||
public @NonNull LiveData<List<Payment>> getRecentSentPayments() {
|
||||
return recentSentPayments;
|
||||
}
|
||||
|
||||
public @NonNull LiveData<List<Payment>> getRecentReceivedPayments() {
|
||||
return recentReceivedPayments;
|
||||
}
|
||||
|
||||
private @NonNull List<Payment> filterPayments(@NonNull List<Payment> payments,
|
||||
@NonNull Direction direction)
|
||||
{
|
||||
return Stream.of(payments)
|
||||
.filter(p -> p.getDirection() == direction)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Dialog to display if chosen Recipient has not enabled payments.
|
||||
*/
|
||||
public final class RecipientHasNotEnabledPaymentsDialog {
|
||||
|
||||
private RecipientHasNotEnabledPaymentsDialog() {
|
||||
}
|
||||
|
||||
public static void show(@NonNull Context context) {
|
||||
show(context, null);
|
||||
}
|
||||
public static void show(@NonNull Context context, @Nullable Runnable onDismissed) {
|
||||
new AlertDialog.Builder(context).setTitle(R.string.ConfirmPaymentFragment__invalid_recipient)
|
||||
.setMessage(R.string.ConfirmPaymentFragment__this_person_has_not_activated_payments)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
if (onDismissed != null) {
|
||||
onDismissed.run();
|
||||
}
|
||||
})
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.settings.BaseSettingsAdapter;
|
||||
|
||||
import java.util.Currency;
|
||||
|
||||
public final class SetCurrencyFragment extends LoggingFragment {
|
||||
|
||||
private boolean handledInitialScroll = false;
|
||||
|
||||
public SetCurrencyFragment() {
|
||||
super(R.layout.set_currency_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.set_currency_fragment_toolbar);
|
||||
RecyclerView list = view.findViewById(R.id.set_currency_fragment_list);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
SetCurrencyViewModel viewModel = ViewModelProviders.of(this, new SetCurrencyViewModel.Factory()).get(SetCurrencyViewModel.class);
|
||||
|
||||
BaseSettingsAdapter adapter = new BaseSettingsAdapter();
|
||||
adapter.configureSingleSelect(selection -> viewModel.select((Currency) selection));
|
||||
list.setAdapter(adapter);
|
||||
|
||||
viewModel.getCurrencyListState().observe(getViewLifecycleOwner(), currencyListState -> {
|
||||
adapter.submitList(currencyListState.getItems(), () -> {
|
||||
if (currencyListState.isLoaded() &&
|
||||
currencyListState.getSelectedIndex() != -1 &&
|
||||
savedInstanceState == null &&
|
||||
!handledInitialScroll)
|
||||
{
|
||||
handledInitialScroll = true;
|
||||
list.post(() -> list.scrollToPosition(currencyListState.getSelectedIndex()));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.settings.SettingHeader;
|
||||
import org.thoughtcrime.securesms.components.settings.SettingProgress;
|
||||
import org.thoughtcrime.securesms.components.settings.SingleSelectSetting;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchange;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyExchangeRepository;
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.Store;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.Collections.emptyMap;
|
||||
|
||||
public final class SetCurrencyViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(SetCurrencyViewModel.class);
|
||||
|
||||
private final Store<SetCurrencyState> store;
|
||||
private final LiveData<CurrencyListState> list;
|
||||
|
||||
public SetCurrencyViewModel(@NonNull CurrencyExchangeRepository currencyExchangeRepository) {
|
||||
this.store = new Store<>(new SetCurrencyState(SignalStore.paymentsValues().currentCurrency()));
|
||||
this.list = Transformations.map(this.store.getStateLiveData(), this::createListState);
|
||||
|
||||
this.store.update(SignalStore.paymentsValues().liveCurrentCurrency(), (currency, state) -> state.updateCurrentCurrency(currency));
|
||||
|
||||
currencyExchangeRepository.getCurrencyExchange(new AsynchronousCallback.WorkerThread<CurrencyExchange, Throwable>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable CurrencyExchange result) {
|
||||
store.update(state -> state.updateCurrencyExchange(Objects.requireNonNull(result)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable Throwable error) {
|
||||
Log.w(TAG, error);
|
||||
store.update(state -> state.updateExchangeRateLoadState(LoadState.ERROR));
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
|
||||
public void select(@NonNull Currency selection) {
|
||||
SignalStore.paymentsValues().setCurrentCurrency(selection);
|
||||
}
|
||||
|
||||
public LiveData<CurrencyListState> getCurrencyListState() {
|
||||
return list;
|
||||
}
|
||||
|
||||
private @NonNull CurrencyListState createListState(SetCurrencyState state) {
|
||||
MappingModelList items = new MappingModelList();
|
||||
boolean areAllCurrenciesLoaded = state.getCurrencyExchangeLoadState() == LoadState.LOADED;
|
||||
|
||||
items.addAll(fromCurrencies(state.getDefaultCurrencies(), state.getCurrentCurrency()));
|
||||
items.add(new SettingHeader.Item(R.string.SetCurrencyFragment__all_currencies));
|
||||
if (areAllCurrenciesLoaded) {
|
||||
items.addAll(fromCurrencies(state.getOtherCurrencies(), state.getCurrentCurrency()));
|
||||
} else {
|
||||
items.add(new SettingProgress.Item());
|
||||
}
|
||||
|
||||
return new CurrencyListState(items, findSelectedIndex(items), areAllCurrenciesLoaded);
|
||||
}
|
||||
|
||||
private @NonNull MappingModelList fromCurrencies(@NonNull Collection<Currency> currencies, @NonNull Currency currentCurrency) {
|
||||
return Stream.of(currencies)
|
||||
.map(c -> new SingleSelectSetting.Item(c, c.getDisplayName(Locale.getDefault()), c.getCurrencyCode(), c.equals(currentCurrency)))
|
||||
.sortBy(SingleSelectSetting.Item::getText)
|
||||
.collect(MappingModelList.toMappingModelList());
|
||||
}
|
||||
|
||||
private int findSelectedIndex(MappingModelList items) {
|
||||
return Stream.of(items)
|
||||
.mapIndexed(Pair::new)
|
||||
.filter(p -> p.second() instanceof SingleSelectSetting.Item)
|
||||
.map(p -> new Pair<>(p.first(), (SingleSelectSetting.Item) p.second()))
|
||||
.filter(pair -> pair.second().isSelected())
|
||||
.findFirst()
|
||||
.map(Pair::first)
|
||||
.orElse(-1);
|
||||
}
|
||||
|
||||
public static class CurrencyListState {
|
||||
private final MappingModelList items;
|
||||
private final int selectedIndex;
|
||||
private final boolean isLoaded;
|
||||
|
||||
public CurrencyListState(@NonNull MappingModelList items, int selectedIndex, boolean isLoaded) {
|
||||
this.items = items;
|
||||
this.isLoaded = isLoaded;
|
||||
this.selectedIndex = selectedIndex;
|
||||
}
|
||||
|
||||
public boolean isLoaded() {
|
||||
return isLoaded;
|
||||
}
|
||||
|
||||
public @NonNull MappingModelList getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public int getSelectedIndex() {
|
||||
return selectedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static class SetCurrencyState {
|
||||
private static final List<Currency> DEFAULT_CURRENCIES = Stream.of(BuildConfig.DEFAULT_CURRENCIES.split(","))
|
||||
.map(CurrencyUtil::getCurrencyByCurrencyCode)
|
||||
.withoutNulls()
|
||||
.toList();
|
||||
|
||||
private final Currency currentCurrency;
|
||||
private final CurrencyExchange currencyExchange;
|
||||
private final LoadState currencyExchangeLoadState;
|
||||
private final Collection<Currency> defaultCurrencies;
|
||||
private final Collection<Currency> otherCurrencies;
|
||||
|
||||
public SetCurrencyState(@NonNull Currency currentCurrency) {
|
||||
this(currentCurrency, new CurrencyExchange(emptyMap(), 0), LoadState.LOADING, DEFAULT_CURRENCIES, emptyList());
|
||||
}
|
||||
|
||||
public SetCurrencyState(@NonNull Currency currentCurrency,
|
||||
@NonNull CurrencyExchange currencyExchange,
|
||||
@NonNull LoadState loadState,
|
||||
@NonNull Collection<Currency> defaultCurrencies,
|
||||
@NonNull Collection<Currency> otherCurrencies)
|
||||
{
|
||||
this.currentCurrency = currentCurrency;
|
||||
this.currencyExchange = currencyExchange;
|
||||
this.currencyExchangeLoadState = loadState;
|
||||
this.defaultCurrencies = defaultCurrencies;
|
||||
this.otherCurrencies = otherCurrencies;
|
||||
}
|
||||
|
||||
public @NonNull Currency getCurrentCurrency() {
|
||||
return currentCurrency;
|
||||
}
|
||||
|
||||
public @NonNull LoadState getCurrencyExchangeLoadState() {
|
||||
return currencyExchangeLoadState;
|
||||
}
|
||||
|
||||
public @NonNull Collection<Currency> getDefaultCurrencies() {
|
||||
return defaultCurrencies;
|
||||
}
|
||||
|
||||
public @NonNull Collection<Currency> getOtherCurrencies() {
|
||||
return otherCurrencies;
|
||||
}
|
||||
|
||||
public @NonNull SetCurrencyState updateExchangeRateLoadState(@NonNull LoadState currencyExchangeLoadState) {
|
||||
return new SetCurrencyState(this.currentCurrency,
|
||||
this.currencyExchange,
|
||||
currencyExchangeLoadState,
|
||||
this.defaultCurrencies,
|
||||
this.otherCurrencies);
|
||||
}
|
||||
|
||||
public @NonNull SetCurrencyState updateCurrencyExchange(@NonNull CurrencyExchange currencyExchange) {
|
||||
List<Currency> currencies = currencyExchange.getSupportedCurrencies();
|
||||
|
||||
Collection<Currency> defaultCurrencies = SetUtil.intersection(currencies, DEFAULT_CURRENCIES);
|
||||
Collection<Currency> otherCurrencies = SetUtil.difference(currencies, defaultCurrencies);
|
||||
|
||||
return new SetCurrencyState(this.currentCurrency,
|
||||
currencyExchange,
|
||||
LoadState.LOADED,
|
||||
defaultCurrencies,
|
||||
otherCurrencies);
|
||||
}
|
||||
|
||||
public @NonNull SetCurrencyState updateCurrentCurrency(@NonNull Currency currentCurrency) {
|
||||
return new SetCurrencyState(currentCurrency,
|
||||
this.currencyExchange,
|
||||
this.currencyExchangeLoadState,
|
||||
this.defaultCurrencies,
|
||||
this.otherCurrencies);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new SetCurrencyViewModel(new CurrencyExchangeRepository(ApplicationDependencies.getPayments())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.addmoney;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class AddressAndUri {
|
||||
private final String addressB58;
|
||||
private final Uri uri;
|
||||
|
||||
AddressAndUri(@NonNull String addressB58, @NonNull Uri uri) {
|
||||
this.addressB58 = addressB58;
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public String getAddressB58() {
|
||||
return addressB58;
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.addmoney;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.qr.QrView;
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
|
||||
|
||||
public final class PaymentsAddMoneyFragment extends LoggingFragment {
|
||||
|
||||
public PaymentsAddMoneyFragment() {
|
||||
super(R.layout.payments_add_money_fragment);
|
||||
}
|
||||
|
||||
@Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
PaymentsAddMoneyViewModel viewModel = ViewModelProviders.of(this, new PaymentsAddMoneyViewModel.Factory()).get(PaymentsAddMoneyViewModel.class);
|
||||
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_add_money_toolbar);
|
||||
QrView qrImageView = view.findViewById(R.id.payments_add_money_qr_image);
|
||||
TextView walletAddressAbbreviated = view.findViewById(R.id.payments_add_money_abbreviated_wallet_address);
|
||||
View copyAddress = view.findViewById(R.id.payments_add_money_copy_address_button);
|
||||
LearnMoreTextView info = view.findViewById(R.id.payments_add_money_info);
|
||||
|
||||
info.setLearnMoreVisible(true);
|
||||
info.setLink(getString(R.string.PaymentsAddMoneyFragment__learn_more__information));
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
viewModel.getSelfAddressAbbreviated().observe(getViewLifecycleOwner(), walletAddressAbbreviated::setText);
|
||||
|
||||
viewModel.getSelfAddressB58().observe(getViewLifecycleOwner(), base58 -> copyAddress.setOnClickListener(v -> copyAddressToClipboard(base58)));
|
||||
|
||||
// Note we are choosing to put Base58 directly into QR here
|
||||
viewModel.getSelfAddressB58().observe(getViewLifecycleOwner(), qrImageView::setQrText);
|
||||
|
||||
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
|
||||
switch (error) {
|
||||
case PAYMENTS_NOT_ENABLED: throw new AssertionError("Payments are not enabled");
|
||||
default : throw new AssertionError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void copyAddressToClipboard(@NonNull String base58) {
|
||||
Context context = requireContext();
|
||||
ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.app_name), base58));
|
||||
|
||||
Toast.makeText(context, R.string.PaymentsAddMoneyFragment__copied_to_clipboard, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.addmoney;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
|
||||
final class PaymentsAddMoneyRepository {
|
||||
|
||||
@MainThread
|
||||
void getWalletAddress(@NonNull AsynchronousCallback.MainThread<AddressAndUri, Error> callback) {
|
||||
if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) {
|
||||
callback.onError(Error.PAYMENTS_NOT_ENABLED);
|
||||
}
|
||||
|
||||
MobileCoinPublicAddress publicAddress = ApplicationDependencies.getPayments().getWallet().getMobileCoinPublicAddress();
|
||||
String paymentAddressBase58 = publicAddress.getPaymentAddressBase58();
|
||||
Uri paymentAddressUri = publicAddress.getPaymentAddressUri();
|
||||
|
||||
callback.onComplete(new AddressAndUri(paymentAddressBase58, paymentAddressUri));
|
||||
}
|
||||
|
||||
enum Error {
|
||||
PAYMENTS_NOT_ENABLED
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.addmoney;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
|
||||
final class PaymentsAddMoneyViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<AddressAndUri> selfAddressAndUri = new MutableLiveData<>();
|
||||
private final MutableLiveData<PaymentsAddMoneyRepository.Error> errors = new MutableLiveData<>();
|
||||
private final LiveData<Uri> selfAddressUri;
|
||||
private final LiveData<String> selfAddressB58;
|
||||
private final LiveData<CharSequence> selfAddressAbbreviated;
|
||||
|
||||
PaymentsAddMoneyViewModel(@NonNull PaymentsAddMoneyRepository paymentsAddMoneyRepository) {
|
||||
paymentsAddMoneyRepository.getWalletAddress(new AsynchronousCallback.MainThread<AddressAndUri, PaymentsAddMoneyRepository.Error>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable AddressAndUri result) {
|
||||
selfAddressAndUri.setValue(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable PaymentsAddMoneyRepository.Error error) {
|
||||
errors.setValue(error);
|
||||
}
|
||||
});
|
||||
|
||||
selfAddressB58 = Transformations.map(selfAddressAndUri, AddressAndUri::getAddressB58);
|
||||
selfAddressUri = Transformations.map(selfAddressAndUri, AddressAndUri::getUri);
|
||||
selfAddressAbbreviated = Transformations.map(selfAddressB58, longAddress -> StringUtil.abbreviateInMiddle(longAddress, 17));
|
||||
}
|
||||
|
||||
LiveData<String> getSelfAddressB58() {
|
||||
return selfAddressB58;
|
||||
}
|
||||
|
||||
LiveData<CharSequence> getSelfAddressAbbreviated() {
|
||||
return selfAddressAbbreviated;
|
||||
}
|
||||
|
||||
LiveData<PaymentsAddMoneyRepository.Error> getErrors() {
|
||||
return errors;
|
||||
}
|
||||
|
||||
LiveData<Uri> getSelfAddressUriForQr() {
|
||||
return selfAddressUri;
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new PaymentsAddMoneyViewModel(new PaymentsAddMoneyRepository()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.details;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.TextAppearanceSpan;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.constraintlayout.widget.Group;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.payments.Direction;
|
||||
import org.thoughtcrime.securesms.payments.MoneyView;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.State;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class PaymentDetailsFragment extends LoggingFragment {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentDetailsFragment.class);
|
||||
|
||||
public PaymentDetailsFragment() {
|
||||
super(R.layout.payment_details_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_details_toolbar);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
|
||||
|
||||
PaymentDetailsParcelable details = PaymentDetailsFragmentArgs.fromBundle(requireArguments()).getPaymentDetails();
|
||||
|
||||
AvatarImageView avatar = view.findViewById(R.id.payments_details_avatar);
|
||||
TextView contactFromTo = view.findViewById(R.id.payments_details_contact_to_from);
|
||||
MoneyView amount = view.findViewById(R.id.payments_details_amount);
|
||||
TextView note = view.findViewById(R.id.payments_details_note);
|
||||
TextView status = view.findViewById(R.id.payments_details_status);
|
||||
View sentByHeader = view.findViewById(R.id.payments_details_sent_by_header);
|
||||
TextView sentBy = view.findViewById(R.id.payments_details_sent_by);
|
||||
LearnMoreTextView transactionInfo = view.findViewById(R.id.payments_details_info);
|
||||
TextView sentTo = view.findViewById(R.id.payments_details_sent_to_header);
|
||||
MoneyView sentToAmount = view.findViewById(R.id.payments_details_sent_to_amount);
|
||||
View sentFeeHeader = view.findViewById(R.id.payments_details_sent_fee_header);
|
||||
MoneyView sentFeeAmount = view.findViewById(R.id.payments_details_sent_fee_amount);
|
||||
Group sentViews = view.findViewById(R.id.payments_details_sent_views);
|
||||
View blockHeader = view.findViewById(R.id.payments_details_block_header);
|
||||
TextView blockNumber = view.findViewById(R.id.payments_details_block);
|
||||
|
||||
if (details.hasPayment()) {
|
||||
Payment payment = details.requirePayment();
|
||||
avatar.disableQuickContact();
|
||||
avatar.setImageResource(R.drawable.ic_mobilecoin_avatar_24);
|
||||
contactFromTo.setText(getContactFromToTextFromDirection(payment.getDirection()));
|
||||
amount.setMoney(payment.getAmountPlusFeeWithDirection());
|
||||
note.setVisibility(View.GONE);
|
||||
status.setText(getStatusFromPayment(payment));
|
||||
sentByHeader.setVisibility(View.GONE);
|
||||
sentBy.setVisibility(View.GONE);
|
||||
transactionInfo.setLearnMoreVisible(true);
|
||||
transactionInfo.setText(R.string.PaymentsDetailsFragment__information);
|
||||
transactionInfo.setLink(getString(R.string.PaymentsDetailsFragment__learn_more__information));
|
||||
sentTo.setVisibility(View.GONE);
|
||||
sentToAmount.setVisibility(View.GONE);
|
||||
blockHeader.setVisibility(View.VISIBLE);
|
||||
blockNumber.setVisibility(View.VISIBLE);
|
||||
blockNumber.setText(String.valueOf(payment.getBlockIndex()));
|
||||
|
||||
if (payment.getDirection() == Direction.SENT) {
|
||||
sentFeeAmount.setMoney(payment.getFee());
|
||||
sentFeeHeader.setVisibility(View.VISIBLE);
|
||||
sentFeeAmount.setVisibility(View.VISIBLE);
|
||||
}
|
||||
} else {
|
||||
PaymentsDetailsViewModel viewModel = ViewModelProviders.of(this, new PaymentsDetailsViewModel.Factory(details.requireUuid())).get(PaymentsDetailsViewModel.class);
|
||||
viewModel.getViewState()
|
||||
.observe(getViewLifecycleOwner(),
|
||||
state -> {
|
||||
if (state.getRecipient().getId().isUnknown() || state.getPayment().isDefrag()) {
|
||||
avatar.disableQuickContact();
|
||||
avatar.setImageResource(R.drawable.ic_mobilecoin_avatar_24);
|
||||
} else {
|
||||
avatar.setRecipient(state.getRecipient(), true);
|
||||
}
|
||||
contactFromTo.setText(describeToOrFrom(state));
|
||||
|
||||
if (state.getPayment().getState() == State.FAILED) {
|
||||
amount.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_primary_disabled));
|
||||
amount.setMoney(state.getPayment().getAmountPlusFeeWithDirection(), false);
|
||||
transactionInfo.setVisibility(View.GONE);
|
||||
} else {
|
||||
amount.setMoney(state.getPayment().getAmountPlusFeeWithDirection());
|
||||
if (state.getPayment().isDefrag()) {
|
||||
transactionInfo.setLearnMoreVisible(true);
|
||||
transactionInfo.setText(R.string.PaymentsDetailsFragment__coin_cleanup_information);
|
||||
transactionInfo.setLink(getString(R.string.PaymentsDetailsFragment__learn_more__cleanup_fee));
|
||||
} else {
|
||||
transactionInfo.setLearnMoreVisible(true);
|
||||
transactionInfo.setText(R.string.PaymentsDetailsFragment__information);
|
||||
transactionInfo.setLink(getString(R.string.PaymentsDetailsFragment__learn_more__information));
|
||||
}
|
||||
transactionInfo.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
String trimmedNote = state.getPayment().getNote().trim();
|
||||
note.setText(trimmedNote);
|
||||
note.setVisibility(TextUtils.isEmpty(trimmedNote) ? View.GONE : View.VISIBLE);
|
||||
status.setText(describeStatus(state.getPayment()));
|
||||
sentBy.setText(describeSentBy(state));
|
||||
if (state.getPayment().getDirection().isReceived()) {
|
||||
sentToAmount.setMoney(Money.MobileCoin.ZERO);
|
||||
sentFeeAmount.setMoney(Money.MobileCoin.ZERO);
|
||||
sentViews.setVisibility(View.GONE);
|
||||
} else {
|
||||
sentTo.setText(describeSentTo(state, state.getPayment()));
|
||||
sentToAmount.setMoney(state.getPayment().getAmount());
|
||||
sentFeeAmount.setMoney(state.getPayment().getFee());
|
||||
sentViews.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
viewModel.getPaymentExists()
|
||||
.observe(getViewLifecycleOwner(), exists -> {
|
||||
if (!exists) {
|
||||
Log.w(TAG, "Failed to find payment detail");
|
||||
FragmentActivity fragmentActivity = requireActivity();
|
||||
fragmentActivity.onBackPressed();
|
||||
Toast.makeText(fragmentActivity, R.string.PaymentsDetailsFragment__no_details_available, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence describeToOrFrom(PaymentsDetailsViewModel.ViewState state) {
|
||||
if (state.getPayment().isDefrag()) {
|
||||
return getString(R.string.PaymentsDetailsFragment__coin_cleanup_fee);
|
||||
}
|
||||
SpannableStringBuilder stringBuilder = new SpannableStringBuilder();
|
||||
switch (state.getPayment().getDirection()) {
|
||||
case SENT:
|
||||
stringBuilder.append(getString(R.string.PaymentsDetailsFragment__to));
|
||||
break;
|
||||
case RECEIVED:
|
||||
stringBuilder.append(getString(R.string.PaymentsDetailsFragment__from));
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
stringBuilder.append(' ').append(describe(state.getPayment().getPayee(), state.getRecipient()));
|
||||
return stringBuilder;
|
||||
}
|
||||
|
||||
private @NonNull CharSequence describe(@NonNull Payee payee, @NonNull Recipient recipient) {
|
||||
if (payee.hasRecipientId()) {
|
||||
return recipient.getDisplayName(requireContext());
|
||||
} else if (payee.hasPublicAddress()) {
|
||||
return mono(requireContext(), Objects.requireNonNull(StringUtil.abbreviateInMiddle(payee.requirePublicAddress().getPaymentAddressBase58(), 17)));
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull CharSequence mono(@NonNull Context context, @NonNull CharSequence address) {
|
||||
SpannableString spannable = new SpannableString(address);
|
||||
spannable.setSpan(new TextAppearanceSpan(context, R.style.TextAppearance_Signal_Mono),
|
||||
0,
|
||||
address.length(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
private CharSequence describeSentBy(PaymentsDetailsViewModel.ViewState state) {
|
||||
switch (state.getPayment().getDirection()) {
|
||||
case SENT:
|
||||
return getResources().getString(R.string.PaymentsDetailsFragment__you_on_s_at_s, state.getDate(), state.getTime(requireContext()));
|
||||
case RECEIVED:
|
||||
return SpanUtil.replacePlaceHolder(getResources().getString(R.string.PaymentsDetailsFragment__s_on_s_at_s, SpanUtil.SPAN_PLACE_HOLDER, state.getDate(), state.getTime(requireContext())),
|
||||
describe(state.getPayment().getPayee(), state.getRecipient()));
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull CharSequence describeSentTo(@NonNull PaymentsDetailsViewModel.ViewState state, @NonNull PaymentDatabase.PaymentTransaction payment) {
|
||||
if (payment.getDirection().isSent()) {
|
||||
return SpanUtil.insertSingleSpan(getResources(), R.string.PaymentsDetailsFragment__sent_to_s, describe(payment.getPayee(), state.getRecipient()));
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull CharSequence describeStatus(@NonNull PaymentDatabase.PaymentTransaction payment) {
|
||||
switch (payment.getState()) {
|
||||
case INITIAL:
|
||||
return getResources().getString(R.string.PaymentsDetailsFragment__submitting_payment);
|
||||
case SUBMITTED:
|
||||
return getResources().getString(R.string.PaymentsDetailsFragment__processing_payment);
|
||||
case SUCCESSFUL:
|
||||
return getResources().getString(R.string.PaymentsDetailsFragment__payment_complete);
|
||||
case FAILED:
|
||||
return SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary), getResources().getString(R.string.PaymentsDetailsFragment__payment_failed));
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull CharSequence getContactFromToTextFromDirection(@NonNull Direction direction) {
|
||||
switch (direction) {
|
||||
case SENT:
|
||||
return getResources().getString(R.string.PaymentsDetailsFragment__sent_payment);
|
||||
case RECEIVED:
|
||||
return getResources().getString(R.string.PaymentsDetailsFragment__received_payment);
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull CharSequence getStatusFromPayment(@NonNull Payment payment) {
|
||||
return getResources().getString(R.string.PaymentsDeatilsFragment__payment_completed_s, DateUtils.getTimeString(requireContext(), Locale.getDefault(), payment.getDisplayTimestamp()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.details;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.PaymentParcelable;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Argument for PaymentDetailsFragment which takes EITHER a Payment OR a UUID, never both.
|
||||
*/
|
||||
public class PaymentDetailsParcelable implements Parcelable {
|
||||
|
||||
private static final int TYPE_PAYMENT = 0;
|
||||
private static final int TYPE_UUID = 1;
|
||||
|
||||
private final Payment payment;
|
||||
private final UUID uuid;
|
||||
|
||||
private PaymentDetailsParcelable(@Nullable Payment payment, @Nullable UUID uuid) {
|
||||
if ((uuid == null) == (payment == null)) {
|
||||
throw new IllegalStateException("Must have exactly one of uuid or payment.");
|
||||
}
|
||||
this.payment = payment;
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
if (payment != null) {
|
||||
dest.writeInt(TYPE_PAYMENT);
|
||||
dest.writeParcelable(new PaymentParcelable(payment), flags);
|
||||
} else {
|
||||
dest.writeInt(TYPE_UUID);
|
||||
dest.writeString(uuid.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<PaymentDetailsParcelable> CREATOR = new Creator<PaymentDetailsParcelable>() {
|
||||
@Override
|
||||
public PaymentDetailsParcelable createFromParcel(Parcel in) {
|
||||
int type = in.readInt();
|
||||
switch (type) {
|
||||
case TYPE_UUID : return forUuid(UUID.fromString(in.readString()));
|
||||
case TYPE_PAYMENT: return forPayment(in.<PaymentParcelable>readParcelable(PaymentParcelable.class.getClassLoader()).getPayment());
|
||||
default : throw new IllegalStateException("Unexpected parcel type " + type);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentDetailsParcelable[] newArray(int size) {
|
||||
return new PaymentDetailsParcelable[size];
|
||||
}
|
||||
};
|
||||
|
||||
public boolean hasPayment() {
|
||||
return payment != null;
|
||||
}
|
||||
|
||||
public @NonNull Payment requirePayment() {
|
||||
return Objects.requireNonNull(payment);
|
||||
}
|
||||
|
||||
public @NonNull UUID requireUuid() {
|
||||
if (uuid != null) return uuid;
|
||||
else return requirePayment().getUuid();
|
||||
}
|
||||
|
||||
public static PaymentDetailsParcelable forUuid(@NonNull UUID uuid) {
|
||||
return new PaymentDetailsParcelable(null, uuid);
|
||||
}
|
||||
|
||||
public static PaymentDetailsParcelable forPayment(@NonNull Payment payment) {
|
||||
return new PaymentDetailsParcelable(payment, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.details;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.database.PaymentDatabase;
|
||||
import org.thoughtcrime.securesms.payments.PaymentTransactionLiveData;
|
||||
import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
final class PaymentsDetailsViewModel extends ViewModel {
|
||||
|
||||
private final LiveData<ViewState> viewState;
|
||||
private final LiveData<Boolean> paymentExists;
|
||||
|
||||
PaymentsDetailsViewModel(@NonNull UUID paymentId) {
|
||||
PaymentTransactionLiveData source = new PaymentTransactionLiveData(paymentId);
|
||||
|
||||
LiveData<Recipient> recipientLiveData = Transformations.switchMap(source,
|
||||
payment -> payment != null && payment.getPayee().hasRecipientId() ? Recipient.live(payment.getPayee().requireRecipientId()).getLiveData()
|
||||
: LiveDataUtil.just(Recipient.UNKNOWN));
|
||||
|
||||
this.viewState = LiveDataUtil.combineLatest(source, recipientLiveData, ViewState::new);
|
||||
this.paymentExists = Transformations.map(source, s -> s != null);
|
||||
|
||||
new UnreadPaymentsRepository().markPaymentSeen(paymentId);
|
||||
}
|
||||
|
||||
LiveData<ViewState> getViewState() {
|
||||
return viewState;
|
||||
}
|
||||
|
||||
LiveData<Boolean> getPaymentExists() {
|
||||
return paymentExists;
|
||||
}
|
||||
|
||||
static class ViewState {
|
||||
|
||||
private final PaymentDatabase.PaymentTransaction payment;
|
||||
private final Recipient recipient;
|
||||
|
||||
private ViewState(@NonNull PaymentDatabase.PaymentTransaction payment, @NonNull Recipient recipient) {
|
||||
this.payment = payment;
|
||||
this.recipient = recipient;
|
||||
}
|
||||
|
||||
Recipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
PaymentDatabase.PaymentTransaction getPayment() {
|
||||
return payment;
|
||||
}
|
||||
|
||||
String getDate() {
|
||||
return DateUtils.formatDate(Locale.getDefault(), payment.getDisplayTimestamp());
|
||||
}
|
||||
|
||||
String getTime(@NonNull Context context) {
|
||||
return DateUtils.getTimeString(context, Locale.getDefault(), payment.getDisplayTimestamp());
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
private final UUID paymentId;
|
||||
|
||||
public Factory(@NonNull UUID paymentId) {
|
||||
this.paymentId = paymentId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new PaymentsDetailsViewModel(paymentId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
|
||||
public class InProgress implements MappingModel<InProgress> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull InProgress newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull InProgress newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.PaymentsValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class InfoCard implements MappingModel<InfoCard> {
|
||||
|
||||
private final @StringRes int titleId;
|
||||
private final @StringRes int messageId;
|
||||
private final @StringRes int actionId;
|
||||
private final @DrawableRes int iconId;
|
||||
private final Type type;
|
||||
private final Runnable dismiss;
|
||||
|
||||
private InfoCard(@StringRes int titleId,
|
||||
@StringRes int messageId,
|
||||
@StringRes int actionId,
|
||||
@DrawableRes int iconId,
|
||||
@NonNull Type type,
|
||||
@NonNull Runnable dismiss)
|
||||
{
|
||||
this.titleId = titleId;
|
||||
this.messageId = messageId;
|
||||
this.actionId = actionId;
|
||||
this.iconId = iconId;
|
||||
this.type = type;
|
||||
this.dismiss = dismiss;
|
||||
}
|
||||
|
||||
public @StringRes int getTitleId() {
|
||||
return titleId;
|
||||
}
|
||||
|
||||
public @StringRes int getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public @StringRes int getActionId() {
|
||||
return actionId;
|
||||
}
|
||||
|
||||
public @NonNull Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public @DrawableRes int getIconId() {
|
||||
return iconId;
|
||||
}
|
||||
|
||||
public void dismiss() {
|
||||
dismiss.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull InfoCard newItem) {
|
||||
return newItem.type == type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull InfoCard newItem) {
|
||||
return newItem.titleId == titleId &&
|
||||
newItem.messageId == messageId &&
|
||||
newItem.actionId == actionId &&
|
||||
newItem.iconId == iconId &&
|
||||
newItem.type == type;
|
||||
}
|
||||
|
||||
public static @NonNull List<InfoCard> getInfoCards() {
|
||||
List<InfoCard> infoCards = new ArrayList<>(Type.values().length);
|
||||
PaymentsValues paymentsValues = SignalStore.paymentsValues();
|
||||
|
||||
if (paymentsValues.showRecoveryPhraseInfoCard()) {
|
||||
infoCards.add(new InfoCard(R.string.payment_info_card_record_recovery_phrase,
|
||||
R.string.payment_info_card_your_recovery_phrase_gives_you,
|
||||
R.string.payment_info_card_record_your_phrase,
|
||||
R.drawable.ic_payments_info_card_restore_80,
|
||||
Type.RECORD_RECOVERY_PHASE,
|
||||
paymentsValues::dismissRecoveryPhraseInfoCard));
|
||||
}
|
||||
|
||||
if (paymentsValues.showUpdatePinInfoCard()) {
|
||||
infoCards.add(new InfoCard(R.string.payment_info_card_update_your_pin,
|
||||
R.string.payment_info_card_with_a_high_balance,
|
||||
R.string.payment_info_card_update_pin,
|
||||
R.drawable.ic_payments_info_card_pin_80,
|
||||
Type.UPDATE_YOUR_PIN,
|
||||
paymentsValues::dismissUpdatePinInfoCard));
|
||||
}
|
||||
|
||||
if (paymentsValues.showAboutMobileCoinInfoCard()) {
|
||||
infoCards.add(new InfoCard(R.string.payment_info_card_about_mobilecoin,
|
||||
R.string.payment_info_card_mobilecoin_is_a_new_privacy_focused_digital_currency,
|
||||
R.string.LearnMoreTextView_learn_more,
|
||||
R.drawable.ic_about_mc_80,
|
||||
Type.ABOUT_MOBILECOIN,
|
||||
paymentsValues::dismissAboutMobileCoinInfoCard));
|
||||
}
|
||||
|
||||
if (paymentsValues.showAddingToYourWalletInfoCard()) {
|
||||
infoCards.add(new InfoCard(R.string.payment_info_card_adding_funds,
|
||||
R.string.payment_info_card_you_can_add_funds_for_use_in,
|
||||
R.string.LearnMoreTextView_learn_more,
|
||||
R.drawable.ic_add_money_80,
|
||||
Type.ADDING_TO_YOUR_WALLET,
|
||||
paymentsValues::dismissAddingToYourWalletInfoCard));
|
||||
}
|
||||
|
||||
if (paymentsValues.showCashingOutInfoCard()) {
|
||||
infoCards.add(new InfoCard(R.string.payment_info_card_cashing_out,
|
||||
R.string.payment_info_card_you_can_cash_out_mobilecoin,
|
||||
R.string.LearnMoreTextView_learn_more,
|
||||
R.drawable.ic_cash_out_80,
|
||||
Type.CASHING_OUT,
|
||||
paymentsValues::dismissCashingOutInfoCard));
|
||||
}
|
||||
|
||||
return infoCards;
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
RECORD_RECOVERY_PHASE,
|
||||
UPDATE_YOUR_PIN,
|
||||
ABOUT_MOBILECOIN,
|
||||
ADDING_TO_YOUR_WALLET,
|
||||
CASHING_OUT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsHomeState;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
|
||||
public class IntroducingPayments implements MappingModel<IntroducingPayments> {
|
||||
private PaymentsHomeState.PaymentsState paymentsState;
|
||||
|
||||
public IntroducingPayments(@NonNull PaymentsHomeState.PaymentsState paymentsState) {
|
||||
this.paymentsState = paymentsState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull IntroducingPayments newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull IntroducingPayments newItem) {
|
||||
return this.paymentsState == newItem.paymentsState;
|
||||
}
|
||||
|
||||
public boolean isActivating() {
|
||||
return paymentsState == PaymentsHomeState.PaymentsState.ACTIVATING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
|
||||
public class NoRecentActivity implements MappingModel<NoRecentActivity> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull NoRecentActivity newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull NoRecentActivity newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.payments.Payee;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
public final class PayeeParcelable implements Parcelable {
|
||||
private final Payee payee;
|
||||
|
||||
public PayeeParcelable(@NonNull Payee payee) {
|
||||
this.payee = payee;
|
||||
}
|
||||
|
||||
public PayeeParcelable(@NonNull RecipientId recipientId) {
|
||||
this(new Payee(recipientId));
|
||||
}
|
||||
|
||||
public PayeeParcelable(@NonNull RecipientId recipientId, @NonNull MobileCoinPublicAddress address) {
|
||||
this(Payee.fromRecipientAndAddress(recipientId, address));
|
||||
}
|
||||
|
||||
public PayeeParcelable(@NonNull MobileCoinPublicAddress publicAddress) {
|
||||
this(new Payee(publicAddress));
|
||||
}
|
||||
|
||||
public Payee getPayee() {
|
||||
return payee;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof PayeeParcelable)) return false;
|
||||
|
||||
PayeeParcelable other = (PayeeParcelable) o;
|
||||
return payee.equals(other.payee);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return payee.hashCode();
|
||||
}
|
||||
|
||||
private static final int UNKNOWN = 0;
|
||||
private static final int CONTAINS_RECIPIENT_ID = 1;
|
||||
private static final int CONTAINS_ADDRESS = 2;
|
||||
private static final int CONTAINS_RECIPIENT_ID_AND_ADDRESS = 3;
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
if (payee.hasRecipientId()) {
|
||||
if (payee.hasPublicAddress()) {
|
||||
dest.writeInt(CONTAINS_RECIPIENT_ID_AND_ADDRESS);
|
||||
dest.writeParcelable(payee.requireRecipientId(), flags);
|
||||
dest.writeString(payee.requirePublicAddress().getPaymentAddressBase58());
|
||||
} else {
|
||||
dest.writeInt(CONTAINS_RECIPIENT_ID);
|
||||
dest.writeParcelable(payee.requireRecipientId(), flags);
|
||||
}
|
||||
} else if (payee.hasPublicAddress()) {
|
||||
dest.writeInt(CONTAINS_ADDRESS);
|
||||
dest.writeString(payee.requirePublicAddress().getPaymentAddressBase58());
|
||||
} else {
|
||||
dest.writeInt(UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<PayeeParcelable> CREATOR = new Creator<PayeeParcelable>() {
|
||||
@Override
|
||||
public @NonNull PayeeParcelable createFromParcel(@NonNull Parcel in) {
|
||||
|
||||
switch (in.readInt()) {
|
||||
case UNKNOWN: {
|
||||
return new PayeeParcelable(Payee.UNKNOWN);
|
||||
}
|
||||
case CONTAINS_RECIPIENT_ID: {
|
||||
RecipientId recipientId = in.readParcelable(RecipientId.class.getClassLoader());
|
||||
return new PayeeParcelable(new Payee(recipientId));
|
||||
}
|
||||
case CONTAINS_RECIPIENT_ID_AND_ADDRESS: {
|
||||
RecipientId recipientId = in.readParcelable(RecipientId.class.getClassLoader());
|
||||
MobileCoinPublicAddress publicAddress = MobileCoinPublicAddress.fromBase58OrThrow(in.readString());
|
||||
return new PayeeParcelable(Payee.fromRecipientAndAddress(recipientId, publicAddress));
|
||||
}
|
||||
case CONTAINS_ADDRESS: {
|
||||
return new PayeeParcelable(new Payee(MobileCoinPublicAddress.fromBase58OrThrow(in.readString())));
|
||||
}
|
||||
default: {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull PayeeParcelable[] newArray(int size) {
|
||||
return new PayeeParcelable[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.payments.ReconstructedPayment;
|
||||
import org.thoughtcrime.securesms.payments.State;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentType;
|
||||
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.PaymentsConstants;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel.RecipientIdMappingModel;
|
||||
|
||||
public final class PaymentItem implements MappingModel<PaymentItem> {
|
||||
|
||||
private final Payment payment;
|
||||
private final PaymentType paymentType;
|
||||
|
||||
public static @NonNull MappingModelList fromPayment(@NonNull List<Payment> transactions) {
|
||||
return Stream.of(transactions)
|
||||
.map(PaymentItem::fromPayment)
|
||||
.collect(MappingModelList.toMappingModelList());
|
||||
}
|
||||
|
||||
public static @NonNull PaymentItem fromPayment(@NonNull Payment transaction) {
|
||||
return new PaymentItem(transaction,
|
||||
PaymentType.PAYMENT);
|
||||
}
|
||||
|
||||
private PaymentItem(@NonNull Payment payment,
|
||||
@NonNull PaymentType paymentType)
|
||||
{
|
||||
this.payment = payment;
|
||||
this.paymentType = paymentType;
|
||||
}
|
||||
|
||||
public @NonNull PaymentDetailsParcelable getPaymentDetailsParcelable() {
|
||||
if (payment instanceof ReconstructedPayment) {
|
||||
return PaymentDetailsParcelable.forPayment(payment);
|
||||
} else {
|
||||
return PaymentDetailsParcelable.forUuid(payment.getUuid());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isInProgress() {
|
||||
return payment.getState().isInProgress();
|
||||
}
|
||||
|
||||
public boolean isUnread() {
|
||||
return !payment.isSeen();
|
||||
}
|
||||
|
||||
public @NonNull CharSequence getDate(@NonNull Context context) {
|
||||
if (isInProgress()) {
|
||||
return context.getString(R.string.PaymentsHomeFragment__processing_payment);
|
||||
}
|
||||
|
||||
if (payment.getState() == State.FAILED) {
|
||||
return SpanUtil.color(ContextCompat.getColor(context, R.color.signal_alert_primary), context.getString(R.string.PaymentsHomeFragment__payment_failed));
|
||||
}
|
||||
|
||||
String date = DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), payment.getDisplayTimestamp());
|
||||
int prefix = payment.getDirection().isReceived() ? R.string.PaymentsHomeFragment__received_s : R.string.PaymentsHomeFragment__sent_s;
|
||||
|
||||
return context.getString(prefix, date);
|
||||
}
|
||||
|
||||
public @NonNull String getAmount(@NonNull Context context) {
|
||||
if (isInProgress() && payment.getDirection().isReceived()) {
|
||||
return context.getString(R.string.PaymentsHomeFragment__unknown_amount);
|
||||
}
|
||||
|
||||
if (payment.getState() == State.FAILED) {
|
||||
return context.getString(R.string.PaymentsHomeFragment__details);
|
||||
}
|
||||
|
||||
return payment.getAmountPlusFeeWithDirection()
|
||||
.toString(FormatterOptions.builder(Locale.getDefault())
|
||||
.alwaysPrefixWithSign()
|
||||
.withMaximumFractionDigits(PaymentsConstants.SHORT_FRACTION_LENGTH)
|
||||
.build());
|
||||
}
|
||||
|
||||
public @ColorRes int getAmountColor() {
|
||||
if (isInProgress()) {
|
||||
return R.color.signal_text_primary_disabled;
|
||||
} else if (payment.getState() == State.FAILED) {
|
||||
return R.color.signal_text_secondary;
|
||||
} else if (paymentType == PaymentType.REQUEST) {
|
||||
return R.color.core_grey_45;
|
||||
} else if (payment.getDirection().isReceived()) {
|
||||
return R.color.core_green;
|
||||
} else {
|
||||
return R.color.signal_text_primary;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isDefrag() {
|
||||
return payment.isDefrag();
|
||||
}
|
||||
|
||||
public boolean hasRecipient() {
|
||||
return payment.getPayee().hasRecipientId();
|
||||
}
|
||||
|
||||
public @Nullable String getTransactionName(@NonNull Context context) {
|
||||
return context.getString(payment.isDefrag() ? R.string.PaymentsHomeFragment__coin_cleanup_fee
|
||||
: payment.getDirection().isSent() ? R.string.PaymentsHomeFragment__sent_payment
|
||||
: R.string.PaymentsHomeFragment__received_payment);
|
||||
}
|
||||
|
||||
public @DrawableRes int getTransactionAvatar() {
|
||||
return R.drawable.ic_mobilecoin_avatar_24;
|
||||
}
|
||||
|
||||
public @NonNull RecipientIdMappingModel getRecipientIdModel() {
|
||||
return new RecipientIdMappingModel(payment.getPayee().requireRecipientId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull PaymentItem newItem) {
|
||||
return payment.getUuid().equals(newItem.payment.getUuid());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull PaymentItem newItem) {
|
||||
return payment.getDisplayTimestamp() == newItem.payment.getDisplayTimestamp() &&
|
||||
payment.getAmount().equals(newItem.payment.getAmount()) &&
|
||||
paymentType == newItem.paymentType &&
|
||||
payment.getDirection() == newItem.payment.getDirection() &&
|
||||
payment.getState() == newItem.payment.getState() &&
|
||||
Objects.equals(payment.getPayee(), newItem.payment.getPayee()) &&
|
||||
payment.isSeen() == newItem.payment.isSeen() &&
|
||||
payment.isDefrag() == newItem.payment.isDefrag();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentType;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
|
||||
public class SeeAll implements MappingModel<SeeAll> {
|
||||
|
||||
private final PaymentType paymentType;
|
||||
|
||||
public SeeAll(PaymentType paymentType) {
|
||||
this.paymentType = paymentType;
|
||||
}
|
||||
|
||||
public @NonNull PaymentType getPaymentType() {
|
||||
return paymentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull SeeAll newItem) {
|
||||
return paymentType == newItem.paymentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull SeeAll newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.payments.preferences.transfer;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.NavDirections;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
|
||||
import org.thoughtcrime.securesms.payments.preferences.model.PayeeParcelable;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public final class PaymentsTransferFragment extends LoggingFragment {
|
||||
|
||||
private static final String TAG = Log.tag(PaymentsTransferFragment.class);
|
||||
|
||||
private EditText address;
|
||||
|
||||
public PaymentsTransferFragment() {
|
||||
super(R.layout.payments_transfer_fragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
PaymentsTransferViewModel viewModel = new ViewModelProvider(Navigation.findNavController(view).getViewModelStoreOwner(R.id.payments_transfer), new PaymentsTransferViewModel.Factory()).get(PaymentsTransferViewModel.class);
|
||||
|
||||
Toolbar toolbar = view.findViewById(R.id.payments_transfer_toolbar);
|
||||
|
||||
view.findViewById(R.id.payments_transfer_scan_qr).setOnClickListener(v -> scanQrCode());
|
||||
view.findViewById(R.id.payments_transfer_next).setOnClickListener(v -> next(viewModel.getOwnAddress()));
|
||||
|
||||
address = view.findViewById(R.id.payments_transfer_to_address);
|
||||
address.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
return next(viewModel.getOwnAddress());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
viewModel.getAddress().observe(getViewLifecycleOwner(), address::setText);
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> {
|
||||
ViewUtil.hideKeyboard(requireContext(), v);
|
||||
Navigation.findNavController(v).popBackStack();
|
||||
});
|
||||
}
|
||||
|
||||
private boolean next(@NonNull MobileCoinPublicAddress ownAddress) {
|
||||
try {
|
||||
String base58Address = address.getText().toString();
|
||||
MobileCoinPublicAddress publicAddress = MobileCoinPublicAddress.fromBase58(base58Address);
|
||||
|
||||
if (ownAddress.equals(publicAddress)) {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsTransferFragment__invalid_address)
|
||||
.setMessage(R.string.PaymentsTransferFragment__you_cant_transfer_to_your_own_signal_wallet_address)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
return false;
|
||||
}
|
||||
|
||||
NavDirections action = PaymentsTransferFragmentDirections.actionPaymentsTransferToCreatePayment(new PayeeParcelable(publicAddress))
|
||||
.setFinishOnConfirm(PaymentsTransferFragmentArgs.fromBundle(requireArguments()).getFinishOnConfirm());
|
||||
|
||||
Navigation.findNavController(requireView()).navigate(action);
|
||||
return true;
|
||||
} catch (MobileCoinPublicAddress.AddressException e) {
|
||||
Log.w(TAG, "Address is not valid", e);
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.PaymentsTransferFragment__invalid_address)
|
||||
.setMessage(R.string.PaymentsTransferFragment__check_the_wallet_address)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void scanQrCode() {
|
||||
Permissions.with(requireActivity())
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs), R.drawable.ic_camera_24)
|
||||
.onAnyPermanentlyDenied(this::onCameraPermissionPermanentlyDenied)
|
||||
.onAllGranted(() -> Navigation.findNavController(requireView()).navigate(R.id.action_paymentsTransfer_to_paymentsScanQr))
|
||||
.onAnyDenied(() -> Toast.makeText(requireContext(), R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs_access_to_the_camera, Toast.LENGTH_LONG).show())
|
||||
.execute();
|
||||
}
|
||||
|
||||
private void onCameraPermissionPermanentlyDenied() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.Permissions_permission_required)
|
||||
.setMessage(R.string.PaymentsTransferFragment__signal_needs_the_camera_permission_to_capture_qr_code_go_to_settings)
|
||||
.setPositiveButton(R.string.PaymentsTransferFragment__settings, (dialog, which) -> requireActivity().startActivity(Permissions.getApplicationSettingsIntent(requireContext())))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user