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:
Android Team
2021-04-06 13:03:33 -03:00
committed by Alan Evans
parent c42023855b
commit fddba2906a
311 changed files with 18956 additions and 235 deletions

View File

@@ -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 +
'}';
}
}

View File

@@ -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();
}
}
}

View File

@@ -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];
}
};
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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), " "));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}

View File

@@ -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();
}
});
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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 {
}
}
}

View File

@@ -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
}
}

View File

@@ -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())));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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"));
}

View File

@@ -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();
});
}
}

View File

@@ -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
}
}

View File

@@ -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 +
'}';
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.payments.preferences;
public enum LoadState {
INITIAL,
LOADING,
LOADED,
ERROR
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.payments.preferences;
enum PaymentStateEvent {
NO_BALANCE,
DEACTIVATE_WITHOUT_BALANCE,
DEACTIVATE_WITH_BALANCE,
DEACTIVATED,
ACTIVATED
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}
}

View File

@@ -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() {}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()));
}
});
});
}
}

View File

@@ -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())));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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()));
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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];
}
};
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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