mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 02:10:44 +01:00
Payments.
Co-authored-by: Alan Evans <alan@signal.org> Co-authored-by: Alex Hart <alex@signal.org> Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user