Update chat colors.

This commit is contained in:
Alex Hart
2021-05-03 11:34:41 -03:00
committed by Greyson Parrelli
parent 36fe150678
commit bcc5d485ab
164 changed files with 5817 additions and 1476 deletions

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Path
import android.graphics.Region
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import org.thoughtcrime.securesms.util.Projection
/**
* Drawable which clips out the given projection
*/
class ClipProjectionDrawable(wrapped: Drawable) : LayerDrawable(arrayOf(wrapped)) {
constructor() : this(ColorDrawable(Color.TRANSPARENT))
init {
setId(0, 0)
}
private val clipPath = Path()
private var projections: List<Projection> = listOf()
fun setWrappedDrawable(drawable: Drawable) {
setDrawableByLayerId(0, drawable)
}
fun setProjections(projections: Set<Projection>) {
this.projections = projections.toList()
invalidateSelf()
}
fun clearProjections() {
this.projections = listOf()
invalidateSelf()
}
override fun draw(canvas: Canvas) {
if (projections.isNotEmpty()) {
canvas.save()
clipPath.rewind()
projections.forEach {
it.applyToPath(clipPath)
}
canvas.clipPath(clipPath, Region.Op.DIFFERENCE)
super.draw(canvas)
canvas.restore()
} else {
super.draw(canvas)
}
}
}

View File

@@ -1097,7 +1097,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
break;
case GIF:
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getColor().toConversationColor(this));
AttachmentManager.selectGif(this, PICK_GIF, !isSecureText, recipient.get().getChatColors().asSingleColor());
break;
case FILE:
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
@@ -1252,7 +1252,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
GlideApp.with(this)
.asBitmap()
.load(recipient.getContactPhoto())
.error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getColor().toAvatarColor(this), false))
.error(recipient.getFallbackContactPhoto().asDrawable(this, recipient.getChatColors(), false))
.into(new CustomTarget<Bitmap>() {
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
@@ -3506,7 +3506,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
messageRecord.getDateSent(),
author,
body,
slideDeck);
slideDeck,
fragment.getColorizer());
} else if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getLinkPreviews().isEmpty()) {
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
@@ -3520,7 +3521,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
messageRecord.getDateSent(),
author,
conversationMessage.getDisplayBody(this),
slideDeck);
slideDeck,
fragment.getColorizer());
} else {
SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck();
@@ -3534,7 +3536,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
messageRecord.getDateSent(),
author,
conversationMessage.getDisplayBody(this),
slideDeck);
slideDeck,
fragment.getColorizer());
}
inputPanel.clickOnComposeInput();

View File

@@ -43,11 +43,12 @@ import org.signal.core.util.logging.Log;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -123,13 +124,15 @@ public class ConversationAdapter
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
@NonNull Recipient recipient,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory)
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
@NonNull Colorizer colorizer)
{
super(new DiffUtil.ItemCallback<ConversationMessage>() {
@Override
@@ -157,6 +160,7 @@ public class ConversationAdapter
this.hasWallpaper = recipient.hasWallpaper();
this.isMessageRequestAccepted = true;
this.attachmentMediaSourceFactory = attachmentMediaSourceFactory;
this.colorizer = colorizer;
setHasStableIds(true);
}
@@ -271,7 +275,8 @@ public class ConversationAdapter
hasWallpaper,
isMessageRequestAccepted,
attachmentMediaSourceFactory,
conversationMessage == inlineContent);
conversationMessage == inlineContent,
colorizer);
if (conversationMessage == recordToPulse) {
recordToPulse = null;
@@ -635,7 +640,7 @@ public class ConversationAdapter
}
}
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable {
public ConversationViewHolder(final @NonNull View itemView) {
super(itemView);
}
@@ -665,7 +670,7 @@ public class ConversationAdapter
}
@NonNull
public @Override GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
public @Override Projection getProjection(@NonNull ViewGroup recyclerView) {
return getBindable().getProjection(recyclerView);
}
@@ -673,6 +678,11 @@ public class ConversationAdapter
public boolean canPlayContent() {
return getBindable().canPlayContent();
}
@Override
public @NonNull List<Projection> getColorizerProjections() {
return getBindable().getColorizerProjections();
}
}
static class StickyHeaderViewHolder extends RecyclerView.ViewHolder {

View File

@@ -89,6 +89,8 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
@@ -100,9 +102,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
@@ -183,39 +186,41 @@ public class ConversationFragment extends LoggingFragment {
private ConversationFragmentListener listener;
private LiveRecipient recipient;
private long threadId;
private boolean isReacting;
private ActionMode actionMode;
private Locale locale;
private FrameLayout videoContainer;
private RecyclerView list;
private RecyclerView.ItemDecoration lastSeenDecoration;
private RecyclerView.ItemDecoration inlineDateDecoration;
private ViewSwitcher topLoadMoreView;
private ViewSwitcher bottomLoadMoreView;
private ConversationTypingView typingView;
private View composeDivider;
private ConversationScrollToView scrollToBottomButton;
private ConversationScrollToView scrollToMentionButton;
private TextView scrollDateHeader;
private ConversationBannerView conversationBanner;
private MessageRequestViewModel messageRequestViewModel;
private MessageCountsViewModel messageCountsViewModel;
private ConversationViewModel conversationViewModel;
private SnapToTopDataObserver snapToTopDataObserver;
private MarkReadHelper markReadHelper;
private Animation scrollButtonInAnimation;
private Animation mentionButtonInAnimation;
private Animation scrollButtonOutAnimation;
private Animation mentionButtonOutAnimation;
private OnScrollListener conversationScrollListener;
private int pulsePosition = -1;
private VoiceNoteMediaController voiceNoteMediaController;
private View toolbarShadow;
private Stopwatch startupStopwatch;
private LiveRecipient recipient;
private long threadId;
private boolean isReacting;
private ActionMode actionMode;
private Locale locale;
private FrameLayout videoContainer;
private RecyclerView list;
private RecyclerView.ItemDecoration lastSeenDecoration;
private RecyclerView.ItemDecoration inlineDateDecoration;
private ViewSwitcher topLoadMoreView;
private ViewSwitcher bottomLoadMoreView;
private ConversationTypingView typingView;
private View composeDivider;
private ConversationScrollToView scrollToBottomButton;
private ConversationScrollToView scrollToMentionButton;
private TextView scrollDateHeader;
private ConversationBannerView conversationBanner;
private MessageRequestViewModel messageRequestViewModel;
private MessageCountsViewModel messageCountsViewModel;
private ConversationViewModel conversationViewModel;
private SnapToTopDataObserver snapToTopDataObserver;
private MarkReadHelper markReadHelper;
private Animation scrollButtonInAnimation;
private Animation mentionButtonInAnimation;
private Animation scrollButtonOutAnimation;
private Animation mentionButtonOutAnimation;
private OnScrollListener conversationScrollListener;
private int pulsePosition = -1;
private VoiceNoteMediaController voiceNoteMediaController;
private View toolbarShadow;
private ColorizerView colorizerView;
private Stopwatch startupStopwatch;
private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
private Colorizer colorizer;
public static void prepare(@NonNull Context context) {
FrameLayout parent = new FrameLayout(context);
@@ -247,6 +252,10 @@ public class ConversationFragment extends LoggingFragment {
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow);
colorizerView = view.findViewById(R.id.conversation_colorizer_view);
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
list.setHasFixedSize(false);
@@ -272,7 +281,7 @@ public class ConversationFragment extends LoggingFragment {
conversationMessage.getMessageRecord(),
messageRequestViewModel.shouldShowMessageRequest()),
this::handleReplyMessage,
giphyMp4ProjectionRecycler
this::onViewHolderPositionTranslated
).attachToRecyclerView(list);
setupListLayoutListeners();
@@ -310,6 +319,19 @@ public class ConversationFragment extends LoggingFragment {
updateToolbarDependentMargins();
colorizer = new Colorizer(colorizerView);
colorizer.attachToRecyclerView(list);
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), chatColors -> colorizer.onChatColorsChanged(chatColors));
conversationViewModel.getNameColorsMap().observe(getViewLifecycleOwner(), nameColorsMap -> {
colorizer.onNameColorsChanged(nameColorsMap);
ConversationAdapter adapter = getListAdapter();
if (adapter != null) {
adapter.notifyDataSetChanged();
}
});
return view;
}
@@ -352,10 +374,12 @@ public class ConversationFragment extends LoggingFragment {
private void setListVerticalTranslation() {
if (list.canScrollVertically(1) || list.canScrollVertically(-1) || list.getChildCount() == 0) {
list.setTranslationY(0);
colorizerView.setTranslationY(0);
list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS);
} else {
int chTop = list.getChildAt(list.getChildCount() - 1).getTop();
list.setTranslationY(Math.min(0, -chTop));
colorizerView.setTranslationY(Math.min(0, -chTop));
list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER);
}
@@ -467,6 +491,16 @@ public class ConversationFragment extends LoggingFragment {
}
}
private void onViewHolderPositionTranslated(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4ProjectionRecycler.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
}
if (colorizer != null) {
colorizer.applyClipPathsToMaskedGradient(recyclerView);
}
}
private int getStartPosition() {
return conversationViewModel.getArgs().getStartingPosition();
}
@@ -616,7 +650,7 @@ public class ConversationFragment extends LoggingFragment {
}
Log.d(TAG, "Initializing adapter for " + recipient.getId());
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()));
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()), colorizer);
adapter.setPagingController(conversationViewModel.getPagingController());
list.setAdapter(adapter);
setInlineDateDecoration(adapter);
@@ -1124,6 +1158,10 @@ public class ConversationFragment extends LoggingFragment {
}
}
public @NonNull Colorizer getColorizer() {
return Objects.requireNonNull(colorizer);
}
@SuppressWarnings("CodeBlock2Expr")
public void jumpToMessage(@NonNull RecipientId author, long timestamp, @Nullable Runnable onMessageNotFound) {
SimpleTask.run(getLifecycle(), () -> {

View File

@@ -7,6 +7,7 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -67,8 +68,8 @@ public class ConversationIntents {
private final StickerLocator stickerLocator;
private final boolean isBorderless;
private final int distributionType;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
static Args from(@NonNull Intent intent) {
if (isBubbleIntent(intent)) {
@@ -155,6 +156,10 @@ public class ConversationIntents {
// TODO [greyson][wallpaper] Is it worth it to do this beforehand?
return Recipient.resolved(recipientId).getWallpaper();
}
public @NonNull ChatColors getChatColors() {
return Recipient.resolved(recipientId).getChatColors();
}
}
public final static class Builder {

View File

@@ -55,8 +55,8 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.source.MediaSource;
@@ -72,7 +72,6 @@ import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.BorderlessImageView;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
import org.thoughtcrime.securesms.components.CornerMask;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.LinkPreviewView;
import org.thoughtcrime.securesms.components.Outliner;
@@ -81,6 +80,7 @@ import org.thoughtcrime.securesms.components.SharedContactView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
@@ -92,7 +92,6 @@ import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
@@ -121,9 +120,9 @@ import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UrlClickHandler;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.VibrateUtil;
@@ -135,6 +134,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -160,13 +160,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private static final Rect SWIPE_RECT = new Rect();
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
private ValueAnimator pulseOutlinerAlphaAnimator;
private ClipProjectionDrawable backgroundDrawable;
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
private ValueAnimator pulseOutlinerAlphaAnimator;
protected ConversationItemBodyBubble bodyBubble;
protected View reply;
@@ -212,8 +213,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final Context context;
private MediaSource mediaSource;
private boolean canPlayContent;
private MediaSource mediaSource;
private boolean canPlayContent;
private Projection.Corners bodyBubbleCorners;
private Colorizer colorizer;
private boolean hasWallpaper;
public ConversationItem(Context context) {
this(context, null);
@@ -235,6 +239,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
initializeAttributes();
this.backgroundDrawable = new ClipProjectionDrawable(Objects.requireNonNull(ContextCompat.getDrawable(getContext(),
R.drawable.conversation_item_background)));
this.bodyText = findViewById(R.id.conversation_item_body);
this.footer = findViewById(R.id.conversation_item_footer);
this.stickerFooter = findViewById(R.id.conversation_item_sticker_footer);
@@ -276,7 +282,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
boolean allowedToPlayInline,
@NonNull Colorizer colorizer)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
@@ -293,6 +300,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.recipient = messageRecord.getIndividualRecipient().live();
this.canPlayContent = false;
this.mediaSource = null;
this.colorizer = colorizer;
this.recipient.observeForever(this);
this.conversationRecipient.observeForever(this);
@@ -301,14 +309,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, attachmentMediaSourceFactory, allowedToPlayInline);
setBodyText(messageRecord, searchQuery, isMessageRequestAccepted);
setBubbleState(messageRecord, hasWallpaper);
setBubbleState(messageRecord, messageRecord.getRecipient(), hasWallpaper, colorizer);
setInteractionState(conversationMessage, pulse);
setStatusIcons(messageRecord, hasWallpaper);
setContactPhoto(recipient.get());
setGroupMessageStatus(messageRecord, recipient.get());
setGroupAuthorColor(messageRecord, hasWallpaper);
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, colorizer);
setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setReactions(messageRecord);
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
@@ -390,7 +398,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void onRecipientChanged(@NonNull Recipient modified) {
setBubbleState(messageRecord, modified.hasWallpaper());
setBubbleState(messageRecord, modified, modified.hasWallpaper(), colorizer);
if (recipient.getId().equals(modified.getId())) {
setContactPhoto(modified);
setGroupMessageStatus(messageRecord, modified);
@@ -438,11 +446,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
/// MessageRecord Attribute Parsers
private void setBubbleState(MessageRecord messageRecord, boolean hasWallpaper) {
private void setBubbleState(MessageRecord messageRecord, @NonNull Recipient recipient, boolean hasWallpaper, @NonNull Colorizer colorizer) {
this.hasWallpaper = hasWallpaper;
bodyText.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
bodyText.setLinkTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
if (messageRecord.isOutgoing() && !messageRecord.isRemoteDelete()) {
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY);
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setIconColor(ContextCompat.getColor(context, R.color.signal_icon_tint_secondary));
bodyBubble.getBackground().setColorFilter(recipient.getChatColors().getChatBubbleColorFilter());
bodyText.setTextColor(colorizer.getOutgoingBodyTextColor(context));
bodyText.setLinkTextColor(colorizer.getOutgoingBodyTextColor(context));
footer.setTextColor(colorizer.getOutgoingFooterTextColor(context));
footer.setIconColor(colorizer.getOutgoingFooterIconColor(context));
footer.setOnlyShowSendingStatus(false, messageRecord);
} else if (messageRecord.isRemoteDelete() || (isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord))) {
if (hasWallpaper) {
@@ -454,9 +469,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord);
} else {
bodyBubble.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY);
footer.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
footer.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.SRC_IN);
footer.setTextColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setIconColor(ContextCompat.getColor(context, R.color.signal_text_secondary));
footer.setOnlyShowSendingStatus(false, messageRecord);
}
@@ -490,7 +505,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void setAudioViewTint(MessageRecord messageRecord) {
if (hasAudio(messageRecord)) {
if (messageRecord.isOutgoing()) {
if (!messageRecord.isOutgoing()) {
if (DynamicTheme.isDarkTheme(context)) {
audioViewStub.get().setTint(Color.WHITE);
} else {
@@ -504,7 +519,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void setInteractionState(ConversationMessage conversationMessage, boolean pulseMention) {
if (batchSelected.contains(conversationMessage)) {
setBackgroundResource(R.drawable.conversation_item_background);
setBackground(backgroundDrawable);
setSelected(true);
} else if (pulseMention) {
setBackground(null);
@@ -683,9 +698,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (messageRecord.isOutgoing()) {
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_25));
} else {
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_40));
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
}
bodyText.setText(StringUtil.trim(styledText));
@@ -704,6 +719,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
{
boolean showControls = !messageRecord.isFailed();
bodyBubble.setQuoteViewProjection(null);
bodyBubble.setVideoPlayerProjection(null);
updateBackgroundDrawableProjections();
if (eventListener != null && audioViewStub.resolved()) {
Log.d(TAG, "setMediaAttributes: unregistering voice note callbacks for audio slide " + audioViewStub.get().getAudioSlideUri());
eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
@@ -875,8 +894,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
mediaThumbnailStub.get().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.get().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.get().showShade(TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && !hasExtraText(messageRecord));
mediaThumbnailStub.get().setConversationColor(messageRecord.isOutgoing() ? getDefaultBubbleColor(hasWallpaper)
: messageRecord.getRecipient().getColor().toConversationColor(context));
if (!messageRecord.isOutgoing()) {
mediaThumbnailStub.get().setConversationColor(getDefaultBubbleColor(hasWallpaper));
} else {
mediaThumbnailStub.get().setConversationColor(Color.TRANSPARENT);
}
mediaThumbnailStub.get().setBorderless(false);
setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
@@ -1068,14 +1092,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull Colorizer colorizer) {
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
if (quoteView == null) {
throw new AssertionError();
}
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
//noinspection ConstantConditions
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment());
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment(), colorizer);
quoteView.setVisibility(View.VISIBLE);
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
@@ -1177,11 +1201,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (hasWallpaper && hasNoBubble((messageRecord))) {
if (messageRecord.isOutgoing()) {
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper));
activeFooter.disableBubbleBackground();
activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_sent_text_secondary_color));
activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_sent_text_secondary_color));
} else {
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, messageRecord.getRecipient().getColor().toConversationColor(context));
activeFooter.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
activeFooter.setIconColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_secondary_color));
activeFooter.enableBubbleBackground(R.drawable.wallpaper_bubble_background_tintable_11, getDefaultBubbleColor(hasWallpaper));
}
} else if (hasNoBubble(messageRecord)){
activeFooter.disableBubbleBackground();
@@ -1228,17 +1252,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper) {
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper, @NonNull Colorizer colorizer) {
if (groupSender != null) {
int stickerAuthorColor = ContextCompat.getColor(context, R.color.signal_text_primary);
if (shouldDrawBodyBubbleOutline(messageRecord, false)) {
groupSender.setTextColor(stickerAuthorColor);
} else if (!hasWallpaper && hasNoBubble(messageRecord)) {
groupSender.setTextColor(stickerAuthorColor);
} else {
groupSender.setTextColor(ContextCompat.getColor(context, R.color.conversation_item_received_text_primary_color));
}
groupSender.setTextColor(colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getIndividualRecipient()));
}
}
@@ -1254,7 +1270,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (hasWallpaper && hasNoBubble(current)) {
groupSenderHolder.setBackgroundResource(R.drawable.wallpaper_bubble_background_tintable_11);
groupSenderHolder.getBackground().setColorFilter(messageRecord.getRecipient().getColor().toConversationColor(context), PorterDuff.Mode.MULTIPLY);
groupSenderHolder.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.MULTIPLY);
} else {
groupSenderHolder.setBackground(null);
}
@@ -1297,40 +1313,48 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
background = R.drawable.message_bubble_background_sent_alone;
outliner.setRadius(bigRadius);
pulseOutliner.setRadius(bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius);
} else {
background = R.drawable.message_bubble_background_received_alone;
outliner.setRadius(bigRadius);
pulseOutliner.setRadius(bigRadius);
bodyBubbleCorners = null;
}
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_start;
setOutlinerRadii(outliner, bigRadius, bigRadius, smallRadius, bigRadius);
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, smallRadius, bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius, bigRadius, smallRadius, bigRadius);
} else {
background = R.drawable.message_bubble_background_received_start;
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
bodyBubbleCorners = null;
}
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_end;
setOutlinerRadii(outliner, bigRadius, smallRadius, bigRadius, bigRadius);
setOutlinerRadii(pulseOutliner, bigRadius, smallRadius, bigRadius, bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius, smallRadius, bigRadius, bigRadius);
} else {
background = R.drawable.message_bubble_background_received_end;
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
bodyBubbleCorners = null;
}
} else {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_middle;
setOutlinerRadii(outliner, bigRadius, smallRadius, smallRadius, bigRadius);
setOutlinerRadii(pulseOutliner, bigRadius, smallRadius, smallRadius, bigRadius);
bodyBubbleCorners = new Projection.Corners(bigRadius, smallRadius, smallRadius, bigRadius);
} else {
background = R.drawable.message_bubble_background_received_middle;
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, smallRadius);
bodyBubbleCorners = null;
}
}
@@ -1440,7 +1464,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
public void showProjectionArea() {
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().showThumbnailView();
bodyBubble.setMask(null);
bodyBubble.setVideoPlayerProjection(null);
updateBackgroundDrawableProjections();
}
}
@@ -1449,7 +1474,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().hideThumbnailView();
mediaThumbnailStub.get().getDrawingRect(thumbnailMaskingRect);
bodyBubble.setMask(thumbnailMaskingRect);
bodyBubble.setVideoPlayerProjection(Projection.relativeToViewWithCommonRoot(mediaThumbnailStub.get(), bodyBubble, null));
updateBackgroundDrawableProjections();
}
}
@@ -1475,9 +1501,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
return GiphyMp4Projection.forView(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCornerMask())
.translateX(bodyBubble.getTranslationX());
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCorners())
.translateX(bodyBubble.getTranslationX());
}
@Override
@@ -1494,8 +1520,55 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return rect;
}
public @NonNull CornerMask getThumbnailCornerMask(@NonNull View view) {
return new CornerMask(view, mediaThumbnailStub.get().getCornerMask());
public @NonNull Projection.Corners getThumbnailCorners() {
return mediaThumbnailStub.get().getCorners();
}
@Override
public @NonNull List<Projection> getColorizerProjections() {
List<Projection> projections = new LinkedList<>();
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
bodyBubbleCorners != null)
{
projections.add(Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
}
if (messageRecord.isOutgoing() &&
hasNoBubble(messageRecord) &&
hasWallpaper)
{
Projection footerProjection = getActiveFooter(messageRecord).getProjection();
if (footerProjection != null) {
projections.add(footerProjection.translateX(bodyBubble.getTranslationX()));
}
}
if (!messageRecord.isOutgoing() &&
hasQuote(messageRecord) &&
quoteView != null)
{
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
projections.add(quoteView.getProjection((ViewGroup) getRootView()).translateX(bodyBubble.getTranslationX()));
}
return projections;
}
private void updateBackgroundDrawableProjections() {
Set<Projection> projections = Stream.of(bodyBubble.getProjections())
.map(p -> Projection.translateFromDescendantToParentCoords(p, bodyBubble, this))
.collect(Collectors.toSet());
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
bodyBubbleCorners != null)
{
projections.add(Projection.relativeToParent(this, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX()));
}
backgroundDrawable.setProjections(projections);
}
private class SharedContactEventListener implements SharedContactView.EventListener {

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.LinearLayout;
@@ -10,19 +9,27 @@ import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.Outliner;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.Util;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class ConversationItemBodyBubble extends LinearLayout {
@Nullable private List<Outliner> outliners = Collections.emptyList();
@Nullable private OnSizeChangedListener sizeChangedListener;
private MaskDrawable maskDrawable;
private Rect mask;
private ClipProjectionDrawable clipProjectionDrawable;
private Projection quoteViewProjection;
private Projection videoPlayerProjection;
public ConversationItemBodyBubble(Context context) {
super(context);
@@ -46,14 +53,26 @@ public class ConversationItemBodyBubble extends LinearLayout {
@Override
public void setBackground(Drawable background) {
maskDrawable = new MaskDrawable(background);
maskDrawable.setMask(mask);
super.setBackground(maskDrawable);
clipProjectionDrawable = new ClipProjectionDrawable(background);
clipProjectionDrawable.setProjections(getProjections());
super.setBackground(clipProjectionDrawable);
}
public void setMask(@Nullable Rect mask) {
this.mask = mask;
maskDrawable.setMask(mask);
public void setQuoteViewProjection(@Nullable Projection quoteViewProjection) {
this.quoteViewProjection = quoteViewProjection;
clipProjectionDrawable.setProjections(getProjections());
}
public void setVideoPlayerProjection(@Nullable Projection videoPlayerProjection) {
this.videoPlayerProjection = videoPlayerProjection;
clipProjectionDrawable.setProjections(getProjections());
}
public @NonNull Set<Projection> getProjections() {
return Stream.of(quoteViewProjection, videoPlayerProjection)
.filterNot(Objects::isNull)
.collect(Collectors.toSet());
}
@Override

View File

@@ -2,23 +2,30 @@ package org.thoughtcrime.securesms.conversation;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.CornerMask;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.util.Projection;
import java.util.Arrays;
import java.util.List;
/**
* Masking area to ensure proper rendering of Reactions overlay.
*/
public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
private final ConversationItem conversationItem;
private final View videoContainer;
private final Paint paint;
public ConversationItemMaskTarget(@NonNull ConversationItem conversationItem,
@Nullable View videoContainer)
@@ -26,6 +33,10 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
super(conversationItem);
this.conversationItem = conversationItem;
this.videoContainer = videoContainer;
this.paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.BLACK);
paint.setStyle(Paint.Style.FILL);
}
@Override
@@ -41,19 +52,16 @@ public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
protected void draw(@NonNull Canvas canvas) {
super.draw(canvas);
if (videoContainer == null) {
return;
List<Projection> projections = Stream.of(conversationItem.getColorizerProjections()).map(p ->
Projection.translateFromRootToDescendantCoords(p, conversationItem)
).toList();
if (videoContainer != null) {
projections.add(conversationItem.getProjection((RecyclerView) conversationItem.getParent()));
}
GiphyMp4Projection projection = conversationItem.getProjection((RecyclerView) conversationItem.getParent());
CornerMask cornerMask = projection.getCornerMask();
canvas.clipRect(conversationItem.bodyBubble.getLeft(),
conversationItem.bodyBubble.getTop(),
conversationItem.bodyBubble.getRight(),
conversationItem.bodyBubble.getTop() + projection.getHeight());
canvas.drawColor(Color.BLACK);
cornerMask.mask(canvas);
for (Projection projection : projections) {
canvas.drawPath(projection.getPath(), paint);
}
}
}

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
public class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private static float SWIPE_SUCCESS_DX = ConversationSwipeAnimationHelper.TRIGGER_DX;
private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10;
@@ -30,17 +30,17 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private final SwipeAvailabilityProvider swipeAvailabilityProvider;
private final ConversationItemTouchListener itemTouchListener;
private final OnSwipeListener onSwipeListener;
private final GiphyMp4DisplayUpdater giphyMp4DisplayUpdater;
private final OnViewHolderTranslated onViewHolderTranslated;
ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider,
@NonNull OnSwipeListener onSwipeListener,
@NonNull GiphyMp4DisplayUpdater giphyMp4DisplayUpdater)
@NonNull OnViewHolderTranslated onViewHolderTranslated)
{
super(0, ItemTouchHelper.END);
this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate);
this.swipeAvailabilityProvider = swipeAvailabilityProvider;
this.onSwipeListener = onSwipeListener;
this.giphyMp4DisplayUpdater = giphyMp4DisplayUpdater;
this.onViewHolderTranslated = onViewHolderTranslated;
this.shouldTriggerSwipeFeedback = true;
this.canTriggerSwipe = true;
}
@@ -93,14 +93,14 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign);
updateVideoPlayer(recyclerView, viewHolder);
dispatchTranslationUpdate(recyclerView, viewHolder);
handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx));
if (canTriggerSwipe) {
setTouchListener(recyclerView, viewHolder, Math.abs(dx));
}
} else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
updateVideoPlayer(recyclerView, viewHolder);
dispatchTranslationUpdate(recyclerView, viewHolder);
}
if (dx == 0) {
@@ -109,10 +109,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
}
}
private void updateVideoPlayer(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4DisplayUpdater.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
}
private void dispatchTranslationUpdate(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
onViewHolderTranslated.onViewHolderTranslated(recyclerView, viewHolder);
}
private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) {
@@ -174,7 +172,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView,
0f,
getSignFromDirection(viewHolder.itemView));
updateVideoPlayer(recyclerView, viewHolder);
dispatchTranslationUpdate(recyclerView, viewHolder);
}
}
@@ -211,4 +209,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
interface OnSwipeListener {
void onSwipe(ConversationMessage conversationMessage);
}
public interface OnViewHolderTranslated {
void onViewHolderTranslated(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder);
}
}

View File

@@ -16,7 +16,6 @@ import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.button.MaterialButton;
@@ -24,6 +23,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil;
@@ -31,12 +31,12 @@ import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
@@ -47,6 +47,8 @@ import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@@ -109,7 +111,8 @@ public final class ConversationUpdateItem extends FrameLayout
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
boolean allowedToPlayInline,
@NonNull Colorizer colorizer)
{
this.batchSelected = batchSelected;
@@ -206,7 +209,7 @@ public final class ConversationUpdateItem extends FrameLayout
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) {
throw new UnsupportedOperationException("ConversationUpdateItems cannot be projected into.");
}
@@ -215,6 +218,11 @@ public final class ConversationUpdateItem extends FrameLayout
return false;
}
@Override
public @NonNull List<Projection> getColorizerProjections() {
return Collections.emptyList();
}
static final class RecipientObserverManager {
private final Observer<Recipient> recipientObserver;

View File

@@ -10,6 +10,8 @@ import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
@@ -18,8 +20,13 @@ import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.signal.paging.ProxyPagingController;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.conversation.colors.NameColor;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaRepository;
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
@@ -30,8 +37,11 @@ import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.libsignal.util.Pair;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public class ConversationViewModel extends ViewModel {
@@ -52,6 +62,7 @@ public class ConversationViewModel extends ViewModel {
private final MutableLiveData<RecipientId> recipientId;
private final LiveData<ChatWallpaper> wallpaper;
private final SingleLiveEvent<Event> events;
private final LiveData<ChatColors> chatColors;
private ConversationIntents.Args args;
private int jumpToPosition;
@@ -114,11 +125,15 @@ public class ConversationViewModel extends ViewModel {
conversationMetadata = Transformations.switchMap(messages, m -> metadata);
canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble);
wallpaper = Transformations.distinctUntilChanged(Transformations.map(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getWallpaper));
wallpaper = LiveDataUtil.mapDistinct(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getWallpaper);
EventBus.getDefault().register(this);
chatColors = LiveDataUtil.mapDistinct(Transformations.switchMap(recipientId,
id -> Recipient.live(id).getLiveData()),
Recipient::getChatColors);
}
void onAttachmentKeyboardOpen() {
@@ -159,6 +174,10 @@ public class ConversationViewModel extends ViewModel {
return events;
}
@NonNull LiveData<ChatColors> getChatColors() {
return chatColors;
}
void setHasUnreadMentions(boolean hasUnreadMentions) {
this.hasUnreadMentions.setValue(hasUnreadMentions);
}
@@ -183,6 +202,35 @@ public class ConversationViewModel extends ViewModel {
return pagingController;
}
@NonNull LiveData<Map<RecipientId, NameColor>> getNameColorsMap() {
LiveData<Recipient> recipient = Transformations.switchMap(recipientId, r -> Recipient.live(r).getLiveData());
LiveData<Recipient> groupRecipients = LiveDataUtil.filter(recipient, Recipient::isGroup);
LiveData<List<GroupMemberEntry.FullMember>> groupMembers = Transformations.switchMap(groupRecipients, r -> new LiveGroup(r.getGroupId().get()).getFullMembers());
return Transformations.map(groupMembers, members -> {
List<GroupMemberEntry.FullMember> sorted = Stream.of(members)
.filter(member -> !Objects.equals(member.getMember(), Recipient.self()))
.sortBy(this::getMemberIdentifier)
.toList();
List<NameColor> names = ChatColorsPalette.Names.getAll();
Map<RecipientId, NameColor> colors = new HashMap<>();
for (int i = 0; i < sorted.size(); i++) {
colors.put(sorted.get(i).getMember().getId(), names.get(i % names.size()));
}
return colors;
});
}
private @NonNull String getMemberIdentifier(@NonNull GroupMemberEntry.FullMember fullMember) {
return fullMember.getMember()
.getUuid()
.transform(UUID::toString)
.or(fullMember.getMember().getE164())
.or("");
}
long getLastSeen() {
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0;
}

View File

@@ -13,6 +13,8 @@ import androidx.annotation.Nullable;
/**
* Drawable which lets you punch a hole through another drawable.
*
* TODO: Remove in favor of ClipProjectionDrawable
*/
public final class MaskDrawable extends Drawable {

View File

@@ -0,0 +1,226 @@
package org.thoughtcrime.securesms.conversation.colors
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import com.google.common.base.Objects
import org.thoughtcrime.securesms.components.RotatableGradientDrawable
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
import org.thoughtcrime.securesms.util.customizeOnDraw
import kotlin.math.min
/**
* ChatColors represent how to render the avatar and bubbles in a given context.
*
* @param id The identifier for this chat color. It is either BuiltIn, NotSet, or Custom(long)
* @param linearGradient The LinearGradient to render. Null if this is for a single color.
* @param singleColor The single color to render. Null if this is for a linear gradient.
*/
class ChatColors private constructor(
val id: Id,
private val linearGradient: LinearGradient?,
private val singleColor: Int?
) {
/**
* Returns the Drawable to render the linear gradient, or null if this ChatColors is a single color.
*/
val chatBubbleMask: Drawable
get() = linearGradient?.let {
RotatableGradientDrawable(
it.degrees,
it.colors,
it.positions
)
} ?: ColorDrawable(asSingleColor())
/**
* Returns the ColorFilter to apply to a conversation bubble or other relevant piece of UI.
*/
val chatBubbleColorFilter: ColorFilter = PorterDuffColorFilter(Color.TRANSPARENT, PorterDuff.Mode.SRC_IN)
@ColorInt
fun asSingleColor(): Int {
if (singleColor != null) {
return singleColor
}
if (linearGradient != null) {
val start = linearGradient.colors.first()
val end = linearGradient.colors.last()
return ColorUtils.blendARGB(start, end, 0.5f)
}
throw AssertionError()
}
fun serialize(): ChatColor {
val builder: ChatColor.Builder = ChatColor.newBuilder()
if (linearGradient != null) {
val gradientBuilder = ChatColor.LinearGradient.newBuilder()
gradientBuilder.rotation = linearGradient.degrees
linearGradient.colors.forEach { gradientBuilder.addColors(it) }
linearGradient.positions.forEach { gradientBuilder.addPositions(it) }
builder.setLinearGradient(gradientBuilder)
}
if (singleColor != null) {
builder.setSingleColor(ChatColor.SingleColor.newBuilder().setColor(singleColor))
}
return builder.build()
}
fun getColors(): IntArray {
return linearGradient?.colors ?: if (singleColor != null) {
intArrayOf(singleColor)
} else {
throw AssertionError()
}
}
fun getDegrees(): Float {
return linearGradient?.degrees ?: 180f
}
fun asCircle(): Drawable {
val toWrap: Drawable = chatBubbleMask
val path = Path()
return toWrap.customizeOnDraw { wrapped, canvas ->
canvas.save()
path.rewind()
path.addCircle(
wrapped.bounds.exactCenterX(),
wrapped.bounds.exactCenterY(),
min(wrapped.bounds.exactCenterX(), wrapped.bounds.exactCenterY()),
Path.Direction.CW
)
canvas.clipPath(path)
wrapped.draw(canvas)
canvas.restore()
}
}
fun withId(id: Id): ChatColors = ChatColors(id, linearGradient, singleColor)
override fun equals(other: Any?): Boolean {
val otherChatColors: ChatColors = (other as? ChatColors) ?: return false
if (id != otherChatColors.id) return false
if (linearGradient != otherChatColors.linearGradient) return false
if (singleColor != otherChatColors.singleColor) return false
return true
}
override fun hashCode(): Int {
return Objects.hashCode(linearGradient, singleColor, id)
}
companion object {
@JvmStatic
fun forChatColor(id: Id, chatColor: ChatColor): ChatColors {
assert(chatColor.hasSingleColor() xor chatColor.hasLinearGradient())
return if (chatColor.hasLinearGradient()) {
val linearGradient = LinearGradient(
chatColor.linearGradient.rotation,
chatColor.linearGradient.colorsList.toIntArray(),
chatColor.linearGradient.positionsList.toFloatArray()
)
forGradient(id, linearGradient)
} else {
val singleColor = chatColor.singleColor.color
forColor(id, singleColor)
}
}
@JvmStatic
fun forGradient(id: Id, linearGradient: LinearGradient): ChatColors =
ChatColors(id, linearGradient, null)
@JvmStatic
fun forColor(id: Id, @ColorInt color: Int): ChatColors =
ChatColors(id, null, color)
}
sealed class Id(val longValue: Long) {
/**
* Represents user selection of 'auto'.
*/
object Auto : Id(-2)
/**
* Represents a built in color.
*/
object BuiltIn : Id(-1)
/**
* Represents an unsaved or un-set option.
*/
object NotSet : Id(0)
/**
* Represents a custom created ChatColors.
*/
class Custom internal constructor(id: Long) : Id(id)
override fun equals(other: Any?): Boolean {
return longValue == (other as? Id)?.longValue
}
override fun hashCode(): Int {
return Objects.hashCode(longValue)
}
companion object {
@JvmStatic
fun forLongValue(longValue: Long): Id {
return when (longValue) {
-2L -> Auto
-1L -> BuiltIn
0L -> NotSet
else -> Custom(longValue)
}
}
}
}
data class LinearGradient(
val degrees: Float,
val colors: IntArray,
val positions: FloatArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LinearGradient
if (!colors.contentEquals(other.colors)) return false
if (!positions.contentEquals(other.positions)) return false
return true
}
override fun hashCode(): Int {
var result = colors.contentHashCode()
result = 31 * result + positions.contentHashCode()
return result
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.conversation.colors
import com.google.common.collect.BiMap
import com.google.common.collect.ImmutableBiMap
import com.google.common.collect.ImmutableMap
import org.thoughtcrime.securesms.color.MaterialColor
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.GradientChatWallpaper
import org.thoughtcrime.securesms.wallpaper.SingleColorChatWallpaper
/**
* Contains mappings to get the relevant chat colors for either a legacy MaterialColor or a built-in wallpaper.
*/
object ChatColorsMapper {
private val materialColorToChatColorsBiMap: BiMap<MaterialColor, ChatColors> = ImmutableBiMap.Builder<MaterialColor, ChatColors>().apply {
put(MaterialColor.CRIMSON, ChatColorsPalette.Bubbles.CRIMSON)
put(MaterialColor.VERMILLION, ChatColorsPalette.Bubbles.VERMILION)
put(MaterialColor.BURLAP, ChatColorsPalette.Bubbles.BURLAP)
put(MaterialColor.FOREST, ChatColorsPalette.Bubbles.FOREST)
put(MaterialColor.WINTERGREEN, ChatColorsPalette.Bubbles.WINTERGREEN)
put(MaterialColor.TEAL, ChatColorsPalette.Bubbles.TEAL)
put(MaterialColor.BLUE, ChatColorsPalette.Bubbles.BLUE)
put(MaterialColor.INDIGO, ChatColorsPalette.Bubbles.INDIGO)
put(MaterialColor.VIOLET, ChatColorsPalette.Bubbles.VIOLET)
put(MaterialColor.PLUM, ChatColorsPalette.Bubbles.PLUM)
put(MaterialColor.TAUPE, ChatColorsPalette.Bubbles.TAUPE)
put(MaterialColor.STEEL, ChatColorsPalette.Bubbles.STEEL)
put(MaterialColor.ULTRAMARINE, ChatColorsPalette.Bubbles.ULTRAMARINE)
}.build()
private val wallpaperToChatColorsMap: Map<ChatWallpaper, ChatColors> = ImmutableMap.Builder<ChatWallpaper, ChatColors>().apply {
put(SingleColorChatWallpaper.BLUSH, ChatColorsPalette.Bubbles.CRIMSON)
put(SingleColorChatWallpaper.COPPER, ChatColorsPalette.Bubbles.VERMILION)
put(SingleColorChatWallpaper.DUST, ChatColorsPalette.Bubbles.BURLAP)
put(SingleColorChatWallpaper.CELADON, ChatColorsPalette.Bubbles.FOREST)
put(SingleColorChatWallpaper.RAINFOREST, ChatColorsPalette.Bubbles.WINTERGREEN)
put(SingleColorChatWallpaper.PACIFIC, ChatColorsPalette.Bubbles.TEAL)
put(SingleColorChatWallpaper.FROST, ChatColorsPalette.Bubbles.BLUE)
put(SingleColorChatWallpaper.NAVY, ChatColorsPalette.Bubbles.INDIGO)
put(SingleColorChatWallpaper.LILAC, ChatColorsPalette.Bubbles.VIOLET)
put(SingleColorChatWallpaper.PINK, ChatColorsPalette.Bubbles.PLUM)
put(SingleColorChatWallpaper.EGGPLANT, ChatColorsPalette.Bubbles.TAUPE)
put(SingleColorChatWallpaper.SILVER, ChatColorsPalette.Bubbles.STEEL)
put(GradientChatWallpaper.SUNSET, ChatColorsPalette.Bubbles.EMBER)
put(GradientChatWallpaper.NOIR, ChatColorsPalette.Bubbles.MIDNIGHT)
put(GradientChatWallpaper.HEATMAP, ChatColorsPalette.Bubbles.INFRARED)
put(GradientChatWallpaper.AQUA, ChatColorsPalette.Bubbles.LAGOON)
put(GradientChatWallpaper.IRIDESCENT, ChatColorsPalette.Bubbles.FLUORESCENT)
put(GradientChatWallpaper.MONSTERA, ChatColorsPalette.Bubbles.BASIL)
put(GradientChatWallpaper.BLISS, ChatColorsPalette.Bubbles.SUBLIME)
put(GradientChatWallpaper.SKY, ChatColorsPalette.Bubbles.SEA)
put(GradientChatWallpaper.PEACH, ChatColorsPalette.Bubbles.TANGERINE)
}.build()
@JvmStatic
val entrySet: Set<MutableMap.MutableEntry<MaterialColor, ChatColors>>
get() = materialColorToChatColorsBiMap.entries
@JvmStatic
fun getChatColors(materialColor: MaterialColor): ChatColors {
return materialColorToChatColorsBiMap[materialColor] ?: ChatColorsPalette.Bubbles.default
}
@JvmStatic
fun getChatColors(wallpaper: ChatWallpaper): ChatColors {
return wallpaperToChatColorsMap.entries.find { (key, _) ->
key.isSameSource(wallpaper)
}?.value ?: ChatColorsPalette.Bubbles.default
}
@JvmStatic
fun getMaterialColor(chatColors: ChatColors): MaterialColor {
return materialColorToChatColorsBiMap.inverse()[chatColors] ?: MaterialColor.ULTRAMARINE
}
}

View File

@@ -0,0 +1,229 @@
package org.thoughtcrime.securesms.conversation.colors
/**
* Namespaced collection of supported bubble colors and name colors.
*/
object ChatColorsPalette {
object Bubbles {
// region Default
@JvmField
val ULTRAMARINE = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180.0f,
intArrayOf(0xFF0552F0.toInt(), 0xFF2C6BED.toInt()),
floatArrayOf(0f, 1f)
)
)
// endregion
// region Solids
@JvmField
val CRIMSON = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFCF16E3.toInt())
@JvmField
val VERMILION = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFC73F0A.toInt())
@JvmField
val BURLAP = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF6F6A58.toInt())
@JvmField
val FOREST = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF3B7845.toInt())
@JvmField
val WINTERGREEN = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF1D8663.toInt())
@JvmField
val TEAL = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF077D92.toInt())
@JvmField
val BLUE = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF336BA3.toInt())
@JvmField
val INDIGO = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF6058CA.toInt())
@JvmField
val VIOLET = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFF9932CB.toInt())
@JvmField
val PLUM = ChatColors.forColor(ChatColors.Id.BuiltIn, 0xFFAA377A.toInt())
@JvmField
val TAUPE = ChatColors.forColor(
ChatColors.Id.BuiltIn, 0xFF8F616A.toInt()
)
@JvmField
val STEEL = ChatColors.forColor(
ChatColors.Id.BuiltIn, 0xFF71717F.toInt()
)
// endregion
// region Gradients
@JvmField
val EMBER = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
168f,
intArrayOf(0xFFE57C00.toInt(), 0xFF5E0000.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val MIDNIGHT = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF2C2C3A.toInt(), 0xFF787891.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val INFRARED = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
192f,
intArrayOf(0xFFF65560.toInt(), 0xFF442CED.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val LAGOON = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF004066.toInt(), 0xFF32867D.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val FLUORESCENT = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
192f,
intArrayOf(0xFFEC13DD.toInt(), 0xFF1B36C6.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val BASIL = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF2F9373.toInt(), 0xFF077343.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val SUBLIME = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(
0xFF6281D5.toInt(), 0xFF974460.toInt()
),
floatArrayOf(0f, 1f)
)
)
@JvmField
val SEA = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
180f,
intArrayOf(0xFF498FD4.toInt(), 0xFF2C66A0.toInt()),
floatArrayOf(0f, 1f)
)
)
@JvmField
val TANGERINE = ChatColors.forGradient(
ChatColors.Id.BuiltIn,
ChatColors.LinearGradient(
192f,
intArrayOf(0xFFDB7133.toInt(), 0xFF911231.toInt()),
floatArrayOf(0f, 1f)
)
)
// endregion
@JvmStatic
val default = ULTRAMARINE
val solids = listOf(
CRIMSON,
VERMILION,
BURLAP,
FOREST,
WINTERGREEN,
TEAL,
BLUE,
INDIGO,
VIOLET,
PLUM,
TAUPE,
STEEL
)
val gradients =
listOf(EMBER, MIDNIGHT, INFRARED, LAGOON, FLUORESCENT, BASIL, SUBLIME, SEA, TANGERINE)
val all = listOf(default) + solids + gradients
}
object Names {
@JvmStatic
val all = listOf(
NameColor(lightColor = 0xFFD00B0B.toInt(), darkColor = 0xFFF76E6E.toInt()),
NameColor(lightColor = 0xFF067906.toInt(), darkColor = 0xFF0AB80A.toInt()),
NameColor(lightColor = 0xFF5151F6.toInt(), darkColor = 0xFF8B8BF9.toInt()),
NameColor(lightColor = 0xFF866118.toInt(), darkColor = 0xFFD08F0B.toInt()),
NameColor(lightColor = 0xFF067953.toInt(), darkColor = 0xFF09B37B.toInt()),
NameColor(lightColor = 0xFFA20CED.toInt(), darkColor = 0xFFCB72F8.toInt()),
NameColor(lightColor = 0xFF507406.toInt(), darkColor = 0xFF77AE09.toInt()),
NameColor(lightColor = 0xFF086DA0.toInt(), darkColor = 0xFF0DA6F2.toInt()),
NameColor(lightColor = 0xFFC70A88.toInt(), darkColor = 0xFFF76EC9.toInt()),
NameColor(lightColor = 0xFFB34209.toInt(), darkColor = 0xFFF4702F.toInt()),
NameColor(lightColor = 0xFF06792D.toInt(), darkColor = 0xFF0AB844.toInt()),
NameColor(lightColor = 0xFF7A3DF5.toInt(), darkColor = 0xFFAC86F9.toInt()),
NameColor(lightColor = 0xFF6C6C13.toInt(), darkColor = 0xFFA5A509.toInt()),
NameColor(lightColor = 0xFF067474.toInt(), darkColor = 0xFF09AEAE.toInt()),
NameColor(lightColor = 0xFFB80AB8.toInt(), darkColor = 0xFFF75FF7.toInt()),
NameColor(lightColor = 0xFF2D7906.toInt(), darkColor = 0xFF42B309.toInt()),
NameColor(lightColor = 0xFF0D59F2.toInt(), darkColor = 0xFF6495F7.toInt()),
NameColor(lightColor = 0xFFD00B4D.toInt(), darkColor = 0xFFF76998.toInt()),
NameColor(lightColor = 0xFFC72A0A.toInt(), darkColor = 0xFFF67055.toInt()),
NameColor(lightColor = 0xFF067919.toInt(), darkColor = 0xFF0AB827.toInt()),
NameColor(lightColor = 0xFF6447F5.toInt(), darkColor = 0xFF9986F9.toInt()),
NameColor(lightColor = 0xFF76681E.toInt(), darkColor = 0xFFB89B0A.toInt()),
NameColor(lightColor = 0xFF067462.toInt(), darkColor = 0xFF09B397.toInt()),
NameColor(lightColor = 0xFFAF0BD0.toInt(), darkColor = 0xFFE06EF7.toInt()),
NameColor(lightColor = 0xFF3D7406.toInt(), darkColor = 0xFF5EB309.toInt()),
NameColor(lightColor = 0xFF0A69C7.toInt(), darkColor = 0xFF429CF5.toInt()),
NameColor(lightColor = 0xFFCB0B6B.toInt(), darkColor = 0xFFF76EB2.toInt()),
NameColor(lightColor = 0xFF9C5711.toInt(), darkColor = 0xFFE97A0C.toInt()),
NameColor(lightColor = 0xFF067940.toInt(), darkColor = 0xFF09B35E.toInt()),
NameColor(lightColor = 0xFF8F2AF4.toInt(), darkColor = 0xFFBD81F8.toInt()),
NameColor(lightColor = 0xFF5E6E0C.toInt(), darkColor = 0xFF8FAA09.toInt()),
NameColor(lightColor = 0xFF077288.toInt(), darkColor = 0xFF0BABCB.toInt()),
NameColor(lightColor = 0xFFC20AA3.toInt(), darkColor = 0xFFF75FDD.toInt()),
NameColor(lightColor = 0xFF1A7906.toInt(), darkColor = 0xFF27B80A.toInt()),
NameColor(lightColor = 0xFF3454F4.toInt(), darkColor = 0xFF778DF8.toInt()),
NameColor(lightColor = 0xFFD00B2C.toInt(), darkColor = 0xFFF76E85.toInt())
)
}
@JvmField
val UNKNOWN_CONTACT = Bubbles.STEEL
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.conversation.colors
import org.thoughtcrime.securesms.util.Projection
/**
* Denotes that a class can be colorized. The class is responsible for
* generating its own projection.
*/
interface Colorizable {
val colorizerProjections: List<Projection>
}

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.conversation.colors
import android.content.Context
import android.graphics.Color
import android.view.View
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Projection
/**
* Helper class for all things ChatColors.
*
* - Maintains a mapping for group recipient colors
* - Gives easy access to different bubble colors
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
*/
class Colorizer(private val colorizerView: ColorizerView) : RecyclerView.OnScrollListener(), View.OnLayoutChangeListener {
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
@ColorInt
fun getOutgoingBodyTextColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.white)
}
@ColorInt
fun getOutgoingFooterTextColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.conversation_item_outgoing_footer_fg)
}
@ColorInt
fun getOutgoingFooterIconColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.conversation_item_outgoing_footer_fg)
}
@ColorInt
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int = groupSenderColors[recipient.id]?.getColor(context) ?: Color.TRANSPARENT
fun attachToRecyclerView(recyclerView: RecyclerView) {
recyclerView.addOnScrollListener(this)
recyclerView.addOnLayoutChangeListener(this)
}
fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
groupSenderColors.clear()
groupSenderColors.putAll(nameColorMap)
}
fun onChatColorsChanged(chatColors: ChatColors) {
colorizerView.background = chatColors.chatBubbleMask
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
applyClipPathsToMaskedGradient(recyclerView)
}
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
applyClipPathsToMaskedGradient(v as RecyclerView)
}
fun applyClipPathsToMaskedGradient(recyclerView: RecyclerView) {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
val projections: List<Projection> = (firstVisibleItemPosition..lastVisibleItemPosition)
.mapNotNull { recyclerView.findViewHolderForAdapterPosition(it) as? Colorizable }
.map {
it.colorizerProjections
.map { p -> Projection.translateFromRootToDescendantCoords(p, colorizerView) }
}
.flatten()
if (projections.isNotEmpty()) {
colorizerView.visibility = View.VISIBLE
colorizerView.setProjections(projections)
} else {
colorizerView.visibility = View.GONE
}
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.conversation.colors
import android.content.Context
import android.graphics.Canvas
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import org.thoughtcrime.securesms.util.Projection
/**
* ColorizerView takes a list of projections and uses them to create a mask over it's background.
*/
class ColorizerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val clipPath = Path()
private var projections: List<Projection> = listOf()
fun setProjections(projections: List<Projection>) {
this.projections = projections
invalidate()
}
override fun draw(canvas: Canvas) {
if (projections.isNotEmpty()) {
canvas.save()
clipPath.rewind()
projections.forEach {
it.applyToPath(clipPath)
}
canvas.clipPath(clipPath)
super.draw(canvas)
canvas.restore()
} else {
super.draw(canvas)
}
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.conversation.colors
import android.content.Context
import androidx.annotation.ColorInt
import org.thoughtcrime.securesms.util.ThemeUtil
/**
* Class which stores information for a Recipient's name color in a group.
*/
class NameColor(
@ColorInt private val lightColor: Int,
@ColorInt private val darkColor: Int
) {
@ColorInt
fun getColor(context: Context): Int {
return if (ThemeUtil.isDarkTheme(context)) {
darkColor
} else {
lightColor
}
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.util.MappingModel
class ChatColorMappingModel(
val chatColors: ChatColors,
val isSelected: Boolean,
val isAuto: Boolean
) : MappingModel<ChatColorMappingModel> {
val isCustom: Boolean = chatColors.id is ChatColors.Id.Custom
override fun areItemsTheSame(newItem: ChatColorMappingModel): Boolean {
return chatColors == newItem.chatColors && isAuto == newItem.isAuto
}
override fun areContentsTheSame(newItem: ChatColorMappingModel): Boolean {
return areItemsTheSame(newItem) && isSelected == newItem.isSelected
}
}

View File

@@ -0,0 +1,178 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import android.content.res.TypedArray
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.DeliveryStatusView
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerView
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.util.Locale
private val TAG = Log.tag(ChatColorPreviewView::class.java)
class ChatColorPreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val wallpaper: ImageView
private val wallpaperDim: View
private val colorizerView: ColorizerView
private val recv1: Bubble
private val sent1: Bubble
private val recv2: Bubble
private val sent2: Bubble
private val bubbleCount: Int
private val colorizer: Colorizer
private var chatColors: ChatColors? = null
init {
inflate(context, R.layout.chat_colors_preview_view, this)
var typedArray: TypedArray? = null
try {
typedArray = context.obtainStyledAttributes(attrs, R.styleable.ChatColorPreviewView, 0, 0)
bubbleCount = typedArray.getInteger(R.styleable.ChatColorPreviewView_ccpv_chat_bubble_count, 2)
assert(bubbleCount == 2 || bubbleCount == 4) {
Log.e(TAG, "Bubble count must be 2 or 4")
}
recv1 = Bubble(
findViewById(R.id.bubble_1),
findViewById(R.id.bubble_1_text),
findViewById(R.id.bubble_1_time),
null
)
sent1 = Bubble(
findViewById(R.id.bubble_2),
findViewById(R.id.bubble_2_text),
findViewById(R.id.bubble_2_time),
findViewById(R.id.bubble_2_delivery)
)
recv2 = Bubble(
findViewById(R.id.bubble_3),
findViewById(R.id.bubble_3_text),
findViewById(R.id.bubble_3_time),
null
)
sent2 = Bubble(
findViewById(R.id.bubble_4),
findViewById(R.id.bubble_4_text),
findViewById(R.id.bubble_4_time),
findViewById(R.id.bubble_4_delivery)
)
val now: String = DateUtils.getExtendedRelativeTimeSpanString(context, Locale.getDefault(), System.currentTimeMillis())
listOf(sent1, sent2, recv1, recv2).forEach {
it.time.text = now
it.delivery?.setRead()
}
if (bubbleCount == 2) {
recv2.bubble.visibility = View.GONE
sent2.bubble.visibility = View.GONE
}
wallpaper = findViewById(R.id.wallpaper)
wallpaperDim = findViewById(R.id.wallpaper_dim)
colorizerView = findViewById(R.id.colorizer)
colorizer = Colorizer(colorizerView)
} finally {
typedArray?.recycle()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (chatColors != null) {
setChatColors(requireNotNull(chatColors))
}
}
fun setWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(wallpaper)
if (ThemeUtil.isDarkTheme(context)) {
wallpaperDim.alpha = chatWallpaper.dimLevelForDarkTheme
} else {
wallpaperDim.alpha = 0f
}
} else {
wallpaper.background = null
wallpaperDim.alpha = 0f
}
val backgroundColor = if (chatWallpaper != null) {
R.color.conversation_item_wallpaper_bubble_color
} else {
R.color.signal_background_secondary
}
listOf(recv1, recv2).forEach {
it.bubble.background.colorFilter = PorterDuffColorFilter(
ContextCompat.getColor(context, backgroundColor),
PorterDuff.Mode.SRC_IN
)
}
}
fun setChatColors(chatColors: ChatColors) {
this.chatColors = chatColors
val sentBubbles = listOf(sent1, sent2)
sentBubbles.forEach {
it.bubble.background.colorFilter = chatColors.chatBubbleColorFilter
}
val mask: Drawable = chatColors.chatBubbleMask
val bubbles = if (bubbleCount == 4) {
listOf(sent1, sent2)
} else {
listOf(sent1)
}
val projections = bubbles.map {
Projection.relativeToViewWithCommonRoot(it.bubble, colorizerView, Projection.Corners(ViewUtil.dpToPx(10).toFloat()))
}
colorizerView.setProjections(projections)
colorizerView.visibility = View.VISIBLE
colorizerView.background = mask
sentBubbles.forEach {
it.body.setTextColor(colorizer.getOutgoingBodyTextColor(context))
it.time.setTextColor(colorizer.getOutgoingFooterTextColor(context))
it.delivery?.setTint(colorizer.getOutgoingFooterIconColor(context))
}
}
private class Bubble(val bubble: View, val body: TextView, val time: TextView, val delivery: DeliveryStatusView?)
}

View File

@@ -0,0 +1,126 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import android.graphics.Path
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.customizeOnDraw
class ChatColorSelectionAdapter(
context: Context,
private val callbacks: Callbacks
) : MappingAdapter() {
init {
val popupWindow = ChatSelectionContextMenu(context)
registerFactory(
ChatColorMappingModel::class.java,
LayoutFactory(
{ v -> ChatColorMappingViewHolder(v, popupWindow, callbacks) },
R.layout.chat_color_selection_adapter_item
)
)
registerFactory(
CustomColorMappingModel::class.java,
LayoutFactory(
{ v -> CustomColorMappingViewHolder(v, callbacks::onAdd) },
R.layout.chat_color_custom_adapter_item
)
)
}
class CustomColorMappingViewHolder(
itemView: View,
onClicked: () -> Unit
) : MappingViewHolder<CustomColorMappingModel>(itemView) {
init {
itemView.setOnClickListener { onClicked() }
}
override fun bind(model: CustomColorMappingModel) = Unit
}
class ChatColorMappingViewHolder(
itemView: View,
private val popupWindow: ChatSelectionContextMenu,
private val callbacks: Callbacks
) : MappingViewHolder<ChatColorMappingModel>(itemView) {
private val preview: ImageView = itemView.findViewById(R.id.chat_color)
private val auto: TextView = itemView.findViewById(R.id.auto)
private val edit: View = itemView.findViewById(R.id.edit)
override fun bind(model: ChatColorMappingModel) {
itemView.isSelected = model.isSelected
auto.visibility = if (model.isAuto) View.VISIBLE else View.GONE
edit.visibility = if (model.isCustom) View.VISIBLE else View.GONE
preview.setOnClickListener {
if (model.isCustom && model.isSelected) {
callbacks.onEdit(model.chatColors)
} else {
callbacks.onSelect(model.chatColors)
}
}
if (model.isCustom) {
preview.setOnLongClickListener {
popupWindow.callback = CallbackBinder(callbacks, model.chatColors)
popupWindow.show(itemView)
true
}
} else {
preview.setOnLongClickListener(null)
preview.isLongClickable = false
}
val mask = model.chatColors.chatBubbleMask
preview.setImageDrawable(
mask.customizeOnDraw { wrapped, canvas ->
val circlePath = Path()
val bounds = canvas.clipBounds
circlePath.addCircle(
bounds.width() / 2f,
bounds.height() / 2f,
bounds.width() / 2f,
Path.Direction.CW
)
canvas.save()
canvas.clipPath(circlePath)
wrapped.draw(canvas)
canvas.restore()
}
)
}
}
class CallbackBinder(private val callbacks: Callbacks, private val chatColors: ChatColors) : ChatSelectionContextMenu.Callback {
override fun onEditPressed() {
callbacks.onEdit(chatColors)
}
override fun onDuplicatePressed() {
callbacks.onDuplicate(chatColors)
}
override fun onDeletePressed() {
callbacks.onDelete(chatColors)
}
}
interface Callbacks {
fun onSelect(chatColors: ChatColors)
fun onEdit(chatColors: ChatColors)
fun onDuplicate(chatColors: ChatColors)
fun onDelete(chatColors: ChatColors)
fun onAdd()
}
}

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
class ChatColorSelectionFragment : Fragment(R.layout.chat_color_selection_fragment) {
private lateinit var viewModel: ChatColorSelectionViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args: ChatColorSelectionFragmentArgs = ChatColorSelectionFragmentArgs.fromBundle(requireArguments())
viewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val preview: ChatColorPreviewView = view.findViewById(R.id.preview)
val recycler: RecyclerView = view.findViewById(R.id.recycler)
val adapter = ChatColorSelectionAdapter(
requireContext(),
Callbacks(args, view)
)
recycler.itemAnimator = null
recycler.adapter = adapter
toolbar.setNavigationOnClickListener {
Navigation.findNavController(it).popBackStack()
}
viewModel.state.observe(viewLifecycleOwner) { state ->
preview.setWallpaper(state.wallpaper)
if (state.chatColors != null) {
preview.setChatColors(state.chatColors)
}
adapter.submitList(state.chatColorModels)
}
viewModel.events.observe(viewLifecycleOwner) { event ->
if (event is ChatColorSelectionViewModel.Event.ConfirmDeletion) {
showWarningDialog(event)
}
}
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
private fun showWarningDialog(confirmDeletion: ChatColorSelectionViewModel.Event.ConfirmDeletion) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ChatColorSelectionFragment__delete_color)
.setMessage(resources.getQuantityString(R.plurals.ChatColorSelectionFragment__this_custom_color_is_used, confirmDeletion.usageCount, confirmDeletion.usageCount))
.setPositiveButton(R.string.delete) { dialog, _ ->
viewModel.deleteNow(confirmDeletion.chatColors)
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
inner class Callbacks(
private val args: ChatColorSelectionFragmentArgs,
private val view: View
) : ChatColorSelectionAdapter.Callbacks {
override fun onSelect(chatColors: ChatColors) {
viewModel.save(chatColors)
}
override fun onEdit(chatColors: ChatColors) {
val startPage = if (chatColors.getColors().size == 1) 0 else 1
val directions = ChatColorSelectionFragmentDirections
.actionChatColorSelectionFragmentToCustomChatColorCreatorFragment(args.recipientId, startPage)
.setChatColorId(chatColors.id.longValue)
Navigation.findNavController(view).navigate(directions)
}
override fun onDuplicate(chatColors: ChatColors) {
viewModel.duplicate(chatColors)
}
override fun onDelete(chatColors: ChatColors) {
viewModel.startDeletion(chatColors)
}
override fun onAdd() {
val directions = ChatColorSelectionFragmentDirections.actionChatColorSelectionFragmentToCustomChatColorCreatorFragment(args.recipientId, 0)
Navigation.findNavController(view).navigate(directions)
}
}
}

View File

@@ -0,0 +1,103 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
sealed class ChatColorSelectionRepository(context: Context) {
protected val context: Context = context.applicationContext
abstract fun getWallpaper(consumer: (ChatWallpaper?) -> Unit)
abstract fun getChatColors(consumer: (ChatColors) -> Unit)
abstract fun save(chatColors: ChatColors, onSaved: () -> Unit)
fun duplicate(chatColors: ChatColors) {
SignalExecutors.BOUNDED.execute {
val duplicate = chatColors.withId(ChatColors.Id.NotSet)
DatabaseFactory.getChatColorsDatabase(context).saveChatColors(duplicate)
}
}
fun getUsageCount(chatColors: ChatColors, consumer: (Int) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(DatabaseFactory.getRecipientDatabase(context).getColorUsageCount(chatColors))
}
}
fun delete(chatColors: ChatColors, onDeleted: () -> Unit) {
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getChatColorsDatabase(context).deleteChatColors(chatColors)
onDeleted()
}
}
private class Global(context: Context) : ChatColorSelectionRepository(context) {
override fun getWallpaper(consumer: (ChatWallpaper?) -> Unit) {
consumer(SignalStore.wallpaper().wallpaper)
}
override fun getChatColors(consumer: (ChatColors) -> Unit) {
if (SignalStore.chatColorsValues().hasChatColors) {
consumer(requireNotNull(SignalStore.chatColorsValues().chatColors))
} else {
getWallpaper { wallpaper ->
if (wallpaper != null) {
consumer(wallpaper.autoChatColors)
} else {
consumer(ChatColorsPalette.Bubbles.default)
}
}
}
}
override fun save(chatColors: ChatColors, onSaved: () -> Unit) {
if (chatColors.id == ChatColors.Id.Auto) {
SignalStore.chatColorsValues().chatColors = null
} else {
SignalStore.chatColorsValues().chatColors = chatColors
}
onSaved()
}
}
private class Single(context: Context, private val recipientId: RecipientId) : ChatColorSelectionRepository(context) {
override fun getWallpaper(consumer: (ChatWallpaper?) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
consumer(recipient.wallpaper)
}
}
override fun getChatColors(consumer: (ChatColors) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
consumer(recipient.chatColors)
}
}
override fun save(chatColors: ChatColors, onSaved: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.setColor(recipientId, chatColors)
onSaved()
}
}
}
companion object {
fun create(context: Context, recipientId: RecipientId?): ChatColorSelectionRepository {
return if (recipientId != null) {
Single(context, recipientId)
} else {
Global(context)
}
}
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.util.MappingModelList
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
data class ChatColorSelectionState(
val wallpaper: ChatWallpaper? = null,
val chatColors: ChatColors? = null,
private val chatColorOptions: List<ChatColors> = listOf()
) {
val chatColorModels: MappingModelList
init {
val models: List<ChatColorMappingModel> = chatColorOptions.map { chatColors ->
ChatColorMappingModel(
chatColors,
chatColors == this.chatColors,
false
)
}.toList()
val defaultModel: ChatColorMappingModel = if (wallpaper != null) {
ChatColorMappingModel(
wallpaper.autoChatColors,
chatColors?.id == ChatColors.Id.Auto,
true
)
} else {
ChatColorMappingModel(
ChatColorsPalette.Bubbles.default,
chatColors?.id == ChatColors.Id.Auto,
true
)
}
chatColorModels = MappingModelList().apply {
add(defaultModel)
addAll(models)
add(CustomColorMappingModel())
}
}
}

View File

@@ -0,0 +1,74 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
class ChatColorSelectionViewModel(private val repository: ChatColorSelectionRepository) : ViewModel() {
private val store = Store<ChatColorSelectionState>(ChatColorSelectionState())
private val chatColors = ChatColorsOptionsLiveData()
private val internalEvents = SingleLiveEvent<Event>()
val state: LiveData<ChatColorSelectionState> = store.stateLiveData
val events: LiveData<Event> = internalEvents
init {
store.update(chatColors) { colors, state -> state.copy(chatColorOptions = colors) }
}
fun refresh() {
repository.getWallpaper { wallpaper ->
store.update { it.copy(wallpaper = wallpaper) }
}
repository.getChatColors { chatColors ->
store.update { it.copy(chatColors = chatColors) }
}
}
fun save(chatColors: ChatColors) {
repository.save(chatColors, this::refresh)
}
fun duplicate(chatColors: ChatColors) {
repository.duplicate(chatColors)
}
fun startDeletion(chatColors: ChatColors) {
repository.getUsageCount(chatColors) {
if (it > 0) {
internalEvents.postValue(Event.ConfirmDeletion(it, chatColors))
} else {
deleteNow(chatColors)
}
}
}
fun deleteNow(chatColors: ChatColors) {
repository.delete(chatColors, this::refresh)
}
class Factory(private val repository: ChatColorSelectionRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T = requireNotNull(modelClass.cast(ChatColorSelectionViewModel(repository)))
}
companion object {
fun getOrCreate(activity: FragmentActivity, recipientId: RecipientId?): ChatColorSelectionViewModel {
val repository = ChatColorSelectionRepository.create(activity, recipientId)
val viewModelFactory = Factory(repository)
return ViewModelProviders.of(activity, viewModelFactory).get(ChatColorSelectionViewModel::class.java)
}
}
sealed class Event {
class ConfirmDeletion(val usageCount: Int, val chatColors: ChatColors) : Event()
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import androidx.lifecycle.LiveData
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.ChatColorsDatabase
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import java.util.concurrent.Executor
class ChatColorsOptionsLiveData : LiveData<List<ChatColors>>() {
private val chatColorsDatabase: ChatColorsDatabase = DatabaseFactory.getChatColorsDatabase(ApplicationDependencies.getApplication())
private val observer: DatabaseObserver.Observer = DatabaseObserver.Observer { refreshChatColors() }
private val executor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
override fun onActive() {
refreshChatColors()
ApplicationDependencies.getDatabaseObserver().registerChatColorsObserver(observer)
}
override fun onInactive() {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
private fun refreshChatColors() {
executor.execute {
val options = mutableListOf<ChatColors>().apply {
addAll(ChatColorsPalette.Bubbles.all)
addAll(chatColorsDatabase.getSavedChatColors())
}
postValue(options)
}
}
}

View File

@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
class ChatSelectionContextMenu(val context: Context) : PopupWindow(context) {
var callback: Callback? = null
init {
contentView = LayoutInflater.from(context).inflate(R.layout.chat_colors_fragment_context_menu, null, false)
if (Build.VERSION.SDK_INT >= 21) {
elevation = ViewUtil.dpToPx(8).toFloat()
}
isOutsideTouchable = false
isFocusable = true
width = ViewUtil.dpToPx(280)
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.round_background))
val edit: View = contentView.findViewById(R.id.context_menu_edit)
val duplicate: View = contentView.findViewById(R.id.context_menu_duplicate)
val delete: View = contentView.findViewById(R.id.context_menu_delete)
edit.setOnClickListener {
dismiss()
callback?.onEditPressed()
}
duplicate.setOnClickListener {
dismiss()
callback?.onDuplicatePressed()
}
delete.setOnClickListener {
dismiss()
callback?.onDeletePressed()
}
}
fun show(anchor: View) {
val rect = Rect()
val root: ViewGroup = anchor.rootView as ViewGroup
anchor.getDrawingRect(rect)
root.offsetDescendantRectToMyCoords(anchor, rect)
if (rect.bottom + contentView.height > root.bottom) {
showAsDropDown(anchor, 0, -(contentView.height + anchor.height))
} else {
showAsDropDown(anchor, 0, 0)
}
}
interface Callback {
fun onEditPressed()
fun onDuplicatePressed()
fun onDeletePressed()
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.conversation.colors.ui
import org.thoughtcrime.securesms.util.MappingModel
class CustomColorMappingModel : MappingModel<CustomColorMappingModel> {
override fun areItemsTheSame(newItem: CustomColorMappingModel): Boolean {
return true
}
override fun areContentsTheSame(newItem: CustomColorMappingModel): Boolean {
return true
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.navigation.Navigation
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.thoughtcrime.securesms.R
class CustomChatColorCreatorFragment : Fragment(R.layout.custom_chat_color_creator_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val tabLayout: TabLayout = view.findViewById(R.id.tab_layout)
val pager: ViewPager2 = view.findViewById(R.id.pager)
val adapter = CustomChatColorPagerAdapter(this, requireArguments())
val tabLayoutMediator = TabLayoutMediator(tabLayout, pager) { tab, position ->
tab.setText(
if (position == 0) {
R.string.CustomChatColorCreatorFragment__solid
} else {
R.string.CustomChatColorCreatorFragment__gradient
}
)
}
toolbar.setNavigationOnClickListener {
Navigation.findNavController(it).popBackStack()
}
pager.isUserInputEnabled = false
pager.adapter = adapter
tabLayoutMediator.attach()
val startPage = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments()).startPage
pager.setCurrentItem(startPage, false)
}
}

View File

@@ -0,0 +1,366 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.View
import android.widget.SeekBar
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.appcompat.widget.AppCompatSeekBar
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorPreviewView
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorSelectionViewModel
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.customizeOnDraw
private const val MAX_SEEK_DIVISIONS = 1023
private const val MAX_HUE = 360
private const val PAGE_ARG = "page"
private const val SINGLE_PAGE = 0
private const val GRADIENT_PAGE = 1
class CustomChatColorCreatorPageFragment :
Fragment(R.layout.custom_chat_color_creator_fragment_page) {
private lateinit var hueSlider: AppCompatSeekBar
private lateinit var saturationSlider: AppCompatSeekBar
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args: CustomChatColorCreatorFragmentArgs = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments())
val chatColorSelectionViewModel: ChatColorSelectionViewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
val page: Int = requireArguments().getInt(PAGE_ARG)
val factory: CustomChatColorCreatorViewModel.Factory = CustomChatColorCreatorViewModel.Factory(MAX_SEEK_DIVISIONS, ChatColors.Id.forLongValue(args.chatColorId), args.recipientId, createRepository())
val viewModel: CustomChatColorCreatorViewModel = ViewModelProviders.of(
requireParentFragment(),
factory
)[CustomChatColorCreatorViewModel::class.java]
val preview: ChatColorPreviewView = view.findViewById(R.id.chat_color_preview)
val hueThumb = ThumbDrawable(requireContext())
val saturationThumb = ThumbDrawable(requireContext())
val gradientTool: CustomChatColorGradientToolView = view.findViewById(R.id.gradient_tool)
val save: View = view.findViewById(R.id.save)
if (page == SINGLE_PAGE) {
gradientTool.visibility = View.GONE
} else {
gradientTool.setListener(object : CustomChatColorGradientToolView.Listener {
override fun onDegreesChanged(degrees: Float) {
viewModel.setDegrees(degrees)
}
override fun onSelectedEdgeChanged(edge: CustomChatColorEdge) {
viewModel.setSelectedEdge(edge)
}
})
}
hueSlider = view.findViewById(R.id.hue_slider)
saturationSlider = view.findViewById(R.id.saturation_slider)
hueSlider.thumb = hueThumb
saturationSlider.thumb = saturationThumb
hueSlider.max = MAX_SEEK_DIVISIONS
saturationSlider.max = MAX_SEEK_DIVISIONS
val colors: IntArray = (0..MAX_SEEK_DIVISIONS).map { hue ->
ColorUtils.HSLToColor(
floatArrayOf(
hue.toHue(MAX_SEEK_DIVISIONS),
1f,
calculateLightness(hue.toFloat(), valueFor60To80 = 0.4f)
)
)
}.toIntArray()
val hueGradientDrawable = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colors)
hueSlider.progressDrawable = hueGradientDrawable.forSeekBar()
val saturationProgressDrawable = GradientDrawable().apply {
orientation = GradientDrawable.Orientation.LEFT_RIGHT
}
saturationSlider.progressDrawable = saturationProgressDrawable.forSeekBar()
hueSlider.setOnSeekBarChangeListener(
OnProgressChangedListener {
viewModel.setHueProgress(it)
}
)
saturationSlider.setOnSeekBarChangeListener(
OnProgressChangedListener {
viewModel.setSaturationProgress(it)
}
)
viewModel.events.observe(viewLifecycleOwner) { event ->
when (event) {
is CustomChatColorCreatorViewModel.Event.SaveNow -> {
viewModel.saveNow(event.chatColors) { colors ->
chatColorSelectionViewModel.save(colors)
}
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
}
is CustomChatColorCreatorViewModel.Event.Warn -> MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.CustomChatColorCreatorFragmentPage__edit_color)
.setMessage(resources.getQuantityString(R.plurals.CustomChatColorCreatorFragmentPage__this_color_is_used, event.usageCount, event.usageCount))
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.dismiss()
viewModel.saveNow(event.chatColors) { colors ->
chatColorSelectionViewModel.save(colors)
}
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
}
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.loading) {
return@observe
}
val sliderState: ColorSlidersState = requireNotNull(state.sliderStates[state.selectedEdge])
hueSlider.progress = sliderState.huePosition
saturationSlider.progress = sliderState.saturationPosition
val color: Int = sliderState.getColor()
hueThumb.setColor(sliderState.getHueColor())
saturationThumb.setColor(color)
saturationProgressDrawable.colors = sliderState.getSaturationColors()
preview.setWallpaper(state.wallpaper)
if (page == 0) {
val chatColor = ChatColors.forColor(ChatColors.Id.NotSet, color)
preview.setChatColors(chatColor)
save.setOnClickListener {
viewModel.startSave(chatColor)
}
} else {
val topEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.TOP])
val bottomEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.BOTTOM])
val chatColor: ChatColors = ChatColors.forGradient(
ChatColors.Id.NotSet,
ChatColors.LinearGradient(
state.degrees,
intArrayOf(topEdgeColor.getColor(), bottomEdgeColor.getColor()),
floatArrayOf(0f, 1f)
),
)
preview.setChatColors(chatColor)
gradientTool.setSelectedEdge(state.selectedEdge)
gradientTool.setDegrees(state.degrees)
gradientTool.setTopColor(topEdgeColor.getColor())
gradientTool.setBottomColor(bottomEdgeColor.getColor())
save.setOnClickListener {
viewModel.startSave(chatColor)
}
}
}
}
private fun createRepository(): CustomChatColorCreatorRepository {
return CustomChatColorCreatorRepository(requireContext())
}
@ColorInt
private fun ColorSlidersState.getHueColor(): Int {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
return ColorUtils.HSLToColor(
floatArrayOf(
hue,
1f,
calculateLightness(hue, 0.4f)
)
)
}
@ColorInt
private fun ColorSlidersState.getColor(): Int {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
return ColorUtils.HSLToColor(
floatArrayOf(
hue,
saturationPosition.toUnit(MAX_SEEK_DIVISIONS),
calculateLightness(hue)
)
)
}
private fun ColorSlidersState.getSaturationColors(): IntArray {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
val level = calculateLightness(hue)
return listOf(0f, 1f).map {
ColorUtils.HSLToColor(
floatArrayOf(
hue, it, level
)
)
}.toIntArray()
}
private fun calculateLightness(hue: Float, valueFor60To80: Float = 0.3f): Float {
val point1 = PointF()
val point2 = PointF()
if (hue >= 0f && hue < 60f) {
point1.set(0f, 0.45f)
point2.set(60f, valueFor60To80)
} else if (hue >= 60f && hue < 180f) {
return valueFor60To80
} else if (hue >= 180f && hue < 240f) {
point1.set(180f, valueFor60To80)
point2.set(240f, 0.5f)
} else if (hue >= 240f && hue < 300f) {
point1.set(240f, 0.5f)
point2.set(300f, 0.4f)
} else if (hue >= 300f && hue < 360f) {
point1.set(300f, 0.4f)
point2.set(360f, 0.45f)
} else {
return 0.45f
}
return interpolate(point1, point2, hue)
}
private fun interpolate(point1: PointF, point2: PointF, x: Float): Float {
return ((point1.y * (point2.x - x)) + (point2.y * (x - point1.x))) / (point2.x - point1.x)
}
private fun Number.toHue(max: Number): Float {
return Util.clamp(toFloat() * (MAX_HUE / max.toFloat()), 0f, MAX_HUE.toFloat())
}
private fun Number.toUnit(max: Number): Float {
return Util.clamp(toFloat() / max.toFloat(), 0f, 1f)
}
private fun Drawable.forSeekBar(): Drawable {
val height: Int = ViewUtil.dpToPx(8)
val radii: FloatArray = (1..8).map { 50f }.toFloatArray()
val bounds = RectF()
val clipPath = Path()
return customizeOnDraw { wrapped, canvas ->
canvas.save()
bounds.set(this.bounds)
bounds.inset(0f, (height / 2f) + 1)
clipPath.rewind()
clipPath.addRoundRect(bounds, radii, Path.Direction.CW)
canvas.clipPath(clipPath)
wrapped.draw(canvas)
canvas.restore()
}
}
private class OnProgressChangedListener(private val updateFn: (Int) -> Unit) :
SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateFn(progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
}
private class ThumbDrawable(context: Context) : Drawable() {
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.TRANSPARENT
}
private val borderWidth: Int = ViewUtil.dpToPx(THUMB_MARGIN)
private val thumbInnerSize: Int = ViewUtil.dpToPx(THUMB_INNER_SIZE)
private val innerRadius: Float = thumbInnerSize / 2f
private val thumbSize: Float = (thumbInnerSize + borderWidth).toFloat()
private val thumbRadius: Float = thumbSize / 2f
override fun getIntrinsicHeight(): Int = ViewUtil.dpToPx(48)
override fun getIntrinsicWidth(): Int = ViewUtil.dpToPx(48)
fun setColor(@ColorInt color: Int) {
paint.color = color
invalidateSelf()
}
override fun draw(canvas: Canvas) {
canvas.drawCircle(
(bounds.width() / 2f) + bounds.left,
(bounds.height() / 2f) + bounds.top,
thumbRadius,
borderPaint
)
canvas.drawCircle(
(bounds.width() / 2f) + bounds.left,
(bounds.height() / 2f) + bounds.top,
innerRadius,
paint
)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
companion object {
@Dimension(unit = Dimension.DP)
private val THUMB_INNER_SIZE = 16
@Dimension(unit = Dimension.DP)
private val THUMB_MARGIN = 1
}
}
companion object {
fun forSingle(bundle: Bundle): Fragment = forPage(SINGLE_PAGE, bundle)
fun forGradient(bundle: Bundle): Fragment = forPage(GRADIENT_PAGE, bundle)
private fun forPage(page: Int, bundle: Bundle): Fragment = CustomChatColorCreatorPageFragment().apply {
arguments = Bundle().apply {
putInt(PAGE_ARG, page)
putAll(bundle)
}
}
}
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
class CustomChatColorCreatorRepository(private val context: Context) {
fun loadColors(chatColorsId: ChatColors.Id, consumer: (ChatColors) -> Unit) {
SignalExecutors.BOUNDED.execute {
val chatColorsDatabase = DatabaseFactory.getChatColorsDatabase(context)
val chatColors = chatColorsDatabase.getById(chatColorsId)
consumer(chatColors)
}
}
fun getWallpaper(recipientId: RecipientId?, consumer: (ChatWallpaper?) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (recipientId != null) {
val recipient = Recipient.resolved(recipientId)
consumer(recipient.wallpaper)
} else {
consumer(SignalStore.wallpaper().wallpaper)
}
}
}
fun setChatColors(chatColors: ChatColors, consumer: (ChatColors) -> Unit) {
SignalExecutors.BOUNDED.execute {
val chatColorsDatabase = DatabaseFactory.getChatColorsDatabase(context)
val savedColors = chatColorsDatabase.saveChatColors(chatColors)
consumer(savedColors)
}
}
fun getUsageCount(chatColors: ChatColors, consumer: (Int) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipientsDatabase = DatabaseFactory.getRecipientDatabase(context)
consumer(recipientsDatabase.getColorUsageCount(chatColors))
}
}
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.util.EnumMap
data class CustomChatColorCreatorState(
val loading: Boolean,
val wallpaper: ChatWallpaper?,
val sliderStates: EnumMap<CustomChatColorEdge, ColorSlidersState>,
val selectedEdge: CustomChatColorEdge,
val degrees: Float
)
data class ColorSlidersState(val huePosition: Int, val saturationPosition: Int)

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import androidx.core.graphics.ColorUtils
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.EnumMap
import kotlin.math.roundToInt
class CustomChatColorCreatorViewModel(
private val maxSliderValue: Int,
private val chatColorsId: ChatColors.Id,
private val recipientId: RecipientId?,
private val repository: CustomChatColorCreatorRepository
) : ViewModel() {
private val store = Store<CustomChatColorCreatorState>(getInitialState())
private val internalEvents = SingleLiveEvent<Event>()
val state: LiveData<CustomChatColorCreatorState> = store.stateLiveData
val events: LiveData<Event> = internalEvents
init {
repository.getWallpaper(recipientId) { wallpaper ->
store.update { it.copy(wallpaper = wallpaper) }
}
if (chatColorsId is ChatColors.Id.Custom) {
repository.loadColors(chatColorsId) {
val colors: IntArray = it.getColors()
val topColor: Int = colors.first()
val bottomColor: Int = colors.last()
val topHsl = floatArrayOf(0f, 0f, 0f)
val bottomHsl = floatArrayOf(0f, 0f, 0f)
ColorUtils.colorToHSL(topColor, topHsl)
ColorUtils.colorToHSL(bottomColor, bottomHsl)
val topHue: Float = topHsl[0]
val topSaturation: Float = topHsl[1]
val bottomHue: Float = bottomHsl[0]
val bottomSaturation: Float = bottomHsl[1]
val topEdge = ColorSlidersState(
huePosition = ((topHue / 360f) * maxSliderValue).roundToInt(),
saturationPosition = (topSaturation * maxSliderValue).roundToInt()
)
val bottomEdge = ColorSlidersState(
huePosition = ((bottomHue / 360f) * maxSliderValue).roundToInt(),
saturationPosition = (bottomSaturation * maxSliderValue).roundToInt()
)
store.update { state ->
state.copy(
degrees = it.getDegrees(),
loading = false,
sliderStates = EnumMap(
mapOf(
CustomChatColorEdge.TOP to topEdge,
CustomChatColorEdge.BOTTOM to bottomEdge
)
)
)
}
}
}
}
fun setHueProgress(progress: Int) {
store.update { state ->
state.copy(
sliderStates = state.sliderStates.apply {
val oldData: ColorSlidersState = requireNotNull(get(state.selectedEdge))
put(state.selectedEdge, oldData.copy(huePosition = progress))
}
)
}
}
fun setSaturationProgress(progress: Int) {
store.update { state ->
state.copy(
sliderStates = state.sliderStates.apply {
val oldData: ColorSlidersState = requireNotNull(get(state.selectedEdge))
put(state.selectedEdge, oldData.copy(saturationPosition = progress))
}
)
}
}
fun setDegrees(degrees: Float) {
store.update { it.copy(degrees = degrees) }
}
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
store.update { it.copy(selectedEdge = selectedEdge) }
}
fun startSave(chatColors: ChatColors) {
if (chatColors.id is ChatColors.Id.Custom) {
repository.getUsageCount(chatColors) {
if (it > 0) {
internalEvents.postValue(Event.Warn(it, chatColors))
} else {
internalEvents.postValue(Event.SaveNow(chatColors))
}
}
} else {
internalEvents.postValue(Event.SaveNow(chatColors))
}
}
fun saveNow(chatColors: ChatColors, onSaved: (ChatColors) -> Unit) {
repository.setChatColors(chatColors.withId(chatColorsId), onSaved)
}
private fun getInitialState() = CustomChatColorCreatorState(
loading = chatColorsId is ChatColors.Id.Custom,
wallpaper = null,
sliderStates = EnumMap(
mapOf(
CustomChatColorEdge.TOP to ColorSlidersState(maxSliderValue / 2, maxSliderValue / 2),
CustomChatColorEdge.BOTTOM to ColorSlidersState(maxSliderValue / 2, maxSliderValue / 2)
)
),
selectedEdge = CustomChatColorEdge.BOTTOM,
degrees = 180f
)
class Factory(
private val maxSliderValue: Int,
private val chatColorsId: ChatColors.Id,
private val recipientId: RecipientId?,
private val chatColorCreatorRepository: CustomChatColorCreatorRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(CustomChatColorCreatorViewModel(maxSliderValue, chatColorsId, recipientId, chatColorCreatorRepository)))
}
}
sealed class Event {
class Warn(val usageCount: Int, val chatColors: ChatColors) : Event()
class SaveNow(val chatColors: ChatColors) : Event()
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
enum class CustomChatColorEdge {
TOP, BOTTOM
}

View File

@@ -0,0 +1,335 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.core.content.ContextCompat
import androidx.core.view.GestureDetectorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.pow
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Renders the gradient customization tool.
*
* The Gradient customization tool is two selectable circles on either side
* of a rectangle with a pipe connecting them, a TOP and a BOTTOM (an edge)
*
* The user can then swap between the selected edge via a touch-down and can
* drag the selected edge such that it traces around the outline of the square.
* The other edge traces along the opposite side of the rectangle.
*
* The way the position along the edge is determined is by dividing the rectangle
* into 8 right-angled triangles, all joining at the center. Using the specified
* angle, we can determine which "octant" the top edge should be in, and can
* determine its distance from the center point of the relevant edge, and use
* similar logic to determine where the bottom edge lies.
*
* All of the math assumes an origin at the dead center of the view, and
* that 0deg corresponds to a vector pointing directly towards the right hand
* side of the view. This doesn't quite line up with what the gradient rendering
* math requires, so we apply a simple function to degrees when it comes into or
* leaves this tool (see `Float.invert`)
*/
class CustomChatColorGradientToolView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val clipRect = Rect()
private val rect = RectF()
private val center = PointF()
private val top = PointF()
private val bottom = PointF()
private var selectedEdge: CustomChatColorEdge = CustomChatColorEdge.TOP
private var degrees: Float = 18f
private var listener: Listener? = null
private val thumbRadius: Float = ViewUtil.dpToPx(THUMB_RADIUS).toFloat()
private val thumbBorder: Float = ViewUtil.dpToPx(THUMB_BORDER).toFloat()
private val thumbBorderSelected: Float = ViewUtil.dpToPx(THUMB_BORDER_SELECTED).toFloat()
private val opaqueThumbRadius: Float = ViewUtil.dpToPx(OPAQUE_THUMB_RADIUS).toFloat()
private val opaqueThumbPadding: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING).toFloat()
private val opaqueThumbPaddingSelected: Float = ViewUtil.dpToPx(OPAGUE_THUMB_PADDING_SELECTED).toFloat()
private val pipeWidth: Float = ViewUtil.dpToPx(PIPE_WIDTH).toFloat()
private val pipeBorder: Float = ViewUtil.dpToPx(PIPE_BORDER).toFloat()
private val topColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
private val bottomColorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
}
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val thumbBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
}
private val thumbBorderPaintSelected = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_60)
}
private val pipePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = pipeWidth - pipeBorder * 2
style = Paint.Style.STROKE
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val pipeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = pipeWidth
style = Paint.Style.STROKE
color = ContextCompat.getColor(context, R.color.signal_inverse_transparent_10)
}
val gestureDetectorCompat = GestureDetectorCompat(context, GestureListener())
fun setTopColor(@ColorInt color: Int) {
topColorPaint.color = color
invalidate()
}
fun setBottomColor(@ColorInt color: Int) {
bottomColorPaint.color = color
invalidate()
}
fun setSelectedEdge(selectedEdge: CustomChatColorEdge) {
if (this.selectedEdge == selectedEdge) {
return
}
this.selectedEdge = selectedEdge
invalidate()
listener?.onSelectedEdgeChanged(selectedEdge)
}
fun setDegrees(degrees: Float) {
setDegreesInternal(degrees.invertDegrees())
}
private fun setDegreesInternal(degrees: Float) {
if (this.degrees == degrees) {
return
}
this.degrees = degrees
invalidate()
listener?.onDegreesChanged(degrees.invertDegrees())
}
private fun Float.invertDegrees(): Float = 360f - rotate(90f)
fun setListener(listener: Listener) {
this.listener = listener
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
return gestureDetectorCompat.onTouchEvent(event)
}
override fun onDraw(canvas: Canvas) {
canvas.getClipBounds(clipRect)
rect.set(clipRect)
rect.inset(thumbRadius, thumbRadius)
center.set(rect.width() / 2f, rect.height() / 2f)
val alpha = atan((rect.height() / rect.width())).toDegrees()
val beta = (360.0 - alpha * 4) / 4f
if (degrees < alpha) {
// Right top
val a = center.x
val b = a * tan(degrees.toRadians())
top.set(rect.width(), center.y - b)
bottom.set(0f, center.y + b)
} else if (degrees < 90f) {
// Top right
val phi = 90f - degrees
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x + b, 0f)
bottom.set(center.x - b, rect.height())
} else if (degrees < (90f + beta)) {
// Top left
val phi = degrees - 90f
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x - b, 0f)
bottom.set(center.x + b, rect.height())
} else if (degrees < 180f) {
// left top
val phi = 180f - degrees
val a = center.x
val b = a * tan(phi.toRadians())
top.set(0f, center.y - b)
bottom.set(rect.width(), center.y + b)
} else if (degrees < (180f + alpha)) {
// left bottom
val phi = degrees - 180f
val a = center.x
val b = a * tan(phi.toRadians())
top.set(0f, center.y + b)
bottom.set(rect.width(), center.y - b)
} else if (degrees < 270f) {
// bottom left
val phi = 270f - degrees
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x - b, rect.height())
bottom.set(center.x + b, 0f)
} else if (degrees < (270f + beta)) {
// bottom right
val phi = degrees - 270f
val a = center.y
val b = a * tan(phi.toRadians())
top.set(center.x + b, rect.height())
bottom.set(center.x - b, 0f)
} else {
// right bottom
val phi = 360f - degrees
val a = center.x
val b = a * tan(phi.toRadians())
top.set(rect.width(), center.y + b)
bottom.set(0f, center.y - b)
}
val (selected, other) = when (selectedEdge) {
CustomChatColorEdge.TOP -> top to bottom
CustomChatColorEdge.BOTTOM -> bottom to top
}
val (selectedPaint, otherPaint) = when (selectedEdge) {
CustomChatColorEdge.TOP -> topColorPaint to bottomColorPaint
CustomChatColorEdge.BOTTOM -> bottomColorPaint to topColorPaint
}
canvas.apply {
save()
translate(rect.top, rect.left)
drawLine(selected.x, selected.y, other.x, other.y, pipeBorderPaint)
drawLine(selected.x, selected.y, other.x, other.y, pipePaint)
drawCircle(other.x, other.y, opaqueThumbRadius + thumbBorder, thumbBorderPaint)
drawCircle(other.x, other.y, opaqueThumbRadius, backgroundPaint)
drawCircle(other.x, other.y, opaqueThumbRadius - opaqueThumbPadding, otherPaint)
drawCircle(selected.x, selected.y, opaqueThumbRadius + thumbBorderSelected, thumbBorderPaintSelected)
drawCircle(selected.x, selected.y, opaqueThumbRadius, backgroundPaint)
drawCircle(selected.x, selected.y, opaqueThumbRadius - opaqueThumbPaddingSelected, selectedPaint)
restore()
}
top.offset(rect.top, rect.left)
bottom.offset(rect.top, rect.left)
}
private fun Float.toDegrees(): Float = this * (180f / Math.PI.toFloat())
private fun Float.toRadians(): Float = this * (Math.PI.toFloat() / 180f)
private fun PointF.distance(other: PointF): Float = abs(sqrt((this.x - other.x).pow(2) + (this.y - other.y).pow(2)))
private fun PointF.dotProduct(other: PointF): Float = (this.x * other.x) + (this.y * other.y)
private fun PointF.determinate(other: PointF): Float = (this.x * other.y) - (this.y * other.x)
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
var activePointerId: Int = MotionEvent.INVALID_POINTER_ID
override fun onDown(e: MotionEvent): Boolean {
activePointerId = e.getPointerId(0)
val touchPoint = PointF(e.getX(activePointerId), e.getY(activePointerId))
val distanceFromTop = touchPoint.distance(top)
if (distanceFromTop <= thumbRadius) {
setSelectedEdge(CustomChatColorEdge.TOP)
return true
}
val distanceFromBottom = touchPoint.distance(bottom)
if (distanceFromBottom <= thumbRadius) {
setSelectedEdge(CustomChatColorEdge.BOTTOM)
return true
}
return false
}
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
val a = PointF(e2.getX(activePointerId) - center.x, e2.getY(activePointerId) - center.y)
val b = PointF(center.x, 0f)
val dot = a.dotProduct(b)
val det = a.determinate(b)
val offset = if (selectedEdge == CustomChatColorEdge.BOTTOM) 180f else 0f
val degrees = (atan2(det, dot).toDegrees() + 360f + offset) % 360f
setDegreesInternal(degrees)
return true
}
}
private fun Float.rotate(degrees: Float): Float = (this + degrees + 360f) % 360f
interface Listener {
fun onDegreesChanged(degrees: Float)
fun onSelectedEdgeChanged(edge: CustomChatColorEdge)
}
companion object {
@Dimension(unit = Dimension.DP)
private const val THUMB_RADIUS = 24
@Dimension(unit = Dimension.DP)
private const val THUMB_BORDER = 1
@Dimension(unit = Dimension.DP)
private const val THUMB_BORDER_SELECTED = 4
@Dimension(unit = Dimension.DP)
private const val OPAQUE_THUMB_RADIUS = 20
@Dimension(unit = Dimension.DP)
private const val OPAGUE_THUMB_PADDING = 2
@Dimension(unit = Dimension.DP)
private const val OPAGUE_THUMB_PADDING_SELECTED = 1
@Dimension(unit = Dimension.DP)
private const val PIPE_WIDTH = 6
@Dimension(unit = Dimension.DP)
private const val PIPE_BORDER = 1
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
class CustomChatColorPagerAdapter(parentFragment: Fragment, private val arguments: Bundle) : FragmentStateAdapter(parentFragment) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> CustomChatColorCreatorPageFragment.forSingle(arguments)
1 -> CustomChatColorCreatorPageFragment.forGradient(arguments)
else -> {
throw AssertionError()
}
}
}
}