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

@@ -34,6 +34,10 @@ public final class CursorUtil {
return cursor.getBlob(cursor.getColumnIndexOrThrow(column));
}
public static boolean isNull(@NonNull Cursor cursor, @NonNull String column) {
return cursor.isNull(cursor.getColumnIndexOrThrow(column));
}
public static boolean requireMaskedBoolean(@NonNull Cursor cursor, @NonNull String column, int position) {
return Bitmask.read(requireLong(cursor, column), position);
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.util;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.NonNull;
@@ -50,6 +49,7 @@ public final class FeatureFlags {
private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2);
private static final String PAYMENTS_KILL_SWITCH = "android.payments.kill";
private static final String USERNAMES = "android.usernames";
private static final String GROUPS_V2_RECOMMENDED_LIMIT = "global.groupsv2.maxGroupSize";
private static final String GROUPS_V2_HARD_LIMIT = "global.groupsv2.groupSizeHardLimit";
@@ -81,6 +81,7 @@ public final class FeatureFlags {
*/
@VisibleForTesting
static final Set<String> REMOTE_CAPABLE = SetUtil.newHashSet(
PAYMENTS_KILL_SWITCH,
GROUPS_V2_RECOMMENDED_LIMIT,
GROUPS_V2_HARD_LIMIT,
INTERNAL_USER,
@@ -229,6 +230,11 @@ public final class FeatureFlags {
getInteger(GROUPS_V2_HARD_LIMIT, 1001));
}
/** Payments Support */
public static boolean payments() {
return !getBoolean(PAYMENTS_KILL_SWITCH, false);
}
/** Internal testing extensions. */
public static boolean internalUser() {
return getBoolean(INTERNAL_USER, false);

View File

@@ -7,8 +7,10 @@ import android.view.ViewGroup;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.whispersystems.libsignal.util.guava.Function;
@@ -63,12 +65,24 @@ public class MappingAdapter extends ListAdapter<MappingModel<?>, MappingViewHold
holder.onDetachedFromWindow();
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
if (recyclerView.getItemAnimator() != null && recyclerView.getItemAnimator().getClass() == DefaultItemAnimator.class) {
recyclerView.setItemAnimator(new NoCrossfadeChangeDefaultAnimator());
}
}
public <T extends MappingModel<T>> void registerFactory(Class<T> clazz, Factory<T> factory) {
int type = typeCount++;
factories.put(type, factory);
itemTypes.put(clazz, type);
}
public <T extends MappingModel<T>> void registerFactory(@NonNull Class<T> clazz, @NonNull Function<View, MappingViewHolder<T>> creator, @LayoutRes int layout) {
registerFactory(clazz, new LayoutFactory<>(creator, layout));
}
@Override
public int getItemViewType(int position) {
Integer type = itemTypes.get(getItem(position).getClass());

View File

@@ -25,4 +25,13 @@ public abstract class MappingViewHolder<Model extends MappingModel<Model>> exten
}
public abstract void bind(@NonNull Model model);
public static final class SimpleViewHolder<Model extends MappingModel<Model>> extends MappingViewHolder<Model> {
public SimpleViewHolder(@NonNull View itemView) {
super(itemView);
}
@Override
public void bind(@NonNull Model model) { }
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
/**
* Disable animations for changes to same item
*/
public class NoCrossfadeChangeDefaultAnimator extends DefaultItemAnimator {
@Override
public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, int fromX, int fromY, int toX, int toY) {
if (oldHolder == newHolder) {
if (oldHolder != null) {
dispatchChangeFinished(oldHolder, true);
}
} else {
if (oldHolder != null) {
dispatchChangeFinished(oldHolder, true);
}
if (newHolder != null) {
dispatchChangeFinished(newHolder, false);
}
}
return false;
}
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull List<Object> payloads) {
return true;
}
}

View File

@@ -7,18 +7,26 @@ import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddressProfileUtil;
import org.thoughtcrime.securesms.payments.PaymentsAddressException;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
@@ -33,6 +41,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
@@ -94,15 +103,85 @@ public final class ProfileUtil {
}
}
public static @Nullable String decryptName(@NonNull ProfileKey profileKey, @Nullable String encryptedName)
public static @Nullable String decryptString(@NonNull ProfileKey profileKey, @Nullable byte[] encryptedString)
throws InvalidCiphertextException, IOException
{
if (encryptedName == null) {
if (encryptedString == null) {
return null;
}
ProfileCipher profileCipher = new ProfileCipher(profileKey);
return new String(profileCipher.decryptName(Base64.decode(encryptedName)));
return profileCipher.decryptString(encryptedString);
}
public static @Nullable String decryptString(@NonNull ProfileKey profileKey, @Nullable String encryptedStringBase64)
throws InvalidCiphertextException, IOException
{
if (encryptedStringBase64 == null) {
return null;
}
return decryptString(profileKey, Base64.decode(encryptedStringBase64));
}
@WorkerThread
public static @NonNull MobileCoinPublicAddress getAddressForRecipient(@NonNull Recipient recipient)
throws InterruptedException, ExecutionException, PaymentsAddressException
{
ProfileKey profileKey;
try {
profileKey = getProfileKey(recipient);
} catch (IOException e) {
Log.w(TAG, "Profile key not available for " + recipient.getId());
throw new PaymentsAddressException(PaymentsAddressException.Code.NO_PROFILE_KEY);
}
ProfileAndCredential profileAndCredential = ProfileUtil.retrieveProfile(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE).get();
SignalServiceProfile profile = profileAndCredential.getProfile();
byte[] encryptedPaymentsAddress = profile.getPaymentAddress();
if (encryptedPaymentsAddress == null) {
Log.w(TAG, "Payments not enabled for " + recipient.getId());
throw new PaymentsAddressException(PaymentsAddressException.Code.NOT_ENABLED);
}
try {
IdentityKey identityKey = new IdentityKey(Base64.decode(profileAndCredential.getProfile().getIdentityKey()), 0);
ProfileCipher profileCipher = new ProfileCipher(profileKey);
byte[] decrypted = profileCipher.decryptWithLength(encryptedPaymentsAddress);
SignalServiceProtos.PaymentAddress paymentAddress = SignalServiceProtos.PaymentAddress.parseFrom(decrypted);
byte[] bytes = MobileCoinPublicAddressProfileUtil.verifyPaymentsAddress(paymentAddress, identityKey);
MobileCoinPublicAddress mobileCoinPublicAddress = MobileCoinPublicAddress.fromBytes(bytes);
if (mobileCoinPublicAddress == null) {
throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS);
}
return mobileCoinPublicAddress;
} catch (InvalidCiphertextException | IOException e) {
Log.w(TAG, "Could not decrypt payments address, ProfileKey may be outdated for " + recipient.getId(), e);
throw new PaymentsAddressException(PaymentsAddressException.Code.COULD_NOT_DECRYPT);
} catch (InvalidKeyException e) {
Log.w(TAG, "Could not verify payments address due to bad identity key " + recipient.getId(), e);
throw new PaymentsAddressException(PaymentsAddressException.Code.INVALID_ADDRESS_SIGNATURE);
}
}
private static ProfileKey getProfileKey(@NonNull Recipient recipient) throws IOException {
byte[] profileKeyBytes = recipient.getProfileKey();
if (profileKeyBytes == null) {
Log.w(TAG, "Profile key unknown for " + recipient.getId());
throw new IOException("No profile key");
}
ProfileKey profileKey;
try {
profileKey = new ProfileKey(profileKeyBytes);
} catch (InvalidInputException e) {
Log.w(TAG, "Profile key invalid for " + recipient.getId());
throw new IOException("Invalid profile key");
}
return profileKey;
}
/**
@@ -116,6 +195,7 @@ public final class ProfileUtil {
profileName,
Optional.fromNullable(Recipient.self().getAbout()).or(""),
Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""),
getSelfPaymentsAddressProtobuf(),
avatar);
}
}
@@ -131,10 +211,20 @@ public final class ProfileUtil {
Recipient.self().getProfileName(),
about,
emoji,
getSelfPaymentsAddressProtobuf(),
avatar);
}
}
/**
* Uploads the profile based on all state that's already written to disk.
*/
public static void uploadProfile(@NonNull Context context) throws IOException {
try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) {
uploadProfileWithAvatar(context, avatar);
}
}
/**
* Uploads the profile based on all state that's written to disk, except we'll use the provided
* avatar instead. This is useful when you want to ensure that the profile has been uploaded
@@ -145,39 +235,48 @@ public final class ProfileUtil {
Recipient.self().getProfileName(),
Optional.fromNullable(Recipient.self().getAbout()).or(""),
Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""),
getSelfPaymentsAddressProtobuf(),
avatar);
}
/**
* Uploads the profile based on all state that's already written to disk.
*/
public static void uploadProfile(@NonNull Context context) throws IOException {
try (StreamDetails avatar = AvatarHelper.getSelfProfileAvatarStream(context)) {
uploadProfile(context,
Recipient.self().getProfileName(),
Optional.fromNullable(Recipient.self().getAbout()).or(""),
Optional.fromNullable(Recipient.self().getAboutEmoji()).or(""),
avatar);
}
}
private static void uploadProfile(@NonNull Context context,
@NonNull ProfileName profileName,
@Nullable String about,
@Nullable String aboutEmoji,
@Nullable SignalServiceProtos.PaymentAddress paymentsAddress,
@Nullable StreamDetails avatar)
throws IOException
{
Log.d(TAG, "Uploading " + (!Util.isEmpty(about) ? "non-" : "") + "empty about.");
Log.d(TAG, "Uploading " + (!Util.isEmpty(aboutEmoji) ? "non-" : "") + "empty emoji.");
Log.d(TAG, "Uploading " + (paymentsAddress != null ? "non-" : "") + "empty payments address.");
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
String avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(), profileKey, profileName.serialize(), about, aboutEmoji, avatar).orNull();
String avatarPath = accountManager.setVersionedProfile(Recipient.self().getUuid().get(),
profileKey,
profileName.serialize(),
about,
aboutEmoji,
Optional.fromNullable(paymentsAddress),
avatar).orNull();
DatabaseFactory.getRecipientDatabase(context).setProfileAvatar(Recipient.self().getId(), avatarPath);
}
private static @Nullable SignalServiceProtos.PaymentAddress getSelfPaymentsAddressProtobuf() {
if (!SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) {
return null;
} else {
IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(ApplicationDependencies.getApplication());
MobileCoinPublicAddress publicAddress = ApplicationDependencies.getPayments()
.getWallet()
.getMobileCoinPublicAddress();
return MobileCoinPublicAddressProfileUtil.signPaymentsAddress(publicAddress.serialize(), identityKeyPair);
}
}
private static @NonNull ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(@NonNull SignalServiceAddress address,
@NonNull Optional<ProfileKey> profileKey,
@NonNull Optional<UnidentifiedAccess> unidentifiedAccess,

View File

@@ -1,11 +1,13 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
@@ -20,11 +22,16 @@ import android.text.style.StyleSpan;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
public class SpanUtil {
public final class SpanUtil {
private SpanUtil() {}
public static final String SPAN_PLACE_HOLDER = "<<<SPAN>>>";
public static CharSequence italic(CharSequence sequence) {
return italic(sequence, sequence.length());
@@ -116,4 +123,18 @@ public class SpanUtil {
return spannable;
}
public static @NonNull CharSequence insertSingleSpan(@NonNull Resources resources, @StringRes int res, @NonNull CharSequence span) {
return replacePlaceHolder(resources.getString(res, SPAN_PLACE_HOLDER), span);
}
public static CharSequence replacePlaceHolder(@NonNull String string, @NonNull CharSequence span) {
int index = string.indexOf(SpanUtil.SPAN_PLACE_HOLDER);
if (index == -1) {
return string;
}
SpannableStringBuilder builder = new SpannableStringBuilder(string);
builder.replace(index, index + SpanUtil.SPAN_PLACE_HOLDER.length(), span);
return builder;
}
}

View File

@@ -247,4 +247,22 @@ public final class StringUtil {
}
return (startIndex > 0 || length < text.length()) ? text.subSequence(startIndex, length) : text;
}
/**
* If the {@param text} exceeds the {@param maxChars} it is trimmed in the middle so that the result is exactly {@param maxChars} long including an added
* ellipsis character.
* <p>
* Otherwise the string is returned untouched.
* <p>
* When {@param maxChars} is even, one more character is kept from the end of the string than the start.
*/
public static @Nullable CharSequence abbreviateInMiddle(@Nullable CharSequence text, int maxChars) {
if (text == null || text.length() <= maxChars) {
return text;
}
int start = (maxChars - 1) / 2;
int end = (maxChars - 1) - start;
return text.subSequence(0, start) + "" + text.subSequence(text.length() - end, text.length());
}
}

View File

@@ -18,6 +18,7 @@ import org.whispersystems.libsignal.util.guava.Function;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executor;
@@ -178,6 +179,14 @@ public final class LiveDataUtil {
};
}
public static <T> LiveData<T> never() {
return new MutableLiveData<>();
}
public static <T, R> LiveData<T> distinctUntilChanged(@NonNull LiveData<T> source, @NonNull Function<T, R> selector) {
return LiveDataUtil.distinctUntilChanged(source, (current, next) -> Objects.equals(selector.apply(current), selector.apply(next)));
}
public static <T> LiveData<T> distinctUntilChanged(@NonNull LiveData<T> source, @NonNull EqualityChecker<T> checker) {
final MediatorLiveData<T> outputLiveData = new MediatorLiveData<>();
outputLiveData.addSource(source, new Observer<T>() {
@@ -226,6 +235,10 @@ public final class LiveDataUtil {
@NonNull R apply(@NonNull A a, @NonNull B b);
}
public interface Combine3<A, B, C, R> {
@NonNull R apply(@NonNull A a, @NonNull B b, @NonNull C c);
}
public interface EqualityChecker<T> {
boolean contentsMatch(@NonNull T current, @NonNull T next);
}

View File

@@ -6,6 +6,7 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.MappingModel;
import java.util.Objects;
@@ -28,4 +29,18 @@ public abstract class RecipientMappingModel<T extends RecipientMappingModel<T>>
Context context = ApplicationDependencies.getApplication();
return getName(context).equals(newItem.getName(context)) && Objects.equals(getRecipient().getContactPhoto(), newItem.getRecipient().getContactPhoto());
}
public static class RecipientIdMappingModel extends RecipientMappingModel<RecipientIdMappingModel> {
private final RecipientId recipientId;
public RecipientIdMappingModel(@NonNull RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public @NonNull Recipient getRecipient() {
return Recipient.resolved(recipientId);
}
}
}

View File

@@ -18,10 +18,16 @@ public class RecipientViewHolder<T extends RecipientMappingModel<T>> extends Map
protected final @Nullable AvatarImageView avatar;
protected final @Nullable TextView name;
protected final @Nullable EventListener<T> eventListener;
private final boolean quickContactEnabled;
public RecipientViewHolder(@NonNull View itemView, @Nullable EventListener<T> eventListener) {
this(itemView, eventListener, false);
}
public RecipientViewHolder(@NonNull View itemView, @Nullable EventListener<T> eventListener, boolean quickContactEnabled) {
super(itemView);
this.eventListener = eventListener;
this.eventListener = eventListener;
this.quickContactEnabled = quickContactEnabled;
avatar = findViewById(R.id.recipient_view_avatar);
name = findViewById(R.id.recipient_view_name);
@@ -30,7 +36,7 @@ public class RecipientViewHolder<T extends RecipientMappingModel<T>> extends Map
@Override
public void bind(@NonNull T model) {
if (avatar != null) {
avatar.setRecipient(model.getRecipient());
avatar.setRecipient(model.getRecipient(), quickContactEnabled);
}
if (name != null) {

View File

@@ -17,6 +17,7 @@ import androidx.annotation.StringRes;
import androidx.appcompat.widget.AppCompatTextView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.ThemeUtil;
public class LearnMoreTextView extends AppCompatTextView {
@@ -68,6 +69,10 @@ public class LearnMoreTextView extends AppCompatTextView {
setTextInternal(baseText, visible ? BufferType.SPANNABLE : BufferType.NORMAL);
}
public void setLink(@NonNull String url) {
setOnLinkClickListener(new OpenUrlOnClickListener(url));
}
private void setLinkTextInternal(@StringRes int linkText) {
ClickableSpan clickable = new ClickableSpan() {
@Override
@@ -99,5 +104,19 @@ public class LearnMoreTextView extends AppCompatTextView {
super.setText(text, type);
}
}
private static class OpenUrlOnClickListener implements OnClickListener {
private final String url;
public OpenUrlOnClickListener(@NonNull String url) {
this.url = url;
}
@Override
public void onClick(View v) {
CommunicationActions.openBrowserLink(v.getContext(), url);
}
}
}