diff --git a/app/build.gradle b/app/build.gradle index 3402afc1e8..dc29c38312 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -322,6 +322,7 @@ android { } dependencies { + implementation 'androidx.fragment:fragment-ktx:1.2.5' lintChecks project(':lintchecks') coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 6165098106..6474a2fa54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -11,6 +11,8 @@ import androidx.lifecycle.Observer; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.conversation.colors.Colorizable; +import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; @@ -29,7 +31,7 @@ import java.util.List; import java.util.Locale; import java.util.Set; -public interface BindableConversationItem extends Unbindable, GiphyMp4Playable { +public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable { void bind(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage messageRecord, @NonNull Optional previousMessageRecord, @@ -43,7 +45,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable { boolean hasWallpaper, boolean isMessageRequestAccepted, @NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory, - boolean canPlayInline); + boolean canPlayInline, + @NonNull Colorizer colorizer); ConversationMessage getConversationMessage(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java index 132d7b69fd..a7652fa833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java @@ -218,12 +218,6 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } - private void setActionBarNotificationBarColor(MaterialColor color) { - getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this))); - - WindowUtil.setStatusBarColor(getWindow(), color.toStatusBarColor(this)); - } - public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener { public static final String RECIPIENT_ID = "recipient_id"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java index 9372a96d6e..353102f9ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java @@ -91,14 +91,14 @@ public enum MaterialColor { } public @ColorRes int toQuoteBarColorResource(@NonNull Context context, boolean outgoing) { - if (outgoing) { + if (!outgoing) { return isDarkTheme(context) ? tintColor : shadeColor ; } return R.color.core_white; } public @ColorInt int toQuoteBackgroundColor(@NonNull Context context, boolean outgoing) { - if (outgoing) { + if (!outgoing) { int color = toConversationColor(context); int alpha = isDarkTheme(context) ? (int) (0.2 * 255) : (int) (0.4 * 255); return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); @@ -108,7 +108,7 @@ public enum MaterialColor { } public @ColorInt int toQuoteFooterColor(@NonNull Context context, boolean outgoing) { - if (outgoing) { + if (!outgoing) { int color = toConversationColor(context); int alpha = isDarkTheme(context) ? (int) (0.4 * 255) : (int) (0.6 * 255); return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 3e42a4429f..559e0df351 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -28,6 +28,8 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; import org.thoughtcrime.securesms.mms.GlideApp; @@ -73,6 +75,7 @@ public final class AvatarImageView extends AppCompatImageView { private OnClickListener listener; private Recipient.FallbackPhotoProvider fallbackPhotoProvider; private boolean blurred; + private ChatColors chatColors; private @Nullable RecipientContactPhoto recipientContactPhoto; private @NonNull Drawable unknownRecipientDrawable; @@ -99,8 +102,9 @@ public final class AvatarImageView extends AppCompatImageView { outlinePaint = ThemeUtil.isDarkTheme(getContext()) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT; - unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(getContext(), ContactColors.UNKNOWN_COLOR.toConversationColor(getContext()), inverted); + unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(getContext(), ChatColorsPalette.UNKNOWN_CONTACT, inverted); blurred = false; + chatColors = null; } @Override @@ -171,10 +175,12 @@ public final class AvatarImageView extends AppCompatImageView { Recipient.self().getProfileAvatar())) : new RecipientContactPhoto(recipient); - boolean shouldBlur = recipient.shouldBlurAvatar(); + boolean shouldBlur = recipient.shouldBlurAvatar(); + ChatColors chatColors = recipient.getChatColors(); - if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred) { + if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors)) { requestManager.clear(this); + this.chatColors = chatColors; recipientContactPhoto = photo; Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider) @@ -207,7 +213,7 @@ public final class AvatarImageView extends AppCompatImageView { requestManager.clear(this); if (fallbackPhotoProvider != null) { setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName() - .asDrawable(getContext(), MaterialColor.STEEL.toAvatarColor(getContext()), inverted)); + .asDrawable(getContext(), ChatColorsPalette.Bubbles.STEEL, inverted)); } else { setImageDrawable(unknownRecipientDrawable); } @@ -240,11 +246,11 @@ public final class AvatarImageView extends AppCompatImageView { public void setImageBytesForGroup(@Nullable byte[] avatarBytes, @Nullable Recipient.FallbackPhotoProvider fallbackPhotoProvider, - @NonNull MaterialColor color) + @NonNull ChatColors color) { Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER) .getPhotoForGroup() - .asDrawable(getContext(), color.toAvatarColor(getContext())); + .asDrawable(getContext(), color); GlideApp.with(this) .load(avatarBytes) @@ -285,7 +291,7 @@ public final class AvatarImageView extends AppCompatImageView { if (other == null) return false; return other.recipient.equals(recipient) && - other.recipient.getColor().equals(recipient.getColor()) && + other.recipient.getChatColors().equals(recipient.getChatColors()) && other.ready == ready && Objects.equals(other.contactPhoto, contactPhoto); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 1e57fffc9b..f2d92a237e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat; import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat; @@ -146,6 +147,14 @@ public class ConversationItemFooter extends LinearLayout { setBackground(null); } + public @Nullable Projection getProjection() { + if (getVisibility() == VISIBLE) { + return Projection.relativeToViewRoot(this, new Projection.Corners(ViewUtil.dpToPx(11))); + } else { + return null; + } + } + private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { dateView.forceLayout(); if (messageRecord.isFailed()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java index 3ef36a208f..212cef7f6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideClickListener; import org.thoughtcrime.securesms.mms.SlidesClickedListener; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.List; @@ -116,8 +117,8 @@ public class ConversationItemThumbnail extends FrameLayout { thumbnail.setAlpha(1f); } - public @Nullable CornerMask getCornerMask() { - return cornerMask; + public @NonNull Projection.Corners getCorners() { + return new Projection.Corners(cornerMask.getRadii()); } public void setPulseOutliner(@NonNull Outliner outliner) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java index 8eb881ca86..a143381088 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java @@ -1,13 +1,15 @@ package org.thoughtcrime.securesms.components; import android.content.Context; -import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; import android.util.AttributeSet; import android.view.View; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -41,7 +43,6 @@ public class ConversationTypingView extends LinearLayout { } Recipient typist = typists.get(0); - bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY); if (isGroupThread) { avatar.setAvatar(glideRequests, typist, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java index 9bccf370e9..da6601d9c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/CornerMask.java @@ -22,20 +22,12 @@ public class CornerMask { private final RectF bounds = new RectF(); public CornerMask(@NonNull View view) { - this(view, null); - } - - public CornerMask(@NonNull View view, @Nullable CornerMask toClone) { view.setLayerType(View.LAYER_TYPE_HARDWARE, null); clearPaint.setColor(Color.BLACK); clearPaint.setStyle(Paint.Style.FILL); clearPaint.setAntiAlias(true); clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - - if (toClone != null) { - System.arraycopy(toClone.radii, 0, radii, 0, radii.length); - } } public void mask(Canvas canvas) { @@ -64,6 +56,13 @@ public class CornerMask { radii[6] = radii[7] = bottomLeft; } + public void setRadii(float topLeft, float topRight, float bottomRight, float bottomLeft) { + radii[0] = radii[1] = topLeft; + radii[2] = radii[3] = topRight; + radii[4] = radii[5] = bottomRight; + radii[6] = radii[7] = bottomLeft; + } + public void setTopLeftRadius(int radius) { radii[0] = radii[1] = radius; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index 6892893108..c8dc12d63a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components; import android.animation.Animator; import android.animation.ValueAnimator; import android.content.Context; +import android.graphics.drawable.ColorDrawable; import android.net.Uri; import android.text.format.DateUtils; import android.util.AttributeSet; @@ -22,6 +23,7 @@ import androidx.annotation.DimenRes; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -33,6 +35,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; import org.thoughtcrime.securesms.components.emoji.EmojiToggle; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter; +import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; @@ -42,7 +45,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; @@ -50,6 +52,7 @@ import org.thoughtcrime.securesms.util.concurrent.SettableFuture; import org.whispersystems.libsignal.util.guava.Optional; import java.util.List; +import java.util.Objects; import java.util.concurrent.TimeUnit; public class InputPanel extends LinearLayout @@ -74,7 +77,7 @@ public class InputPanel extends LinearLayout private View buttonToggle; private View recordingContainer; private View recordLockCancel; - private View composeContainer; + private ViewGroup composeContainer; private MicrophoneRecorderView microphoneRecorderView; private SlideToCancel slideToCancel; @@ -161,9 +164,10 @@ public class InputPanel extends LinearLayout long id, @NonNull Recipient author, @NonNull CharSequence body, - @NonNull SlideDeck attachments) + @NonNull SlideDeck attachments, + @NonNull Colorizer colorizer) { - this.quoteView.setQuote(glideRequests, id, author, body, false, attachments); + this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, colorizer); int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight() : 0; @@ -290,11 +294,11 @@ public class InputPanel extends LinearLayout public void setWallpaperEnabled(boolean enabled) { if (enabled) { - setBackgroundColor(getContext().getResources().getColor(R.color.wallpaper_compose_background)); - composeContainer.setBackgroundResource(R.drawable.compose_background_wallpaper); + setBackground(new ColorDrawable(getContext().getResources().getColor(R.color.wallpaper_compose_background))); + composeContainer.setBackground(Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.compose_background_wallpaper))); } else { - setBackgroundColor(getResources().getColor(R.color.signal_background_primary)); - composeContainer.setBackgroundResource(R.drawable.compose_background); + setBackground(new ColorDrawable(getContext().getResources().getColor(R.color.signal_background_primary))); + composeContainer.setBackground(Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.compose_background))); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index 4bfbcb3d07..d7d6e28a21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -17,6 +17,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; import com.annimon.stream.Stream; import com.bumptech.glide.load.engine.DiskCacheStrategy; @@ -25,6 +26,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -33,6 +35,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ThemeUtil; import java.util.List; @@ -49,7 +52,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { private ViewGroup footerView; private TextView authorView; private TextView bodyView; - private ImageView quoteBarView; + private View quoteBarView; private ImageView thumbnailView; private View attachmentVideoOverlayView; private ViewGroup attachmentContainerView; @@ -66,6 +69,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { private int largeCornerRadius; private int smallCornerRadius; private CornerMask cornerMask; + private Colorizer colorizer; public QuoteView(Context context) { @@ -152,7 +156,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { @NonNull Recipient author, @Nullable CharSequence body, boolean originalMissing, - @NonNull SlideDeck attachments) + @NonNull SlideDeck attachments, + @NonNull Colorizer colorizer) { if (this.author != null) this.author.removeForeverObserver(this); @@ -160,6 +165,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { this.author = author.live(); this.body = body; this.attachments = attachments; + this.colorizer = colorizer; this.author.observeForever(this); setQuoteAuthor(author); @@ -188,15 +194,22 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { setQuoteAuthor(recipient); } + public @NonNull Projection getProjection(@NonNull ViewGroup parent) { + return Projection.relativeToParent(parent, this, getCorners()); + } + + public @NonNull Projection.Corners getCorners() { + return new Projection.Corners(cornerMask.getRadii()); + } + private void setQuoteAuthor(@NonNull Recipient author) { - boolean outgoing = messageType != MESSAGE_TYPE_INCOMING; + boolean outgoing = messageType == MESSAGE_TYPE_OUTGOING; authorView.setText(author.isSelf() ? getContext().getString(R.string.QuoteView_you) : author.getDisplayName(getContext())); - // We use the raw color resource because Android 4.x was struggling with tints here - quoteBarView.setImageResource(author.getColor().toQuoteBarColorResource(getContext(), outgoing)); - mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing)); + quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing ? R.color.core_white : android.R.color.transparent)); + mainView.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.quote_view_background)); } private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { @@ -272,7 +285,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { private void setQuoteMissingFooter(boolean missing) { footerView.setVisibility(missing ? VISIBLE : GONE); - footerView.setBackgroundColor(author.get().getColor().toQuoteFooterColor(getContext(), messageType != MESSAGE_TYPE_INCOMING)); + footerView.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.quote_view_background)); } public long getQuoteId() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java b/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java new file mode 100644 index 0000000000..60fb3e2648 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/RotatableGradientDrawable.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.components; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; + +import kotlin.jvm.functions.Function1; +import kotlin.jvm.functions.Function2; + +/** + * Drawable which renders a gradient at a specified angle. Note that this drawable does + * not implement drawable state, and all the baggage that comes with a normal Drawable + * override, so this may not work in every scenario. + * + * Essentially, this drawable creates a LinearGradient shader using the given colors and + * positions, but makes it larger than the bounds, such that it can be rotated and still + * fill the bounds with a gradient. + * + * If you wish to apply clipping to this drawable, it is recommended to either use it with + * a CardView or utilize {@link org.thoughtcrime.securesms.util.CustomDrawWrapperKt#customizeOnDraw(Drawable, Function2)} + */ +public final class RotatableGradientDrawable extends Drawable { + + /** + * From investigation into how Gradients are rendered vs how they are rendered in + * designs, in order to match spec, we need to rotate gradients by 225 degrees. (180 + 45) + * + * This puts 0 at the bottom (0, -1) of the surface area. + */ + private static final float DEGREE_OFFSET = 225f; + + private final float degrees; + private final int[] colors; + private final float[] positions; + + private final Rect fillRect = new Rect(); + private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + + /** + * @param degrees Gradient rotation in degrees, relative to a vector pointed from the center to bottom center + * @param colors The colors of the gradient + * @param positions The positions of the colors. Values should be between 0f and 1f and this array should be the + * same length as colors. + */ + public RotatableGradientDrawable(float degrees, int[] colors, @Nullable float[] positions) { + this.degrees = degrees + DEGREE_OFFSET; + this.colors = colors; + this.positions = positions; + } + + @Override + public void setBounds(int left, int top, int right, int bottom) { + super.setBounds(left, top, right, bottom); + + Point topLeft = new Point(left, top); + Point topRight = new Point(right, top); + Point bottomLeft = new Point(left, bottom); + Point bottomRight = new Point(right, bottom); + Point origin = new Point(getBounds().width() / 2, getBounds().height() / 2); + + Point rotationTopLeft = cornerPrime(origin, topLeft, degrees); + Point rotationTopRight = cornerPrime(origin, topRight, degrees); + Point rotationBottomLeft = cornerPrime(origin, bottomLeft, degrees); + Point rotationBottomRight = cornerPrime(origin, bottomRight, degrees); + + fillRect.left = Integer.MAX_VALUE; + fillRect.top = Integer.MAX_VALUE; + fillRect.right = Integer.MIN_VALUE; + fillRect.bottom = Integer.MIN_VALUE; + + for (Point point : Arrays.asList(topLeft, topRight, bottomLeft, bottomRight, rotationTopLeft, rotationTopRight, rotationBottomLeft, rotationBottomRight)) { + if (point.x < fillRect.left) { + fillRect.left = point.x; + } + + if (point.x > fillRect.right) { + fillRect.right = point.x; + } + + if (point.y < fillRect.top) { + fillRect.top = point.y; + } + + if (point.y > fillRect.bottom) { + fillRect.bottom = point.y; + } + } + + fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP)); + } + + private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) { + return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees))); + } + + private static int xPrime(@NonNull Point origin, @NonNull Point corner, double theta) { + return (int) Math.ceil(((corner.x - origin.x) * Math.cos(theta)) - ((corner.y - origin.y) * Math.sin(theta)) + origin.x); + } + + private static int yPrime(@NonNull Point origin, @NonNull Point corner, double theta) { + return (int) Math.ceil(((corner.x - origin.x) * Math.sin(theta)) + ((corner.y - origin.y) * Math.cos(theta)) + origin.y); + } + + @Override + public void draw(Canvas canvas) { + int save = canvas.save(); + canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f); + canvas.drawRect(fillRect, fillPaint); + canvas.restoreToCount(save); + } + + @Override + public void setAlpha(int alpha) { + // Not supported + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + // Not supported + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt index 3deec34daf..08501e1010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt @@ -42,7 +42,7 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app ) clickPref( - title = DSLSettingsText.from(R.string.preferences__chat_wallpaper), + title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper), onClick = { Navigation.findNavController(requireView()).navigate(R.id.action_appearanceSettings_to_wallpaperActivity) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java index 1395352b5a..b363d458b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java @@ -65,7 +65,7 @@ class VoiceNoteMediaDescriptionCompatFactory { extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize()); extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition); extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId()); - extras.putString(EXTRA_COLOR, threadRecipient.getColor().serialize()); + extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor()); extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId()); NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java index 7ac939c227..0fda3aed71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java @@ -19,6 +19,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.recipients.Recipient; @@ -99,14 +101,13 @@ class VoiceNoteNotificationManager { int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION); long threadId = controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID); - MaterialColor color; - try { - color = MaterialColor.fromSerialized(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR)); - } catch (MaterialColor.UnknownColorException e) { - color = ContactColors.UNKNOWN_COLOR; + int color = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR); + + if (color == 0) { + color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor(); } - notificationManager.setColor(color.toNotificationColor(context)); + notificationManager.setColor(color); Intent conversationActivity = ConversationIntents.createBuilder(context, recipientId, threadId) .withStartingPosition(startingPosition) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java index f68cddec39..8de91fc1f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.recipients.Recipient; @@ -225,7 +226,10 @@ public class CallParticipantView extends ConstraintLayout { .into(pipAvatar); pipAvatar.setScaleType(contactPhoto == null ? ImageView.ScaleType.CENTER_INSIDE : ImageView.ScaleType.CENTER_CROP); - pipAvatar.setBackgroundColor(recipient.getColor().toActionBarColor(getContext())); + + ChatColors chatColors = recipient.getChatColors(); + + pipAvatar.setBackground(chatColors.getChatBubbleMask()); } private void showBlockedDialog(@NonNull Recipient recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java index 9a4e265c6f..395b99959f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackContactPhoto.java @@ -3,11 +3,13 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.conversation.colors.ChatColors; + public interface FallbackContactPhoto { - - public Drawable asDrawable(Context context, int color); - public Drawable asDrawable(Context context, int color, boolean inverted); - public Drawable asSmallDrawable(Context context, int color, boolean inverted); - public Drawable asCallCard(Context context); - + Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors); + Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted); + Drawable asSmallDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted); + Drawable asCallCard(@NonNull Context context); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java index 96c535e030..2da2846e4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java @@ -7,9 +7,9 @@ import android.graphics.drawable.LayerDrawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.graphics.drawable.DrawableCompat; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Objects; @@ -26,18 +26,18 @@ public final class FallbackPhoto20dp implements FallbackContactPhoto { } @Override - public Drawable asDrawable(Context context, int color) { - return buildDrawable(context, color); + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors) { + return buildDrawable(context, chatColors); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { - return buildDrawable(context, color); + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return buildDrawable(context, chatColors); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { - return buildDrawable(context, color); + public Drawable asSmallDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return buildDrawable(context, chatColors); } @Override @@ -45,15 +45,13 @@ public final class FallbackPhoto20dp implements FallbackContactPhoto { throw new UnsupportedOperationException(); } - private @NonNull Drawable buildDrawable(@NonNull Context context, int color) { - Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate(); + private @NonNull Drawable buildDrawable(@NonNull Context context, @NonNull ChatColors backgroundColor) { + Drawable background = backgroundColor.asCircle(); Drawable foreground = AppCompatResources.getDrawable(context, drawable20dp); Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient); LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); int foregroundInset = ViewUtil.dpToPx(2); - DrawableCompat.setTint(background, color); - drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); return drawable; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java index e5b0aa7a97..c41bfc447b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto80dp.java @@ -10,49 +10,48 @@ import androidx.appcompat.content.res.AppCompatResources; import androidx.core.graphics.drawable.DrawableCompat; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Objects; public final class FallbackPhoto80dp implements FallbackContactPhoto { - @DrawableRes private final int drawable80dp; - private final int backgroundColor; + @DrawableRes private final int drawable80dp; + private final ChatColors backgroundColor; - public FallbackPhoto80dp(@DrawableRes int drawable80dp, int backgroundColor) { + public FallbackPhoto80dp(@DrawableRes int drawable80dp, @NonNull ChatColors backgroundColor) { this.drawable80dp = drawable80dp; this.backgroundColor = backgroundColor; } @Override - public Drawable asDrawable(Context context, int color) { + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors) { return buildDrawable(context); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { return buildDrawable(context); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + public Drawable asSmallDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { throw new UnsupportedOperationException(); } @Override - public Drawable asCallCard(Context context) { + public Drawable asCallCard(@NonNull Context context) { throw new UnsupportedOperationException(); } private @NonNull Drawable buildDrawable(@NonNull Context context) { - Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate(); + Drawable background = backgroundColor.asCircle(); Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp); Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient); LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient}); int foregroundInset = ViewUtil.dpToPx(24); - DrawableCompat.setTint(background, backgroundColor); - drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset); return drawable; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java index eae791436f..0d939bd6fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java @@ -15,6 +15,7 @@ import androidx.appcompat.content.res.AppCompatResources; import com.amulyakhare.textdrawable.TextDrawable; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.util.ContextUtil; import org.thoughtcrime.securesms.util.ViewUtil; @@ -42,12 +43,12 @@ public class GeneratedContactPhoto implements FallbackContactPhoto { } @Override - public Drawable asDrawable(Context context, int color) { - return asDrawable(context, color,false); + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors) { + return asDrawable(context, chatColors, false); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { int targetSize = this.targetSize != -1 ? this.targetSize : context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); @@ -55,34 +56,36 @@ public class GeneratedContactPhoto implements FallbackContactPhoto { String character = getAbbreviation(name); if (!TextUtils.isEmpty(character)) { + Drawable background = chatColors.asCircle(); + Drawable base = TextDrawable.builder() .beginConfig() .width(targetSize) .height(targetSize) .useFont(TYPEFACE) .fontSize(fontSize) - .textColor(inverted ? color : Color.WHITE) + .textColor(inverted ? chatColors.asSingleColor() : Color.WHITE) .endConfig() - .buildRound(character, inverted ? Color.WHITE : color); + .buildRound(character, inverted ? Color.WHITE : Color.TRANSPARENT); Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient); - return new LayerDrawable(new Drawable[] { base, gradient }); + return new LayerDrawable(new Drawable[] { background, base, gradient }); } - return newFallbackDrawable(context, color, inverted); + return newFallbackDrawable(context, chatColors, inverted); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { - return asDrawable(context, color, inverted); + public Drawable asSmallDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return asDrawable(context, chatColors, inverted); } protected @DrawableRes int getFallbackResId() { return fallbackResId; } - protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { - return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted); + protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return new ResourceContactPhoto(fallbackResId).asDrawable(context, chatColors, inverted); } private @Nullable String getAbbreviation(String name) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java index 272310d5ee..7267b977d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java @@ -16,6 +16,7 @@ import com.amulyakhare.textdrawable.TextDrawable; import com.makeramen.roundedimageview.RoundedDrawable; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.util.ContextUtil; public class ResourceContactPhoto implements FallbackContactPhoto { @@ -45,29 +46,29 @@ public class ResourceContactPhoto implements FallbackContactPhoto { } @Override - public @NonNull Drawable asDrawable(@NonNull Context context, int color) { - return asDrawable(context, color, false); + public @NonNull Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors) { + return asDrawable(context, chatColors, false); } @Override - public @NonNull Drawable asDrawable(@NonNull Context context, int color, boolean inverted) { - return buildDrawable(context, resourceId, color, inverted); + public @NonNull Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return buildDrawable(context, resourceId, chatColors, inverted); } @Override - public @NonNull Drawable asSmallDrawable(@NonNull Context context, int color, boolean inverted) { - return buildDrawable(context, smallResourceId, color, inverted); + public @NonNull Drawable asSmallDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return buildDrawable(context, smallResourceId, chatColors, inverted); } - private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, int color, boolean inverted) { - Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); + private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, @NonNull ChatColors chatColors, boolean inverted) { + Drawable background = chatColors.asCircle(); RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); //noinspection ConstantConditions foreground.setScaleType(scaleType); if (inverted) { - foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); + foreground.setColorFilter(chatColors.asSingleColor(), PorterDuff.Mode.SRC_ATOP); } Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java index f38e6f0f1f..c63f9b69d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/TransparentContactPhoto.java @@ -3,33 +3,35 @@ package org.thoughtcrime.securesms.contacts.avatars; import android.content.Context; import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import com.makeramen.roundedimageview.RoundedDrawable; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; public class TransparentContactPhoto implements FallbackContactPhoto { public TransparentContactPhoto() {} @Override - public Drawable asDrawable(Context context, int color) { - return asDrawable(context, color, false); + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors) { + return asDrawable(context, chatColors, false); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public Drawable asDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { return RoundedDrawable.fromDrawable(context.getResources().getDrawable(android.R.color.transparent)); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { - return asDrawable(context, color, inverted); + public Drawable asSmallDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return asDrawable(context, chatColors, inverted); } @Override - public Drawable asCallCard(Context context) { + public Drawable asCallCard(@NonNull Context context) { return ContextCompat.getDrawable(context, R.drawable.ic_contact_picture_large); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ClipProjectionDrawable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ClipProjectionDrawable.kt new file mode 100644 index 0000000000..0bb5ec8438 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ClipProjectionDrawable.kt @@ -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 = listOf() + + fun setWrappedDrawable(drawable: Drawable) { + setDrawableByLayerId(0, drawable) + } + + fun setProjections(projections: Set) { + 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 375bcb2ec1..8659e0221c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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() { @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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 5393885932..1461cfbd76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -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() { @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 getColorizerProjections() { + return getBindable().getColorizerProjections(); + } } static class StickyHeaderViewHolder extends RecyclerView.ViewHolder { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index a24ad20338..995bff50a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -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(), () -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 1632649585..05ae0956ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index db7be642ba..0bbd82f277 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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 previous, @NonNull Optional next, boolean isGroupThread) { + private void setQuote(@NonNull MessageRecord current, @NonNull Optional previous, @NonNull Optional 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 getColorizerProjections() { + List 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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java index 6ec61f5892..5b1d871c40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java @@ -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 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 getProjections() { + return Stream.of(quoteViewProjection, videoPlayerProjection) + .filterNot(Objects::isNull) + .collect(Collectors.toSet()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java index 962a6fb94e..3c4fa1f164 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemMaskTarget.java @@ -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 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); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java index 4173956beb..8f7daed035 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java @@ -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); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 5694eae97b..2ef6d9b6d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -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 getColorizerProjections() { + return Collections.emptyList(); + } + static final class RecipientObserverManager { private final Observer recipientObserver; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index cf66fc06aa..4e3eff2679 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -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; private final LiveData wallpaper; private final SingleLiveEvent events; + private final LiveData 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 getChatColors() { + return chatColors; + } + void setHasUnreadMentions(boolean hasUnreadMentions) { this.hasUnreadMentions.setValue(hasUnreadMentions); } @@ -183,6 +202,35 @@ public class ConversationViewModel extends ViewModel { return pagingController; } + @NonNull LiveData> getNameColorsMap() { + LiveData recipient = Transformations.switchMap(recipientId, r -> Recipient.live(r).getLiveData()); + LiveData groupRecipients = LiveDataUtil.filter(recipient, Recipient::isGroup); + LiveData> groupMembers = Transformations.switchMap(groupRecipients, r -> new LiveGroup(r.getGroupId().get()).getFullMembers()); + + return Transformations.map(groupMembers, members -> { + List sorted = Stream.of(members) + .filter(member -> !Objects.equals(member.getMember(), Recipient.self())) + .sortBy(this::getMemberIdentifier) + .toList(); + + List names = ChatColorsPalette.Names.getAll(); + Map 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java index 8089aa7540..547333d3c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MaskDrawable.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt new file mode 100644 index 0000000000..91fed6252f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColors.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt new file mode 100644 index 0000000000..25311b71b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt @@ -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 = ImmutableBiMap.Builder().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 = ImmutableMap.Builder().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> + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsPalette.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsPalette.kt new file mode 100644 index 0000000000..c60a86207c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsPalette.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizable.kt new file mode 100644 index 0000000000..8ea2c9b3b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizable.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt new file mode 100644 index 0000000000..d1ceeae6f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/Colorizer.kt @@ -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 = 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) { + 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 = (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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ColorizerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ColorizerView.kt new file mode 100644 index 0000000000..7e5eea867c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ColorizerView.kt @@ -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 = listOf() + + fun setProjections(projections: List) { + 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/NameColor.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/NameColor.kt new file mode 100644 index 0000000000..d55cbaf606 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/NameColor.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorMappingModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorMappingModel.kt new file mode 100644 index 0000000000..92cba9fda5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorMappingModel.kt @@ -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 { + + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt new file mode 100644 index 0000000000..75428ac85d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorPreviewView.kt @@ -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?) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionAdapter.kt new file mode 100644 index 0000000000..359b73a64d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionAdapter.kt @@ -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(itemView) { + + init { + itemView.setOnClickListener { onClicked() } + } + + override fun bind(model: CustomColorMappingModel) = Unit + } + + class ChatColorMappingViewHolder( + itemView: View, + private val popupWindow: ChatSelectionContextMenu, + private val callbacks: Callbacks + ) : MappingViewHolder(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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionFragment.kt new file mode 100644 index 0000000000..55d1ea005b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionFragment.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionRepository.kt new file mode 100644 index 0000000000..375c0c341a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionRepository.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionState.kt new file mode 100644 index 0000000000..4b4f1343d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionState.kt @@ -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 = listOf() +) { + + val chatColorModels: MappingModelList + + init { + val models: List = 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()) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionViewModel.kt new file mode 100644 index 0000000000..7e58f7b6d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorSelectionViewModel.kt @@ -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()) + private val chatColors = ChatColorsOptionsLiveData() + private val internalEvents = SingleLiveEvent() + + val state: LiveData = store.stateLiveData + val events: LiveData = 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 create(modelClass: Class): 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorsOptionsLiveData.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorsOptionsLiveData.kt new file mode 100644 index 0000000000..e314faa10f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatColorsOptionsLiveData.kt @@ -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>() { + 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().apply { + addAll(ChatColorsPalette.Bubbles.all) + addAll(chatColorsDatabase.getSavedChatColors()) + } + + postValue(options) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatSelectionContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatSelectionContextMenu.kt new file mode 100644 index 0000000000..d8c6535ec8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/ChatSelectionContextMenu.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/CustomColorMappingModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/CustomColorMappingModel.kt new file mode 100644 index 0000000000..fa2ef14e92 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/CustomColorMappingModel.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.conversation.colors.ui + +import org.thoughtcrime.securesms.util.MappingModel + +class CustomColorMappingModel : MappingModel { + override fun areItemsTheSame(newItem: CustomColorMappingModel): Boolean { + return true + } + + override fun areContentsTheSame(newItem: CustomColorMappingModel): Boolean { + return true + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorFragment.kt new file mode 100644 index 0000000000..f31bf254b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorFragment.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorPageFragment.kt new file mode 100644 index 0000000000..77c31660c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorPageFragment.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorRepository.kt new file mode 100644 index 0000000000..3720b1a682 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorRepository.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorState.kt new file mode 100644 index 0000000000..42e9aab11b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorState.kt @@ -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, + val selectedEdge: CustomChatColorEdge, + val degrees: Float +) + +data class ColorSlidersState(val huePosition: Int, val saturationPosition: Int) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorViewModel.kt new file mode 100644 index 0000000000..4077e7d0a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorViewModel.kt @@ -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(getInitialState()) + private val internalEvents = SingleLiveEvent() + + val state: LiveData = store.stateLiveData + val events: LiveData = 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 create(modelClass: Class): 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorEdge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorEdge.kt new file mode 100644 index 0000000000..900943c880 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorEdge.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.conversation.colors.ui.custom + +enum class CustomChatColorEdge { + TOP, BOTTOM +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorGradientToolView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorGradientToolView.kt new file mode 100644 index 0000000000..01afde0919 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorGradientToolView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorPagerAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorPagerAdapter.kt new file mode 100644 index 0000000000..1d08f5bfa3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorPagerAdapter.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index 4cd2aa6c59..7623694c56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -393,7 +393,7 @@ public final class ConversationListItem extends ConstraintLayout private void setRippleColor(Recipient recipient) { if (Build.VERSION.SDK_INT >= 21) { ((RippleDrawable)(getBackground()).mutate()) - .setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext()))); + .setColor(ColorStateList.valueOf(recipient.getChatColors().asSingleColor())); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ChatColorsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ChatColorsDatabase.kt new file mode 100644 index 0000000000..df46841b41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ChatColorsDatabase.kt @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.CursorUtil +import org.thoughtcrime.securesms.util.SqlUtil + +class ChatColorsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Database(context, databaseHelper) { + + companion object { + private const val TABLE_NAME = "chat_colors" + private const val ID = "_id" + private const val CHAT_COLORS = "chat_colors" + + @JvmField + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $CHAT_COLORS BLOB + ) + """.trimIndent() + } + + fun getById(chatColorsId: ChatColors.Id): ChatColors { + val db = databaseHelper.readableDatabase + val projection = arrayOf(ID, CHAT_COLORS) + val args = SqlUtil.buildArgs(chatColorsId.longValue) + + db.query(TABLE_NAME, projection, ID_WHERE, args, null, null, null)?.use { + if (it.moveToFirst()) { + return it.getChatColors() + } + } + + throw IllegalArgumentException("Could not locate chat color $chatColorsId") + } + + fun saveChatColors(chatColors: ChatColors): ChatColors { + return when (chatColors.id) { + is ChatColors.Id.Auto -> throw AssertionError("Saving 'auto' does not make sense") + is ChatColors.Id.BuiltIn -> chatColors + is ChatColors.Id.NotSet -> insertChatColors(chatColors) + is ChatColors.Id.Custom -> updateChatColors(chatColors) + } + } + + fun getSavedChatColors(): List { + val db = databaseHelper.readableDatabase + val projection = arrayOf(ID, CHAT_COLORS) + val result = mutableListOf() + + db.query(TABLE_NAME, projection, null, null, null, null, null)?.use { + while (it.moveToNext()) { + result.add(it.getChatColors()) + } + } + + return result + } + + private fun insertChatColors(chatColors: ChatColors): ChatColors { + if (chatColors.id != ChatColors.Id.NotSet) { + throw IllegalArgumentException("Bad chat colors to insert.") + } + + val db: SQLiteDatabase = databaseHelper.writableDatabase + val values = ContentValues(1).apply { + put(CHAT_COLORS, chatColors.serialize().toByteArray()) + } + + val rowId = db.insert(TABLE_NAME, null, values) + if (rowId == -1L) { + throw IllegalStateException("Failed to insert ChatColor into database") + } + + notifyListeners() + + return chatColors.withId(ChatColors.Id.forLongValue(rowId)) + } + + private fun updateChatColors(chatColors: ChatColors): ChatColors { + if (chatColors.id == ChatColors.Id.NotSet || chatColors.id == ChatColors.Id.BuiltIn) { + throw IllegalArgumentException("Bad chat colors to update.") + } + + val db: SQLiteDatabase = databaseHelper.writableDatabase + val values = ContentValues(1).apply { + put(CHAT_COLORS, chatColors.serialize().toByteArray()) + } + + val rowsUpdated = db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(chatColors.id.longValue)) + if (rowsUpdated < 1) { + throw IllegalStateException("Failed to update ChatColor in database") + } + + if (SignalStore.chatColorsValues().chatColors?.id == chatColors.id) { + SignalStore.chatColorsValues().chatColors = chatColors + } + + val recipientDatabase = DatabaseFactory.getRecipientDatabase(context) + recipientDatabase.onUpdatedChatColors(chatColors) + notifyListeners() + + return chatColors + } + + fun deleteChatColors(chatColors: ChatColors) { + if (chatColors.id == ChatColors.Id.NotSet || chatColors.id == ChatColors.Id.BuiltIn) { + throw IllegalArgumentException("Cannot delete this chat color") + } + + val db: SQLiteDatabase = databaseHelper.writableDatabase + db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(chatColors.id.longValue)) + + if (SignalStore.chatColorsValues().chatColors?.id == chatColors.id) { + SignalStore.chatColorsValues().chatColors = null + } + + val recipientDatabase = DatabaseFactory.getRecipientDatabase(context) + recipientDatabase.onDeletedChatColors(chatColors) + notifyListeners() + } + + private fun notifyListeners() { + ApplicationDependencies.getDatabaseObserver().notifyChatColorsListeners() + } + + private fun Cursor.getId(): Long = CursorUtil.requireLong(this, ID) + private fun Cursor.getChatColors(): ChatColors = ChatColors.forChatColor( + ChatColors.Id.forLongValue(getId()), + ChatColor.parseFrom(CursorUtil.requireBlob(this, CHAT_COLORS)) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 56b65a48ec..f23312f30c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -64,6 +64,7 @@ public class DatabaseFactory { private final RemappedRecordsDatabase remappedRecordsDatabase; private final MentionDatabase mentionDatabase; private final PaymentDatabase paymentDatabase; + private final ChatColorsDatabase chatColorsDatabase; public static DatabaseFactory getInstance(Context context) { if (instance == null) { @@ -174,6 +175,10 @@ public class DatabaseFactory { return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase(); } + public static ChatColorsDatabase getChatColorsDatabase(Context context) { + return getInstance(context).chatColorsDatabase; + } + public static void upgradeRestored(Context context, SQLiteDatabase database){ synchronized (lock) { getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1); @@ -223,6 +228,7 @@ public class DatabaseFactory { this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper); this.mentionDatabase = new MentionDatabase(context, databaseHelper); this.paymentDatabase = new PaymentDatabase(context, databaseHelper); + this.chatColorsDatabase = new ChatColorsDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index aee9794c50..62598d68f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -29,6 +29,7 @@ public final class DatabaseObserver { private final Map> verboseConversationObservers; private final Map> paymentObservers; private final Set allPaymentsObservers; + private final Set chatColorsObservers; public DatabaseObserver(Application application) { this.application = application; @@ -38,6 +39,7 @@ public final class DatabaseObserver { this.verboseConversationObservers = new HashMap<>(); this.paymentObservers = new HashMap<>(); this.allPaymentsObservers = new HashSet<>(); + this.chatColorsObservers = new HashSet<>(); } public void registerConversationListObserver(@NonNull Observer listener) { @@ -70,12 +72,19 @@ public final class DatabaseObserver { }); } + public void registerChatColorsObserver(@NonNull Observer listener) { + executor.execute(() -> { + chatColorsObservers.add(listener); + }); + } + public void unregisterObserver(@NonNull Observer listener) { executor.execute(() -> { conversationListObservers.remove(listener); unregisterMapped(conversationObservers, listener); unregisterMapped(verboseConversationObservers, listener); unregisterMapped(paymentObservers, listener); + chatColorsObservers.remove(listener); }); } @@ -131,6 +140,14 @@ public final class DatabaseObserver { }); } + public void notifyChatColorsListeners() { + executor.execute(() -> { + for (Observer chatColorsObserver : chatColorsObservers) { + chatColorsObserver.onChanged(); + } + }); + } + private void registerMapped(@NonNull Map> map, @NonNull K key, @NonNull Observer listener) { Set listeners = map.get(key); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index f60b9820d4..b27db631d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -16,6 +16,7 @@ import com.google.protobuf.InvalidProtocolBufferException; import net.sqlcipher.SQLException; import net.sqlcipher.database.SQLiteConstraintException; +import org.jetbrains.annotations.NotNull; import org.signal.core.util.logging.Log; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.zkgroup.InvalidInputException; @@ -23,12 +24,14 @@ import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.color.MaterialColor; -import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor; import org.thoughtcrime.securesms.database.model.databaseprotos.DeviceLastResetTime; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileKeyCredentialColumnData; import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras; @@ -62,7 +65,6 @@ import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.libsignal.util.guava.Preconditions; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.storage.SignalAccountRecord; @@ -110,7 +112,7 @@ public class RecipientDatabase extends Database { private static final String CALL_VIBRATE = "call_vibrate"; private static final String NOTIFICATION_CHANNEL = "notification_channel"; private static final String MUTE_UNTIL = "mute_until"; - private static final String COLOR = "color"; + //private static final String COLOR = "color"; private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time"; @@ -145,6 +147,8 @@ public class RecipientDatabase extends Database { public static final String ABOUT_EMOJI = "about_emoji"; private static final String EXTRAS = "extras"; private static final String GROUPS_IN_COMMON = "groups_in_common"; + private static final String CHAT_COLORS = "chat_colors"; + private static final String CUSTOM_CHAT_COLORS_ID = "custom_chat_colors_id"; public static final String SEARCH_PROFILE_NAME = "search_signal_profile"; private static final String SORT_NAME = "sort_name"; @@ -160,7 +164,7 @@ public class RecipientDatabase extends Database { private static final String[] RECIPIENT_PROJECTION = new String[] { ID, UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE, - BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED, + BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED, PROFILE_KEY, PROFILE_KEY_CREDENTIAL, SYSTEM_JOINED_NAME, SYSTEM_GIVEN_NAME, SYSTEM_FAMILY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI, PROFILE_GIVEN_NAME, PROFILE_FAMILY_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, LAST_PROFILE_FETCH, @@ -172,7 +176,8 @@ public class RecipientDatabase extends Database { MENTION_SETTING, WALLPAPER, WALLPAPER_URI, MENTION_SETTING, ABOUT, ABOUT_EMOJI, - EXTRAS, GROUPS_IN_COMMON + EXTRAS, GROUPS_IN_COMMON, + CHAT_COLORS, CUSTOM_CHAT_COLORS_ID }; private static final String[] ID_PROJECTION = new String[]{ID}; @@ -321,7 +326,6 @@ public class RecipientDatabase extends Database { CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " + MUTE_UNTIL + " INTEGER DEFAULT 0, " + - COLOR + " TEXT DEFAULT NULL, " + SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " + DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " + @@ -354,8 +358,10 @@ public class RecipientDatabase extends Database { WALLPAPER_URI + " TEXT DEFAULT NULL, " + ABOUT + " TEXT DEFAULT NULL, " + ABOUT_EMOJI + " TEXT DEFAULT NULL, " + - EXTRAS + " BLOB DEFAULT NULL, " + - GROUPS_IN_COMMON + " INTEGER DEFAULT 0);"; + EXTRAS + " BLOB DEFAULT NULL, " + + GROUPS_IN_COMMON + " INTEGER DEFAULT 0, " + + CHAT_COLORS + " BLOB DEFAULT NULL, " + + CUSTOM_CHAT_COLORS_ID + " INTEGER DEFAULT 0);"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + " FROM " + TABLE_NAME + @@ -1025,10 +1031,6 @@ public class RecipientDatabase extends Database { values.put(MUTE_UNTIL, contact.getMuteUntil()); values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(contact.getId().getRaw())); - if (contact.isProfileSharingEnabled() && isInsert && !profileName.isEmpty()) { - values.put(COLOR, ContactColors.generateFor(profileName.toString()).serialize()); - } - if (contact.hasUnknownFields()) { values.put(STORAGE_PROTO, Base64.encodeBytes(contact.serializeUnknownFields())); } else { @@ -1170,7 +1172,6 @@ public class RecipientDatabase extends Database { int messageVibrateState = CursorUtil.requireInt(cursor, MESSAGE_VIBRATE); int callVibrateState = CursorUtil.requireInt(cursor, CALL_VIBRATE); long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); - String serializedColor = CursorUtil.requireString(cursor, COLOR); int insightsBannerTier = CursorUtil.requireInt(cursor, SEEN_INVITE_REMINDER); int defaultSubscriptionId = CursorUtil.requireInt(cursor, DEFAULT_SUBSCRIPTION_ID); int expireMessages = CursorUtil.requireInt(cursor, MESSAGE_EXPIRATION_TIME); @@ -1195,21 +1196,15 @@ public class RecipientDatabase extends Database { String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING); byte[] wallpaper = CursorUtil.requireBlob(cursor, WALLPAPER); + byte[] serializedChatColors = CursorUtil.requireBlob(cursor, CHAT_COLORS); + long customChatColorsId = CursorUtil.requireLong(cursor, CUSTOM_CHAT_COLORS_ID); String about = CursorUtil.requireString(cursor, ABOUT); String aboutEmoji = CursorUtil.requireString(cursor, ABOUT_EMOJI); boolean hasGroupsInCommon = CursorUtil.requireBoolean(cursor, GROUPS_IN_COMMON); - MaterialColor color; byte[] profileKey = null; ProfileKeyCredential profileKeyCredential = null; - try { - color = serializedColor == null ? null : MaterialColor.fromSerialized(serializedColor); - } catch (MaterialColor.UnknownColorException e) { - Log.w(TAG, e); - color = null; - } - if (profileKeyString != null) { try { profileKey = Base64.decode(profileKeyString); @@ -1247,6 +1242,15 @@ public class RecipientDatabase extends Database { } } + ChatColors chatColors = null; + if (serializedChatColors != null) { + try { + chatColors = ChatColors.forChatColor(ChatColors.Id.forLongValue(customChatColorsId), ChatColor.parseFrom(serializedChatColors)); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Failed to parse chat colors.", e); + } + } + return new RecipientSettings(RecipientId.from(id), uuid, username, @@ -1260,7 +1264,6 @@ public class RecipientDatabase extends Database { VibrateState.fromId(callVibrateState), Util.uri(messageRingtone), Util.uri(callRingtone), - color, defaultSubscriptionId, expireMessages, RegisteredState.fromId(registeredState), @@ -1284,6 +1287,7 @@ public class RecipientDatabase extends Database { storageKey, MentionSetting.fromId(mentionSettingId), chatWallpaper, + chatColors, about, aboutEmoji, getSyncExtras(cursor), @@ -1333,31 +1337,123 @@ public class RecipientDatabase extends Database { return new BulkOperationsHandle(database); } - public void setColor(@NonNull RecipientId id, @NonNull MaterialColor color) { + void onUpdatedChatColors(@NonNull ChatColors chatColors) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String where = CUSTOM_CHAT_COLORS_ID + " = ?"; + String[] args = SqlUtil.buildArgs(chatColors.getId().getLongValue()); + List updated = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, SqlUtil.buildArgs(ID), where, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + updated.add(RecipientId.from(CursorUtil.requireLong(cursor, ID))); + } + } + + if (updated.isEmpty()) { + Log.d(TAG, "No recipients utilizing updated chat color."); + } else { + ContentValues values = new ContentValues(2); + + values.put(CHAT_COLORS, chatColors.serialize().toByteArray()); + values.put(CUSTOM_CHAT_COLORS_ID, chatColors.getId().getLongValue()); + + database.update(TABLE_NAME, values, where, args); + + for (RecipientId recipientId : updated) { + Recipient.live(recipientId).refresh(); + } + } + } + + void onDeletedChatColors(@NonNull ChatColors chatColors) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String where = CUSTOM_CHAT_COLORS_ID + " = ?"; + String[] args = SqlUtil.buildArgs(chatColors.getId().getLongValue()); + List updated = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, SqlUtil.buildArgs(ID), where, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + updated.add(RecipientId.from(CursorUtil.requireLong(cursor, ID))); + } + } + + if (updated.isEmpty()) { + Log.d(TAG, "No recipients utilizing deleted chat color."); + } else { + ContentValues values = new ContentValues(2); + + values.put(CHAT_COLORS, (byte[]) null); + values.put(CUSTOM_CHAT_COLORS_ID, ChatColors.Id.NotSet.INSTANCE.getLongValue()); + + database.update(TABLE_NAME, values, where, args); + + for (RecipientId recipientId : updated) { + Recipient.live(recipientId).refresh(); + } + } + } + + public int getColorUsageCount(@NotNull ChatColors chatColors) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = SqlUtil.buildArgs("COUNT(*)"); + String where = CUSTOM_CHAT_COLORS_ID + " = ?"; + String[] args = SqlUtil.buildArgs(chatColors.getId().getLongValue()); + + try (Cursor cursor = db.query(TABLE_NAME, projection, where, args, null, null, null)) { + if (cursor == null) { + return 0; + } else { + cursor.moveToFirst(); + return cursor.getInt(0); + } + } + } + + public void clearAllColors() { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String where = CUSTOM_CHAT_COLORS_ID + " != ?"; + String[] args = SqlUtil.buildArgs(ChatColors.Id.NotSet.INSTANCE.getLongValue()); + List toUpdate = new LinkedList<>(); + + try (Cursor cursor = database.query(TABLE_NAME, SqlUtil.buildArgs(ID), where, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + toUpdate.add(RecipientId.from(CursorUtil.requireLong(cursor, ID))); + } + } + + if (toUpdate.isEmpty()) { + return; + } + ContentValues values = new ContentValues(); - values.put(COLOR, color.serialize()); + values.put(CHAT_COLORS, (byte[]) null); + values.put(CUSTOM_CHAT_COLORS_ID, ChatColors.Id.NotSet.INSTANCE.getLongValue()); + + database.update(TABLE_NAME, values, where, args); + + for (RecipientId id : toUpdate) { + Recipient.live(id).refresh(); + } + } + + public void clearColor(@NonNull RecipientId id) { + ContentValues values = new ContentValues(); + values.put(CHAT_COLORS, (byte[]) null); + values.put(CUSTOM_CHAT_COLORS_ID, ChatColors.Id.NotSet.INSTANCE.getLongValue()); if (update(id, values)) { Recipient.live(id).refresh(); } } - public void setColorIfNotSet(@NonNull RecipientId id, @NonNull MaterialColor color) { - if (setColorIfNotSetInternal(id, color)) { + public void setColor(@NonNull RecipientId id, @NonNull ChatColors color) { + ContentValues values = new ContentValues(); + values.put(CHAT_COLORS, color.serialize().toByteArray()); + values.put(CUSTOM_CHAT_COLORS_ID, color.getId().getLongValue()); + if (update(id, values)) { Recipient.live(id).refresh(); } } - private boolean setColorIfNotSetInternal(@NonNull RecipientId id, @NonNull MaterialColor color) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - String query = ID + " = ? AND " + COLOR + " IS NULL"; - String[] args = new String[]{ id.serialize() }; - - ContentValues values = new ContentValues(); - values.put(COLOR, color.serialize()); - - return db.update(TABLE_NAME, values, query, args) > 0; - } - public void setDefaultSubscriptionId(@NonNull RecipientId id, int defaultSubscriptionId) { ContentValues values = new ContentValues(); values.put(DEFAULT_SUBSCRIPTION_ID, defaultSubscriptionId); @@ -1739,7 +1835,6 @@ public class RecipientDatabase extends Database { contentValues.put(PROFILE_SHARING, enabled ? 1 : 0); boolean profiledUpdated = update(id, contentValues); - boolean colorUpdated = enabled && setColorIfNotSetInternal(id, ContactColors.generateFor(Recipient.resolved(id).getDisplayName(context))); if (profiledUpdated && enabled) { Optional group = DatabaseFactory.getGroupDatabase(context).getGroup(id); @@ -1749,7 +1844,7 @@ public class RecipientDatabase extends Database { } } - if (profiledUpdated || colorUpdated) { + if (profiledUpdated) { rotateStorageId(id); Recipient.live(id).refresh(); StorageSyncHelper.scheduleSyncForDataChange(); @@ -2161,22 +2256,55 @@ public class RecipientDatabase extends Database { return results; } - public void updateSystemContactColors(@NonNull ColorUpdater updater) { + /** + * We no longer automatically generate a chat color. This method is used only + * in the case of a legacy migration and otherwise should not be called. + */ + @Deprecated + public void updateSystemContactColors() { SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Map updates = new HashMap<>(); + Map updates = new HashMap<>(); db.beginTransaction(); - try (Cursor cursor = db.query(TABLE_NAME, new String[] {ID, COLOR, SYSTEM_JOINED_NAME}, SYSTEM_JOINED_NAME + " IS NOT NULL AND " + SYSTEM_JOINED_NAME + " != \"\"", null, null, null, null)) { + try (Cursor cursor = db.query(TABLE_NAME, new String[] {ID, "color", CHAT_COLORS, CUSTOM_CHAT_COLORS_ID, SYSTEM_JOINED_NAME}, SYSTEM_JOINED_NAME + " IS NOT NULL AND " + SYSTEM_JOINED_NAME + " != \"\"", null, null, null, null)) { while (cursor != null && cursor.moveToNext()) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - MaterialColor newColor = updater.update(cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_JOINED_NAME)), - cursor.getString(cursor.getColumnIndexOrThrow(COLOR))); + long id = CursorUtil.requireLong(cursor, ID); + String serializedColor = CursorUtil.requireString(cursor, "color"); + long customChatColorsId = CursorUtil.requireLong(cursor, CUSTOM_CHAT_COLORS_ID); + byte[] serializedChatColors = CursorUtil.requireBlob(cursor, CHAT_COLORS); + + ChatColors chatColors; + if (serializedChatColors != null) { + try { + chatColors = ChatColors.forChatColor(ChatColors.Id.forLongValue(customChatColorsId), ChatColor.parseFrom(serializedChatColors)); + } catch (InvalidProtocolBufferException e) { + chatColors = null; + } + } else { + chatColors = null; + } + + if (chatColors != null) { + return; + } + + if (serializedColor != null) { + try { + chatColors = ChatColorsMapper.getChatColors(MaterialColor.fromSerialized(serializedColor)); + } catch (MaterialColor.UnknownColorException e) { + return; + } + } else { + return; + } ContentValues contentValues = new ContentValues(1); - contentValues.put(COLOR, newColor.serialize()); + contentValues.put(CHAT_COLORS, chatColors.serialize().toByteArray()); + contentValues.put(CUSTOM_CHAT_COLORS_ID, chatColors.getId().getLongValue()); + db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] { String.valueOf(id) }); - updates.put(RecipientId.from(id), newColor); + updates.put(RecipientId.from(id), chatColors); } } finally { db.setTransactionSuccessful(); @@ -2754,7 +2882,8 @@ public class RecipientDatabase extends Database { uuidValues.put(CALL_VIBRATE, uuidSettings.getCallVibrateState() != VibrateState.DEFAULT ? uuidSettings.getCallVibrateState().getId() : e164Settings.getCallVibrateState().getId()); uuidValues.put(NOTIFICATION_CHANNEL, uuidSettings.getNotificationChannel() != null ? uuidSettings.getNotificationChannel() : e164Settings.getNotificationChannel()); uuidValues.put(MUTE_UNTIL, uuidSettings.getMuteUntil() > 0 ? uuidSettings.getMuteUntil() : e164Settings.getMuteUntil()); - uuidValues.put(COLOR, Optional.fromNullable(uuidSettings.getColor()).or(Optional.fromNullable(e164Settings.getColor())).transform(MaterialColor::serialize).orNull()); + uuidValues.put(CHAT_COLORS, Optional.fromNullable(uuidSettings.getChatColors()).or(Optional.fromNullable(e164Settings.getChatColors())).transform(colors -> colors.serialize().toByteArray()).orNull()); + uuidValues.put(CUSTOM_CHAT_COLORS_ID, Optional.fromNullable(uuidSettings.getChatColors()).or(Optional.fromNullable(e164Settings.getChatColors())).transform(colors -> colors.getId().getLongValue()).orNull()); uuidValues.put(SEEN_INVITE_REMINDER, e164Settings.getInsightsBannerTier().getId()); uuidValues.put(DEFAULT_SUBSCRIPTION_ID, e164Settings.getDefaultSubscriptionId().or(-1)); uuidValues.put(MESSAGE_EXPIRATION_TIME, uuidSettings.getExpireMessages() > 0 ? uuidSettings.getExpireMessages() : e164Settings.getExpireMessages()); @@ -2891,9 +3020,8 @@ public class RecipientDatabase extends Database { refreshQualifyingValues.put(SYSTEM_CONTACT_URI, systemContactUri); boolean updatedValues = update(id, refreshQualifyingValues); - boolean updatedColor = !TextUtils.isEmpty(joinedName) && setColorIfNotSetInternal(id, ContactColors.generateFor(joinedName)); - if (updatedValues || updatedColor) { + if (updatedValues) { pendingContactInfoMap.put(id, new PendingContactInfo(systemProfileName, photoUri, systemPhoneLabel, systemContactUri)); } @@ -2951,7 +3079,7 @@ public class RecipientDatabase extends Database { } public interface ColorUpdater { - MaterialColor update(@NonNull String name, @Nullable String color); + ChatColors update(@NonNull String name, @Nullable MaterialColor materialColor); } public static class RecipientSettings { @@ -2968,7 +3096,6 @@ public class RecipientDatabase extends Database { private final VibrateState callVibrateState; private final Uri messageRingtone; private final Uri callRingtone; - private final MaterialColor color; private final int defaultSubscriptionId; private final int expireMessages; private final RegisteredState registered; @@ -2994,6 +3121,7 @@ public class RecipientDatabase extends Database { private final byte[] storageId; private final MentionSetting mentionSetting; private final ChatWallpaper wallpaper; + private final ChatColors chatColors; private final String about; private final String aboutEmoji; private final SyncExtras syncExtras; @@ -3013,7 +3141,6 @@ public class RecipientDatabase extends Database { @NonNull VibrateState callVibrateState, @Nullable Uri messageRingtone, @Nullable Uri callRingtone, - @Nullable MaterialColor color, int defaultSubscriptionId, int expireMessages, @NonNull RegisteredState registered, @@ -3037,6 +3164,7 @@ public class RecipientDatabase extends Database { @Nullable byte[] storageId, @NonNull MentionSetting mentionSetting, @Nullable ChatWallpaper wallpaper, + @Nullable ChatColors chatColors, @Nullable String about, @Nullable String aboutEmoji, @NonNull SyncExtras syncExtras, @@ -3056,7 +3184,6 @@ public class RecipientDatabase extends Database { this.callVibrateState = callVibrateState; this.messageRingtone = messageRingtone; this.callRingtone = callRingtone; - this.color = color; this.defaultSubscriptionId = defaultSubscriptionId; this.expireMessages = expireMessages; this.registered = registered; @@ -3082,6 +3209,7 @@ public class RecipientDatabase extends Database { this.storageId = storageId; this.mentionSetting = mentionSetting; this.wallpaper = wallpaper; + this.chatColors = chatColors; this.about = about; this.aboutEmoji = aboutEmoji; this.syncExtras = syncExtras; @@ -3117,10 +3245,6 @@ public class RecipientDatabase extends Database { return groupType; } - public @Nullable MaterialColor getColor() { - return color; - } - public boolean isBlocked() { return blocked; } @@ -3241,6 +3365,10 @@ public class RecipientDatabase extends Database { return wallpaper; } + public @Nullable ChatColors getChatColors() { + return chatColors; + } + public @Nullable String getAbout() { return about; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index db4e8a15fe..987e3ea0c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -22,10 +22,14 @@ import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteOpenHelper; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.ChatColorsDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; @@ -77,6 +81,7 @@ import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatabase { @@ -182,8 +187,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab private static final int STORAGE_SERVICE_REFACTOR = 97; private static final int CLEAR_MMS_STORAGE_IDS = 98; private static final int SERVER_GUID = 99; + private static final int CHAT_COLORS = 100; - private static final int DATABASE_VERSION = 99; + private static final int DATABASE_VERSION = 100; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -215,6 +221,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL(UnknownStorageIdDatabase.CREATE_TABLE); db.execSQL(MentionDatabase.CREATE_TABLE); db.execSQL(PaymentDatabase.CREATE_TABLE); + db.execSQL(ChatColorsDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); @@ -1463,6 +1470,27 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL("ALTER TABLE mms ADD COLUMN server_guid TEXT DEFAULT NULL"); } + if (oldVersion < CHAT_COLORS) { + db.execSQL("ALTER TABLE recipient ADD COLUMN chat_colors BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient ADD COLUMN custom_chat_colors_id INTEGER DEFAULT 0"); + db.execSQL("CREATE TABLE chat_colors (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "chat_colors BLOB)"); + + Set> entrySet = ChatColorsMapper.getEntrySet(); + String where = "color = ? AND group_id is NULL"; + + for (Map.Entry entry : entrySet) { + String[] whereArgs = SqlUtil.buildArgs(entry.getKey().serialize()); + ContentValues values = new ContentValues(2); + + values.put("chat_colors", entry.getValue().serialize().toByteArray()); + values.put("custom_chat_colors_id", entry.getValue().getId().getLongValue()); + + db.update("recipient", values, where, whereArgs); + } + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java index 0ee3880756..237f897424 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.giph.mp4; +import android.view.ViewGroup; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.source.MediaSource; +import org.thoughtcrime.securesms.util.Projection; + public interface GiphyMp4Playable { /** * Shows the area in which a video would be projected. Called when a video will not @@ -40,8 +43,9 @@ public interface GiphyMp4Playable { /** * Width, height, and (x,y) of view which video player will "project" into + * @param viewGroup */ - @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerview); + @NonNull Projection getProjection(@NonNull ViewGroup viewGroup); /** * Specifies whether the content can start playing. diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Projection.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Projection.java deleted file mode 100644 index 3a36ad3f0f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Projection.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.giph.mp4; - -import android.graphics.Rect; -import android.view.View; -import android.view.ViewParent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import org.thoughtcrime.securesms.components.CornerMask; - -/** - * Describes the position and size of the area where a video should play. - */ -public final class GiphyMp4Projection { - - private final float x; - private final float y; - private final int width; - private final int height; - private final CornerMask cornerMask; - - public GiphyMp4Projection(float x, float y, int width, int height, @Nullable CornerMask cornerMask) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - this.cornerMask = cornerMask; - } - - public float getX() { - return x; - } - - public float getY() { - return y; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public @Nullable CornerMask getCornerMask() { - return cornerMask; - } - - public @NonNull GiphyMp4Projection translateX(float xTranslation) { - return new GiphyMp4Projection(x + xTranslation, y, width, height, cornerMask); - } - - public static @NonNull GiphyMp4Projection forView(@NonNull RecyclerView recyclerView, @NonNull View view, @Nullable CornerMask cornerMask) { - Rect viewBounds = new Rect(); - - view.getDrawingRect(viewBounds); - recyclerView.offsetDescendantRectToMyCoords(view, viewBounds); - return new GiphyMp4Projection(viewBounds.left, viewBounds.top, view.getWidth(), view.getHeight(), cornerMask); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java index e10b12732c..8dc77eae5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java @@ -18,6 +18,7 @@ import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import org.signal.glide.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.CornerMask; +import org.thoughtcrime.securesms.util.Projection; import java.util.ArrayList; import java.util.List; @@ -120,7 +121,7 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.EventListene return holders; } - public void setCornerMask(@Nullable CornerMask cornerMask) { - player.setCornerMask(cornerMask); + public void setCorners(@Nullable Projection.Corners corners) { + player.setCorners(corners); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java index 52ea2da348..3fa726d5c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionRecycler.java @@ -8,6 +8,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; +import org.thoughtcrime.securesms.conversation.ConversationItemSwipeCallback; +import org.thoughtcrime.securesms.util.Projection; + import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -16,7 +19,7 @@ import java.util.Set; /** * Logic for updating content and positioning of videos as the user scrolls the list of gifs. */ -public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackController.Callback, GiphyMp4DisplayUpdater { +public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackController.Callback { private final List holders; private final SparseArray playing; @@ -48,7 +51,6 @@ public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackControl } } - @Override public void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4Playable holder) { GiphyMp4ProjectionPlayerHolder playerHolder = getCurrentHolder(holder.getAdapterPosition()); if (playerHolder != null) { @@ -84,7 +86,7 @@ public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackControl } private void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) { - GiphyMp4Projection projection = giphyMp4Playable.getProjection(recyclerView); + Projection projection = giphyMp4Playable.getProjection(recyclerView); holder.getContainer().setX(projection.getX()); holder.getContainer().setY(projection.getY()); @@ -96,7 +98,7 @@ public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackControl holder.getContainer().setLayoutParams(params); } - holder.setCornerMask(projection.getCornerMask()); + holder.setCorners(projection.getCorners()); } private void startPlayback(@NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java index 4c95d7f466..6ff56539a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.ui.PlayerView; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.CornerMask; +import org.thoughtcrime.securesms.util.Projection; /** * Video Player class specifically created for the GiphyMp4Fragment. @@ -72,8 +73,13 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif exoPlayer.prepare(mediaSource); } - void setCornerMask(@Nullable CornerMask cornerMask) { - this.cornerMask = new CornerMask(this, cornerMask); + void setCorners(@Nullable Projection.Corners corners) { + if (corners == null) { + this.cornerMask = null; + } else { + this.cornerMask = new CornerMask(this); + this.cornerMask.setRadii(corners.getTopLeft(), corners.getTopRight(), corners.getBottomRight(), corners.getBottomLeft()); + } invalidate(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java index 007ca2d2d5..5a684ef0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java @@ -4,6 +4,7 @@ import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.view.View; +import android.view.ViewGroup; import android.widget.ImageView; import androidx.annotation.NonNull; @@ -16,10 +17,11 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; import org.thoughtcrime.securesms.giph.model.GiphyImage; import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.Util; /** @@ -44,7 +46,7 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyM this.container = (AspectRatioFrameLayout) itemView; this.listener = listener; this.stillImage = itemView.findViewById(R.id.still_image); - this.placeholder = new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(itemView.getContext())); + this.placeholder = new ColorDrawable(Util.getRandomElement(ChatColorsPalette.Names.getAll()).getColor(itemView.getContext())); this.mediaSourceFactory = mediaSourceFactory; container.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH); @@ -78,8 +80,8 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyM } @Override - public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) { - return GiphyMp4Projection.forView(recyclerView, itemView, null); + public @NonNull Projection getProjection(@NonNull ViewGroup recyclerView) { + return Projection.relativeToParent(recyclerView, itemView, null); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java index bcb83c3d32..b8719648aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java @@ -21,12 +21,12 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog; import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; @@ -88,7 +88,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF groupCancelButton.setOnClickListener(v -> dismiss()); - avatar.setImageBytesForGroup(null, new FallbackPhotoProvider(), MaterialColor.STEEL); + avatar.setImageBytesForGroup(null, new FallbackPhotoProvider(), ChatColorsPalette.UNKNOWN_CONTACT); return view; } @@ -130,7 +130,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF break; } - avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL); + avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), ChatColorsPalette.UNKNOWN_CONTACT); groupCancelButton.setVisibility(View.VISIBLE); }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 211030a395..b9e39bed82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.graphics.Color; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.text.TextUtils; import android.view.LayoutInflater; @@ -18,6 +19,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; import androidx.appcompat.widget.Toolbar; +import androidx.core.widget.TextViewCompat; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProviders; @@ -32,12 +34,13 @@ import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.PushContactSelectionActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThreadPhotoRailView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.GroupErrors; @@ -122,14 +125,7 @@ public class ManageGroupFragment extends LoggingFragment { private View toggleAllMembers; private View groupLinkRow; private TextView groupLinkButton; - private View wallpaperButton; - - private final Recipient.FallbackPhotoProvider fallbackPhotoProvider = new Recipient.FallbackPhotoProvider() { - @Override - public @NonNull FallbackContactPhoto getPhotoForGroup() { - return new FallbackPhoto80dp(R.drawable.ic_group_80, MaterialColor.ULTRAMARINE.toAvatarColor(requireContext())); - } - }; + private TextView wallpaperButton; static ManageGroupFragment newInstance(@NonNull String groupId) { ManageGroupFragment fragment = new ManageGroupFragment(); @@ -227,8 +223,6 @@ public class ManageGroupFragment extends LoggingFragment { } }); - avatar.setFallbackPhotoProvider(fallbackPhotoProvider); - toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); toolbar.setOnMenuItemClickListener(this::onMenuItemSelected); toolbar.inflateMenu(R.menu.manage_group_fragment); @@ -244,6 +238,7 @@ public class ManageGroupFragment extends LoggingFragment { viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCountUnderAvatar::setText); viewModel.getFullMemberCountSummary().observe(getViewLifecycleOwner(), memberCountAboveList::setText); viewModel.getGroupRecipient().observe(getViewLifecycleOwner(), groupRecipient -> { + avatar.setFallbackPhotoProvider(new FallbackPhotoProvider(groupRecipient.getChatColors())); avatar.setRecipient(groupRecipient); avatar.setOnClickListener(v -> { FragmentActivity activity = requireActivity(); @@ -253,6 +248,10 @@ public class ManageGroupFragment extends LoggingFragment { customNotificationsRow.setOnClickListener(v -> CustomNotificationsDialogFragment.create(groupRecipient.getId()) .show(requireFragmentManager(), DIALOG_TAG)); wallpaperButton.setOnClickListener(v -> startActivity(ChatWallpaperActivity.createIntent(requireContext(), groupRecipient.getId()))); + + Drawable colorCircle = groupRecipient.getChatColors().asCircle(); + colorCircle.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16)); + TextViewCompat.setCompoundDrawablesRelative(wallpaperButton, null, null, colorCircle, null); }); if (groupId.isV2()) { @@ -506,4 +505,18 @@ public class ManageGroupFragment extends LoggingFragment { }); } } + + private final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + + private final ChatColors groupColors; + + private FallbackPhotoProvider(@NonNull ChatColors groupColors) { + this.groupColors = groupColors; + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new FallbackPhoto80dp(R.drawable.ic_group_80, groupColors); + } + }; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java index 467226ee84..84f4db4761 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsRepository.java @@ -9,10 +9,10 @@ import androidx.core.util.Consumer; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; -import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; @@ -67,11 +67,7 @@ public class InsightsRepository implements InsightsDashboardViewModel.Repository SimpleTask.run(() -> { Recipient self = Recipient.self().resolve(); String name = Optional.fromNullable(self.getDisplayName(context)).or(""); - MaterialColor fallbackColor = self.getColor(); - - if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { - fallbackColor = ContactColors.generateFor(name); - } + ChatColors fallbackColor = self.getChatColors(); return new InsightsUserAvatar(new ProfileContactPhoto(self, self.getProfileAvatar()), fallbackColor, diff --git a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java index 24cb224339..b2acd9355f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/insights/InsightsUserAvatar.java @@ -8,24 +8,24 @@ import androidx.annotation.NonNull; import com.bumptech.glide.load.engine.DiskCacheStrategy; -import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.mms.GlideApp; class InsightsUserAvatar { private final ProfileContactPhoto profileContactPhoto; - private final MaterialColor fallbackColor; + private final ChatColors fallbackColor; private final FallbackContactPhoto fallbackContactPhoto; - InsightsUserAvatar(@NonNull ProfileContactPhoto profileContactPhoto, @NonNull MaterialColor fallbackColor, @NonNull FallbackContactPhoto fallbackContactPhoto) { + InsightsUserAvatar(@NonNull ProfileContactPhoto profileContactPhoto, @NonNull ChatColors fallbackColor, @NonNull FallbackContactPhoto fallbackContactPhoto) { this.profileContactPhoto = profileContactPhoto; this.fallbackColor = fallbackColor; this.fallbackContactPhoto = fallbackContactPhoto; } private Drawable fallbackDrawable(@NonNull Context context) { - return fallbackContactPhoto.asDrawable(context, fallbackColor.toAvatarColor(context)); + return fallbackContactPhoto.asDrawable(context, fallbackColor); } void load(ImageView into) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java index 9bea625484..b253400593 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java @@ -12,6 +12,7 @@ import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -144,7 +145,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob { out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, recipient), Optional.fromNullable(recipient.isGroup() || recipient.isSystemContact() ? recipient.getDisplayName(context) : null), getAvatar(recipient.getId(), recipient.getContactUri()), - Optional.fromNullable(recipient.getColor().serialize()), + Optional.of(ChatColorsMapper.getMaterialColor(recipient.getChatColors()).serialize()), verifiedMessage, ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()), recipient.isBlocked(), @@ -198,7 +199,6 @@ public class MultiDeviceContactUpdateJob extends BaseJob { Optional identity = DatabaseFactory.getIdentityDatabase(context).getIdentity(recipient.getId()); Optional verified = getVerifiedMessage(recipient, identity); Optional name = Optional.fromNullable(recipient.isSystemContact() ? recipient.getDisplayName(context) : recipient.getGroupName(context)); - Optional color = Optional.of(recipient.getColor().serialize()); Optional profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); boolean blocked = recipient.isBlocked(); Optional expireTimer = recipient.getExpireMessages() > 0 ? Optional.of(recipient.getExpireMessages()) : Optional.absent(); @@ -207,7 +207,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob { out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, recipient), name, getAvatar(recipient.getId(), recipient.getContactUri()), - color, + Optional.of(ChatColorsMapper.getMaterialColor(recipient.getChatColors()).serialize()), verified, profileKey, blocked, @@ -224,7 +224,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob { out.write(new DeviceContact(RecipientUtil.toSignalServiceAddress(context, self), Optional.absent(), Optional.absent(), - Optional.of(self.getColor().serialize()), + Optional.of(ChatColorsMapper.getMaterialColor(self.getChatColors()).serialize()), Optional.absent(), ProfileKeyUtil.profileKeyOptionalOrThrow(self.getProfileKey()), false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java index 37ff1aa500..c3b1e47ff3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java @@ -6,6 +6,7 @@ import android.os.ParcelFileDescriptor; import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -117,7 +118,7 @@ public class MultiDeviceGroupUpdateJob extends BaseJob { getAvatar(record.getRecipientId()), record.isActive(), expirationTimer, - Optional.of(recipient.getColor().serialize()), + Optional.of(ChatColorsMapper.getMaterialColor(recipient.getChatColors()).serialize()), recipient.isBlocked(), Optional.fromNullable(inboxPositions.get(recipientId)), archived.contains(recipientId))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ChatColorsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ChatColorsValues.kt new file mode 100644 index 0000000000..4567de3749 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ChatColorsValues.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.keyvalue + +import com.google.protobuf.InvalidProtocolBufferException +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor + +internal class ChatColorsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + private const val KEY_CHAT_COLORS = "chat_colors.chat_colors" + private const val KEY_CHAT_COLORS_ID = "chat_colors.chat_colors.id" + } + + override fun onFirstEverAppLaunch() = Unit + + override fun getKeysToIncludeInBackup(): MutableList = mutableListOf() + + val hasChatColors: Boolean + @JvmName("hasChatColors") + get() = chatColors != null + + var chatColors: ChatColors? + get() = getBlob(KEY_CHAT_COLORS, null)?.let { bytes -> + try { + ChatColors.forChatColor(chatColorsId, ChatColor.parseFrom(bytes)) + } catch (e: InvalidProtocolBufferException) { + null + } + } + set(value) { + if (value != null) { + putBlob(KEY_CHAT_COLORS, value.serialize().toByteArray()) + chatColorsId = value.id + } else { + remove(KEY_CHAT_COLORS) + } + } + + private var chatColorsId: ChatColors.Id + get() = ChatColors.Id.forLongValue(getLong(KEY_CHAT_COLORS_ID, ChatColors.Id.NotSet.longValue)) + set(value) = putLong(KEY_CHAT_COLORS_ID, value.longValue) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 9aa5f130e5..2c3dfc0880 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -36,6 +36,7 @@ public final class SignalStore { private final PaymentsValues paymentsValues; private final ProxyValues proxyValues; private final RateLimitValues rateLimitValues; + private final ChatColorsValues chatColorsValues; private SignalStore() { this.store = new KeyValueStore(ApplicationDependencies.getApplication()); @@ -57,6 +58,7 @@ public final class SignalStore { this.paymentsValues = new PaymentsValues(store); this.proxyValues = new ProxyValues(store); this.rateLimitValues = new RateLimitValues(store); + this.chatColorsValues = new ChatColorsValues(store); } public static void onFirstEverAppLaunch() { @@ -78,6 +80,7 @@ public final class SignalStore { paymentsValues().onFirstEverAppLaunch(); proxy().onFirstEverAppLaunch(); rateLimit().onFirstEverAppLaunch(); + chatColorsValues().onFirstEverAppLaunch(); } public static List getKeysToIncludeInBackup() { @@ -100,6 +103,7 @@ public final class SignalStore { keys.addAll(paymentsValues().getKeysToIncludeInBackup()); keys.addAll(proxy().getKeysToIncludeInBackup()); keys.addAll(rateLimit().getKeysToIncludeInBackup()); + keys.addAll(chatColorsValues().getKeysToIncludeInBackup()); return keys; } @@ -184,6 +188,10 @@ public final class SignalStore { return INSTANCE.rateLimitValues; } + public static @NonNull ChatColorsValues chatColorsValues() { + return INSTANCE.chatColorsValues; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { return new GroupsV2AuthorizationSignalStoreCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java index cf69756e82..9550812952 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java @@ -69,6 +69,6 @@ abstract class SignalStoreValues { } void remove(@NonNull String key) { - store.beginWrite().remove(key); + store.beginWrite().remove(key).apply(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java index c11bfd96e8..311f5f2946 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.longmessage; import android.content.Context; import android.content.Intent; import android.graphics.PorterDuff; -import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.text.SpannableString; import android.text.method.LinkMovementMethod; @@ -23,21 +22,21 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.components.ConversationItemFooter; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ColorizerView; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; -import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.DynamicDarkActionBarTheme; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.WindowUtil; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.views.Stub; +import java.util.Collections; + import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme; public class LongMessageActivity extends PassphraseRequiredActivity { @@ -51,8 +50,10 @@ public class LongMessageActivity extends PassphraseRequiredActivity { private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); private final DynamicTheme dynamicTheme = new DynamicTheme(); - private Stub sentBubble; - private Stub receivedBubble; + private Stub sentBubble; + private Stub receivedBubble; + private ColorizerView colorizerView; + private BubbleLayoutListener bubbleLayoutListener; private LongMessageViewModel viewModel; @@ -78,6 +79,9 @@ public class LongMessageActivity extends PassphraseRequiredActivity { sentBubble = new Stub<>(findViewById(R.id.longmessage_sent_stub)); receivedBubble = new Stub<>(findViewById(R.id.longmessage_received_stub)); + colorizerView = findViewById(R.id.colorizer); + + bubbleLayoutListener = new BubbleLayoutListener(); initViewModel(getIntent().getLongExtra(KEY_MESSAGE_ID, -1), getIntent().getBooleanExtra(KEY_IS_MMS, false)); @@ -130,10 +134,14 @@ public class LongMessageActivity extends PassphraseRequiredActivity { if (message.get().getMessageRecord().isOutgoing()) { bubble = sentBubble.get(); - bubble.getBackground().setColorFilter(ContextCompat.getColor(this, R.color.signal_background_secondary), PorterDuff.Mode.MULTIPLY); + colorizerView.setVisibility(View.VISIBLE); + colorizerView.setBackground(message.get().getMessageRecord().getRecipient().getChatColors().getChatBubbleMask()); + bubble.getBackground().setColorFilter(message.get().getMessageRecord().getRecipient().getChatColors().getChatBubbleColorFilter()); + bubble.addOnLayoutChangeListener(bubbleLayoutListener); + bubbleLayoutListener.onLayoutChange(bubble, 0, 0, 0, 0, 0, 0, 0, 0); } else { bubble = receivedBubble.get(); - bubble.getBackground().setColorFilter(message.get().getMessageRecord().getRecipient().getColor().toConversationColor(this), PorterDuff.Mode.MULTIPLY); + bubble.getBackground().setColorFilter(ContextCompat.getColor(this, R.color.signal_background_secondary), PorterDuff.Mode.MULTIPLY); } EmojiTextView text = bubble.findViewById(R.id.longmessage_text); @@ -146,7 +154,7 @@ public class LongMessageActivity extends PassphraseRequiredActivity { text.setText(styledBody); text.setMovementMethod(LinkMovementMethod.getInstance()); text.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().getMessageFontSize()); - if (message.get().getMessageRecord().isOutgoing()) { + if (!message.get().getMessageRecord().isOutgoing()) { text.setMentionBackgroundTint(ContextCompat.getColor(this, isDarkTheme(this) ? R.color.core_grey_60 : R.color.core_grey_20)); } else { text.setMentionBackgroundTint(ContextCompat.getColor(this, R.color.transparent_black_40)); @@ -171,4 +179,13 @@ public class LongMessageActivity extends PassphraseRequiredActivity { } return messageBody; } + + private final class BubbleLayoutListener implements View.OnLayoutChangeListener { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { + Projection projection = Projection.relativeToViewWithCommonRoot(v, colorizerView, new Projection.Corners(16)); + + colorizerView.setProjections(Collections.singletonList(projection)); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java index b743f40c89..a3f6d6765e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java @@ -14,6 +14,8 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.conversation.colors.Colorizer; +import org.thoughtcrime.securesms.conversation.colors.ColorizerView; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController; @@ -42,6 +44,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { private GlideRequests glideRequests; private MessageDetailsViewModel viewModel; private MessageDetailsAdapter adapter; + private Colorizer colorizer; private DynamicTheme dynamicTheme = new DynamicTheme(); @@ -95,11 +98,15 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { } private void initializeList() { - RecyclerView list = findViewById(R.id.message_details_list); - adapter = new MessageDetailsAdapter(this, glideRequests); + RecyclerView list = findViewById(R.id.message_details_list); + ColorizerView colorizerView = findViewById(R.id.message_details_colorizer); + + colorizer = new Colorizer(colorizerView); + adapter = new MessageDetailsAdapter(this, glideRequests, colorizer); list.setAdapter(adapter); list.setItemAnimator(null); + colorizer.attachToRecyclerView(list); } private void initializeViewModel() { @@ -116,6 +123,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { adapter.submitList(convertToRows(details)); } }); + viewModel.getRecipient().observe(this, recipient -> colorizer.onChatColorsChanged(recipient.getChatColors())); } private void initializeVideoPlayer() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java index a0b42dc952..5975dd56f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java @@ -12,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.mms.GlideRequests; import java.util.List; @@ -22,12 +23,14 @@ final class MessageDetailsAdapter extends ListAdapter getRecipientColor() { - return Transformations.distinctUntilChanged(Transformations.map(recipient, Recipient::getColor)); - } - @NonNull LiveData getMessageDetails() { return messageDetails; } + @NonNull LiveData getRecipient() { + return recipient; + } + static final class Factory implements ViewModelProvider.Factory { private final RecipientId recipientId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index d8c271e5cc..1bad3ea0d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -3,9 +3,6 @@ package org.thoughtcrime.securesms.messagedetails; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import android.graphics.Color; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; @@ -16,19 +13,22 @@ import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.recyclerview.widget.RecyclerView; +import com.annimon.stream.Stream; import com.google.android.exoplayer2.source.MediaSource; +import org.jetbrains.annotations.NotNull; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ClipProjectionDrawable; import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; -import org.thoughtcrime.securesms.conversation.MaskDrawable; +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.sms.MessageSender; import org.thoughtcrime.securesms.util.DateUtils; @@ -39,30 +39,34 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.sql.Date; import java.text.SimpleDateFormat; import java.util.HashSet; +import java.util.List; import java.util.Locale; +import java.util.Set; -final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable { - private final TextView sentDate; - private final TextView receivedDate; - private final TextView expiresIn; - private final TextView transport; - private final View expiresGroup; - private final View receivedGroup; - private final TextView errorText; - private final View resendButton; - private final View messageMetadata; - private final ViewStub updateStub; - private final ViewStub sentStub; - private final ViewStub receivedStub; - private final MaskDrawable maskDrawable; +final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable { + private final TextView sentDate; + private final TextView receivedDate; + private final TextView expiresIn; + private final TextView transport; + private final View expiresGroup; + private final View receivedGroup; + private final TextView errorText; + private final View resendButton; + private final View messageMetadata; + private final ViewStub updateStub; + private final ViewStub sentStub; + private final ViewStub receivedStub; + private final ClipProjectionDrawable clipProjectionDrawable; + private final Colorizer colorizer; private GlideRequests glideRequests; private ConversationItem conversationItem; private ExpiresUpdater expiresUpdater; - MessageHeaderViewHolder(@NonNull View itemView, GlideRequests glideRequests) { + MessageHeaderViewHolder(@NonNull View itemView, GlideRequests glideRequests, @NonNull Colorizer colorizer) { super(itemView); this.glideRequests = glideRequests; + this.colorizer = colorizer; sentDate = itemView.findViewById(R.id.message_details_header_sent_time); receivedDate = itemView.findViewById(R.id.message_details_header_received_time); @@ -77,8 +81,8 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G sentStub = itemView.findViewById(R.id.message_details_header_message_view_sent_multimedia); receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia); - maskDrawable = new MaskDrawable(itemView.getBackground()); - itemView.setBackground(maskDrawable); + clipProjectionDrawable = new ClipProjectionDrawable(itemView.getBackground()); + itemView.setBackground(clipProjectionDrawable); } void bind(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage, boolean running) { @@ -117,7 +121,8 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G false, false, new AttachmentMediaSourceFactory(conversationItem.getContext()), - true); + true, + colorizer); } private void bindErrorState(MessageRecord messageRecord) { @@ -213,14 +218,13 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G @Override public void showProjectionArea() { conversationItem.showProjectionArea(); - maskDrawable.setMask(null); + updateProjections(); } @Override public void hideProjectionArea() { conversationItem.hideProjectionArea(); - maskDrawable.setMask(conversationItem.getThumbnailMaskingRect((ViewGroup) itemView)); - maskDrawable.setCorners(conversationItem.getThumbnailCornerMask(itemView).getRadii()); + updateProjections(); } @Override @@ -234,7 +238,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G } @Override - public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerview) { + public @NonNull Projection getProjection(@NonNull ViewGroup recyclerview) { return conversationItem.getProjection(recyclerview); } @@ -243,6 +247,26 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G return conversationItem.canPlayContent(); } + @NotNull @Override public List getColorizerProjections() { + List projections = conversationItem.getColorizerProjections(); + updateProjections(); + return projections; + } + + private void updateProjections() { + Set projections = new HashSet<>(); + + if (canPlayContent()) { + projections.add(conversationItem.getProjection((ViewGroup) itemView)); + } + + projections.addAll(Stream.of(conversationItem.getColorizerProjections()) + .map(p -> Projection.translateFromRootToDescendantCoords(p, itemView)) + .toList()); + + clipProjectionDrawable.setProjections(projections); + } + private class ExpiresUpdater implements Runnable { private final long expireStartedTimestamp; diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java index c7e1931306..12990c34fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java @@ -10,6 +10,8 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; @@ -231,17 +233,8 @@ public class LegacyMigrationJob extends MigrationJob { if (lastSeenVersion < COLOR_MIGRATION) { long startTime = System.currentTimeMillis(); - DatabaseFactory.getRecipientDatabase(context).updateSystemContactColors((name, color) -> { - if (color != null) { - try { - return MaterialColor.fromSerialized(color); - } catch (MaterialColor.UnknownColorException e) { - Log.w(TAG, "Encountered an unknown color during legacy color migration.", e); - return ContactColorsLegacy.generateFor(name); - } - } - return ContactColorsLegacy.generateFor(name); - }); + //noinspection deprecation + DatabaseFactory.getRecipientDatabase(context).updateSystemContactColors(); Log.i(TAG, "Color migration took " + (System.currentTimeMillis() - startTime) + " ms"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 5647454dc8..b08fdbf983 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; @@ -97,7 +98,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil } else { setContentTitle(context.getString(R.string.SingleRecipientNotificationBuilder_signal)); - setLargeIcon(new GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context))); + setLargeIcon(new GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ChatColorsPalette.UNKNOWN_CONTACT)); } setShortcutId(ConversationUtil.getShortcutId(recipient)); @@ -123,10 +124,10 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) .get(); } catch (InterruptedException | ExecutionException e) { - return fallbackContactPhoto.asDrawable(context, recipient.getColor().toConversationColor(context)); + return fallbackContactPhoto.asDrawable(context, recipient.getChatColors()); } } else { - return fallbackContactPhoto.asDrawable(context, recipient.getColor().toConversationColor(context)); + return fallbackContactPhoto.asDrawable(context, recipient.getChatColors()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt index 597cba93b1..e00b7192de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt @@ -9,9 +9,9 @@ import android.text.SpannableStringBuilder import androidx.core.app.TaskStackBuilder import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.TurnOffContactJoinedNotificationsActivity -import org.thoughtcrime.securesms.contacts.avatars.ContactColors import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver import org.thoughtcrime.securesms.notifications.MarkReadReceiver @@ -52,7 +52,7 @@ data class NotificationConversation( return if (SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) { recipient.getContactDrawable(context) } else { - GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context)) + GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ChatColorsPalette.UNKNOWN_CONTACT) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt index c9479c7526..e8545ea19a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt @@ -56,12 +56,12 @@ fun Recipient.getContactDrawable(context: Context): Drawable? { ) .get() } catch (e: InterruptedException) { - fallbackContactPhoto.asDrawable(context, color.toConversationColor(context)) + fallbackContactPhoto.asDrawable(context, chatColors) } catch (e: ExecutionException) { - fallbackContactPhoto.asDrawable(context, color.toConversationColor(context)) + fallbackContactPhoto.asDrawable(context, chatColors) } } else { - fallbackContactPhoto.asDrawable(context, color.toConversationColor(context)) + fallbackContactPhoto.asDrawable(context, chatColors) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java index 572e661cd2..bf53439206 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentsHomeFragment.java @@ -90,7 +90,6 @@ public class PaymentsHomeFragment extends LoggingFragment { viewModel = ViewModelProviders.of(this, new PaymentsHomeViewModel.Factory()).get(PaymentsHomeViewModel.class); viewModel.getList().observe(getViewLifecycleOwner(), list -> { - // TODO [alex] -- this is a bit of a hack boolean hadPaymentItems = Stream.of(adapter.getCurrentList()).anyMatch(model -> model instanceof PaymentItem); if (!hadPaymentItems) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index b544bad14e..b5e8d5afa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -30,6 +30,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; @@ -111,7 +112,7 @@ public class EditProfileFragment extends LoggingFragment { if (data != null && data.getBooleanExtra("delete", false)) { viewModel.setAvatar(null); - avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), getResources().getColor(R.color.grey_400))); + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_camera_solid_white_24).asDrawable(requireActivity(), ChatColorsPalette.UNKNOWN_CONTACT)); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java index a4c45946d4..c539b5a614 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.ViewUtil; @@ -122,8 +123,8 @@ public class ReviewBannerView extends LinearLayout { } @Override - protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { - return new FallbackPhoto20dp(getFallbackResId()).asDrawable(context, color, inverted); + protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return new FallbackPhoto20dp(getFallbackResId()).asDrawable(context, chatColors, inverted); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 1ecca61262..0f8598e00e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -12,12 +12,9 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; -import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; -import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; @@ -26,6 +23,8 @@ import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; @@ -84,7 +83,6 @@ public class Recipient { private final VibrateState callVibrate; private final Uri messageRingtone; private final Uri callRingtone; - private final MaterialColor color; private final Optional defaultSubscriptionId; private final int expireMessages; private final RegisteredState registered; @@ -108,6 +106,7 @@ public class Recipient { private final byte[] storageId; private final MentionSetting mentionSetting; private final ChatWallpaper wallpaper; + private final ChatColors chatColors; private final String about; private final String aboutEmoji; private final ProfileName systemProfileName; @@ -330,7 +329,6 @@ public class Recipient { this.callVibrate = VibrateState.DEFAULT; this.messageRingtone = null; this.callRingtone = null; - this.color = null; this.insightsBannerTier = InsightsBannerTier.TIER_TWO; this.defaultSubscriptionId = Optional.absent(); this.expireMessages = 0; @@ -354,6 +352,7 @@ public class Recipient { this.storageId = null; this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; this.wallpaper = null; + this.chatColors = null; this.about = null; this.aboutEmoji = null; this.systemProfileName = ProfileName.EMPTY; @@ -379,7 +378,6 @@ public class Recipient { this.callVibrate = details.callVibrateState; this.messageRingtone = details.messageRingtone; this.callRingtone = details.callRingtone; - this.color = details.color; this.insightsBannerTier = details.insightsBannerTier; this.defaultSubscriptionId = details.defaultSubscriptionId; this.expireMessages = details.expireMessages; @@ -403,6 +401,7 @@ public class Recipient { this.storageId = details.storageId; this.mentionSetting = details.mentionSetting; this.wallpaper = details.wallpaper; + this.chatColors = details.chatColors; this.about = details.about; this.aboutEmoji = details.aboutEmoji; this.systemProfileName = details.systemProfileName; @@ -556,24 +555,6 @@ public class Recipient { return StringUtil.isolateBidi(name); } - public @NonNull MaterialColor getColor() { - if (isGroupInternal()) { - return MaterialColor.GROUP; - } else if (color != null) { - return color; - } else if (groupName != null || profileSharing) { - Log.w(TAG, "Had no color for " + id + "! Saving a new one."); - - Context context = ApplicationDependencies.getApplication(); - MaterialColor color = ContactColors.generateFor(getDisplayName(context)); - - SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(context).setColorIfNotSet(id, color)); - return color; - } else { - return ContactColors.UNKNOWN_COLOR; - } - } - public @NonNull Optional getUuid() { return Optional.fromNullable(uuid); } @@ -775,11 +756,11 @@ public class Recipient { } public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { - return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, getColor().toAvatarColor(context), inverted); + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, getChatColors(), inverted); } public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { - return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asSmallDrawable(context, getColor().toAvatarColor(context), inverted); + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asSmallDrawable(context, getChatColors(), inverted); } public @NonNull FallbackContactPhoto getFallbackContactPhoto() { @@ -920,6 +901,32 @@ public class Recipient { return wallpaper != null || SignalStore.wallpaper().hasWallpaperSet(); } + public boolean hasOwnChatColors() { + return chatColors != null; + } + + public @NonNull ChatColors getChatColors() { + if (chatColors != null && chatColors.getId() instanceof ChatColors.Id.Auto) { + return getAutoChatColor(); + } else if (chatColors != null) { + return chatColors; + } else if (SignalStore.chatColorsValues().hasChatColors()) { + return Objects.requireNonNull(SignalStore.chatColorsValues().getChatColors()); + } else { + return getAutoChatColor(); + } + } + + private @NonNull ChatColors getAutoChatColor() { + if (wallpaper != null) { + return wallpaper.getAutoChatColors(); + } else if (getWallpaper() != null) { + return getWallpaper().getAutoChatColors(); + } else { + return ChatColorsPalette.Bubbles.getDefault(); + } + } + public boolean isSystemContact() { return contactUri != null; } @@ -1002,7 +1009,6 @@ public class Recipient { return Objects.hash(id); } - public enum Capability { UNKNOWN(0), SUPPORTED(1), @@ -1088,7 +1094,6 @@ public class Recipient { callVibrate == other.callVibrate && Objects.equals(messageRingtone, other.messageRingtone) && Objects.equals(callRingtone, other.callRingtone) && - color == other.color && Objects.equals(defaultSubscriptionId, other.defaultSubscriptionId) && registered == other.registered && Arrays.equals(profileKey, other.profileKey) && @@ -1108,6 +1113,7 @@ public class Recipient { Arrays.equals(storageId, other.storageId) && mentionSetting == other.mentionSetting && Objects.equals(wallpaper, other.wallpaper) && + Objects.equals(chatColors, other.chatColors) && Objects.equals(about, other.about) && Objects.equals(aboutEmoji, other.aboutEmoji) && Objects.equals(extras, other.extras); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index 3301e87205..f1a910dd71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -7,7 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.zkgroup.profiles.ProfileKeyCredential; -import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier; import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; @@ -38,7 +38,6 @@ public class RecipientDetails { final Uri systemContactPhoto; final Uri contactUri; final Optional groupAvatarId; - final MaterialColor color; final Uri messageRingtone; final Uri callRingtone; final long mutedUntil; @@ -67,6 +66,7 @@ public class RecipientDetails { final byte[] storageId; final MentionSetting mentionSetting; final ChatWallpaper wallpaper; + final ChatColors chatColors; final String about; final String aboutEmoji; final ProfileName systemProfileName; @@ -91,7 +91,6 @@ public class RecipientDetails { this.e164 = settings.getE164(); this.email = settings.getEmail(); this.groupId = settings.getGroupId(); - this.color = settings.getColor(); this.messageRingtone = settings.getMessageRingtone(); this.callRingtone = settings.getCallRingtone(); this.mutedUntil = settings.getMuteUntil(); @@ -120,6 +119,7 @@ public class RecipientDetails { this.storageId = settings.getStorageId(); this.mentionSetting = settings.getMentionSetting(); this.wallpaper = settings.getWallpaper(); + this.chatColors = settings.getChatColors(); this.about = settings.getAbout(); this.aboutEmoji = settings.getAboutEmoji(); this.systemProfileName = settings.getSystemProfileName(); @@ -142,7 +142,6 @@ public class RecipientDetails { this.e164 = null; this.email = null; this.groupId = null; - this.color = null; this.messageRingtone = null; this.callRingtone = null; this.mutedUntil = 0; @@ -172,6 +171,7 @@ public class RecipientDetails { this.storageId = null; this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; this.wallpaper = null; + this.chatColors = null; this.about = null; this.aboutEmoji = null; this.systemProfileName = ProfileName.EMPTY; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java index 957acfe7a8..ab153767ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.java @@ -140,7 +140,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF avatar.setFallbackPhotoProvider(new Recipient.FallbackPhotoProvider() { @Override public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { - return new FallbackPhoto80dp(R.drawable.ic_note_80, recipient.getColor().toAvatarColor(requireContext())); + return new FallbackPhoto80dp(R.drawable.ic_note_80, recipient.getChatColors()); } }); avatar.setAvatar(recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java index e532ccae4f..583ef4ac58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java @@ -14,31 +14,25 @@ import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; -import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; -import androidx.core.view.ViewCompat; +import androidx.core.widget.TextViewCompat; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProviders; -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.ColorStateDrawable; - import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.AvatarPreviewActivity; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.color.MaterialColor; -import org.thoughtcrime.securesms.color.MaterialColors; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThreadPhotoRailView; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -89,8 +83,6 @@ public class ManageRecipientFragment extends LoggingFragment { private View disappearingMessagesCard; private View disappearingMessagesRow; private TextView disappearingMessages; - private View colorRow; - private ImageView colorChip; private View blockUnblockCard; private TextView block; private TextView unblock; @@ -109,7 +101,7 @@ public class ManageRecipientFragment extends LoggingFragment { private View secureCallButton; private View insecureCallButton; private View secureVideoCallButton; - private View chatWallpaperButton; + private TextView chatWallpaperButton; static ManageRecipientFragment newInstance(@NonNull RecipientId recipientId, boolean fromConversation) { ManageRecipientFragment fragment = new ManageRecipientFragment(); @@ -148,8 +140,6 @@ public class ManageRecipientFragment extends LoggingFragment { disappearingMessagesCard = view.findViewById(R.id.recipient_disappearing_messages_card); disappearingMessagesRow = view.findViewById(R.id.disappearing_messages_row); disappearingMessages = view.findViewById(R.id.disappearing_messages); - colorRow = view.findViewById(R.id.color_row); - colorChip = view.findViewById(R.id.color_chip); blockUnblockCard = view.findViewById(R.id.recipient_block_and_leave_card); block = view.findViewById(R.id.block); unblock = view.findViewById(R.id.unblock); @@ -294,6 +284,10 @@ public class ManageRecipientFragment extends LoggingFragment { } private void presentRecipient(@NonNull Recipient recipient) { + Drawable colorCircle = recipient.getChatColors().asCircle(); + colorCircle.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16)); + TextViewCompat.setCompoundDrawablesRelative(chatWallpaperButton, null, null, colorCircle, null); + if (recipient.isSystemContact()) { contactText.setText(R.string.ManageRecipientActivity_this_person_is_in_your_contacts); contactIcon.setVisibility(View.VISIBLE); @@ -315,16 +309,16 @@ public class ManageRecipientFragment extends LoggingFragment { disappearingMessagesCard.setVisibility(recipient.isRegistered() ? View.VISIBLE : View.GONE); addToAGroup.setVisibility(recipient.isRegistered() ? View.VISIBLE : View.GONE); - MaterialColor recipientColor = recipient.getColor(); + ChatColors recipientColor = recipient.getChatColors(); avatar.setFallbackPhotoProvider(new Recipient.FallbackPhotoProvider() { @Override public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { - return new FallbackPhoto80dp(R.drawable.ic_profile_80, recipientColor.toAvatarColor(requireContext())); + return new FallbackPhoto80dp(R.drawable.ic_profile_80, recipientColor); } @Override public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { - return new FallbackPhoto80dp(R.drawable.ic_note_80, recipientColor.toAvatarColor(requireContext())); + return new FallbackPhoto80dp(R.drawable.ic_note_80, recipientColor); } }); avatar.setAvatar(recipient); @@ -334,11 +328,6 @@ public class ManageRecipientFragment extends LoggingFragment { AvatarPreviewActivity.createTransitionBundle(activity, avatar)); }); - @ColorInt int color = recipientColor.toActionBarColor(requireContext()); - Drawable[] colorDrawable = new Drawable[]{ContextCompat.getDrawable(requireContext(), R.drawable.colorpickerpreference_pref_swatch)}; - colorChip.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); - colorRow.setOnClickListener(v -> handleColorSelection(color)); - secureCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); insecureCallButton.setVisibility(!recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); secureVideoCallButton.setVisibility(recipient.isRegistered() && !recipient.isSelf() ? View.VISIBLE : View.GONE); @@ -385,22 +374,6 @@ public class ManageRecipientFragment extends LoggingFragment { } } - private void handleColorSelection(@ColorInt int currentColor) { - @ColorInt int[] colors = MaterialColors.CONVERSATION_PALETTE.asConversationColorArray(requireContext()); - - ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(requireContext()) - .setSelectedColor(currentColor) - .setColors(colors) - .setSize(ColorPickerDialog.SIZE_SMALL) - .setSortColors(false) - .setColumns(3) - .build(); - - ColorPickerDialog dialog = new ColorPickerDialog(requireActivity(), color -> viewModel.onSelectColor(color), params); - dialog.setTitle(R.string.ManageRecipientActivity_chat_color); - dialog.show(); - } - public boolean onMenuItemSelected(@NonNull MenuItem item) { if (item.getItemId() == R.id.action_edit) { startActivity(EditProfileActivity.getIntentForUserProfileEdit(requireActivity())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java index a80b5e7656..7d8bc63268 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java @@ -82,16 +82,6 @@ final class ManageRecipientRepository { SignalExecutors.BOUNDED.execute(() -> DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)); } - void setColor(int color) { - SignalExecutors.BOUNDED.execute(() -> { - MaterialColor selectedColor = MaterialColors.CONVERSATION_PALETTE.getByColor(context, color); - if (selectedColor != null) { - DatabaseFactory.getRecipientDatabase(context).setColor(recipientId, selectedColor); - ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(recipientId)); - } - }); - } - void refreshRecipient() { SignalExecutors.UNBOUNDED.execute(() -> { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java index a5bf88d1ba..9312ab003c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java @@ -240,10 +240,6 @@ public final class ManageRecipientViewModel extends ViewModel { return sharedGroupsCountSummary; } - void onSelectColor(int color) { - manageRecipientRepository.setColor(color); - } - void onGroupClicked(@NonNull Activity activity, @NonNull Recipient recipient) { CommunicationActions.startConversation(activity, recipient, null); activity.finish(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index d7b43caa1f..61435b1ddf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -26,6 +26,8 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequest; import org.thoughtcrime.securesms.recipients.Recipient; @@ -172,12 +174,8 @@ public final class AvatarUtil { private static Drawable getFallback(@NonNull Context context, @NonNull Recipient recipient) { String name = Optional.fromNullable(recipient.getDisplayName(context)).or(""); - MaterialColor fallbackColor = recipient.getColor(); + ChatColors fallbackColor = recipient.getChatColors(); - if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { - fallbackColor = ContactColors.generateFor(name); - } - - return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, fallbackColor.toAvatarColor(context)); + return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, fallbackColor); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java index 9ccfaaba7d..9ca35e8a8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mms.GlideApp; @@ -177,9 +178,9 @@ public final class ConversationShortcutPhoto implements Key { photoSource = R.drawable.ic_profile_80; } - FallbackContactPhoto photo = recipient.isSelf() || recipient.isGroup() ? new FallbackPhoto80dp(photoSource, recipient.getColor().toAvatarColor(context)) + FallbackContactPhoto photo = recipient.isSelf() || recipient.isGroup() ? new FallbackPhoto80dp(photoSource, recipient.getChatColors()) : new ShortcutGeneratedContactPhoto(recipient.getDisplayName(context), photoSource, ViewUtil.dpToPx(80), ViewUtil.dpToPx(28)); - Bitmap toWrap = DrawableUtil.toBitmap(photo.asDrawable(context, recipient.getColor().toAvatarColor(context)), ViewUtil.dpToPx(80), ViewUtil.dpToPx(80)); + Bitmap toWrap = DrawableUtil.toBitmap(photo.asDrawable(context, recipient.getChatColors()), ViewUtil.dpToPx(80), ViewUtil.dpToPx(80)); Bitmap wrapped = DrawableUtil.wrapBitmapForShortcutInfo(toWrap); toWrap.recycle(); @@ -198,8 +199,8 @@ public final class ConversationShortcutPhoto implements Key { } @Override - protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { - return new FallbackPhoto80dp(getFallbackResId(), color).asDrawable(context, -1); + protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull ChatColors chatColors, boolean inverted) { + return new FallbackPhoto80dp(getFallbackResId(), chatColors).asDrawable(context, chatColors); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CustomDrawWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CustomDrawWrapper.kt new file mode 100644 index 0000000000..8f22d289e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CustomDrawWrapper.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.util + +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable + +/** + * Class which wraps a given drawable to perform some custom drawing / masking / whatever. + * + * We extend LayerDrawable here to take advantage of it's overrides and mechanisms, but explicitly + * abstract out the draw method to allow overrides to do whatever they want. + */ +private class CustomDrawWrapper( + private val wrapped: Drawable, + private val drawFn: (wrapped: Drawable, canvas: Canvas) -> Unit +) : LayerDrawable(arrayOf(wrapped)) { + override fun draw(canvas: Canvas) { + drawFn(wrapped, canvas) + } +} + +fun Drawable.customizeOnDraw(customDrawFn: (wrapped: Drawable, canvas: Canvas) -> Unit): Drawable { + return CustomDrawWrapper(this, customDrawFn) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java b/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java new file mode 100644 index 0000000000..453dcd1bd7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Projection.java @@ -0,0 +1,236 @@ +package org.thoughtcrime.securesms.util; + +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.CornerMask; + +import java.util.Objects; + +/** + * Describes the position, size, and corner masking of a given view relative to a parent. + */ +public final class Projection { + + private final float x; + private final float y; + private final int width; + private final int height; + private final Corners corners; + private final Path path; + private final RectF rect; + + public Projection(float x, float y, int width, int height, @Nullable Corners corners) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.corners = corners; + this.path = new Path(); + + rect = new RectF(); + rect.set(x, y, x + width, y + height); + + if (corners != null) { + path.addRoundRect(rect, corners.toRadii(), Path.Direction.CW); + } else { + path.addRect(rect, Path.Direction.CW); + } + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public @Nullable Corners getCorners() { + return corners; + } + + public @NonNull Path getPath() { + return path; + } + + public void applyToPath(@NonNull Path path) { + if (corners == null) { + path.addRect(rect, Path.Direction.CW); + } else { + if (Build.VERSION.SDK_INT >= 21) { + path.addRoundRect(rect, corners.toRadii(), Path.Direction.CW); + } else { + path.op(path, Path.Op.UNION); + } + } + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Projection that = (Projection) o; + return Float.compare(that.x, x) == 0 && + Float.compare(that.y, y) == 0 && + width == that.width && + height == that.height && + Objects.equals(corners, that.corners); + } + + @Override public int hashCode() { + return Objects.hash(x, y, width, height, corners); + } + + public @NonNull Projection translateX(float xTranslation) { + return new Projection(x + xTranslation, y, width, height, corners); + } + + public @NonNull Projection withDimensions(int width, int height) { + return new Projection(x, y, width, height, corners); + } + + public @NonNull Projection withHeight(int height) { + return new Projection(x, y, width, height, corners); + } + + public static @NonNull Projection relativeToParent(@NonNull ViewGroup parent, @NonNull View view, @Nullable Corners corners) { + Rect viewBounds = new Rect(); + + view.getDrawingRect(viewBounds); + parent.offsetDescendantRectToMyCoords(view, viewBounds); + return new Projection(viewBounds.left, viewBounds.top, view.getWidth(), view.getHeight(), corners); + } + + public static @NonNull Projection relativeToViewRoot(@NonNull View view, @Nullable Corners corners) { + Rect viewBounds = new Rect(); + ViewGroup root = (ViewGroup) view.getRootView(); + + view.getDrawingRect(viewBounds); + root.offsetDescendantRectToMyCoords(view, viewBounds); + + return new Projection(viewBounds.left, viewBounds.top, view.getWidth(), view.getHeight(), corners); + } + + public static @NonNull Projection relativeToViewWithCommonRoot(@NonNull View toProject, @NonNull View viewWithCommonRoot, @Nullable Corners corners) { + Rect viewBounds = new Rect(); + ViewGroup root = (ViewGroup) toProject.getRootView(); + + toProject.getDrawingRect(viewBounds); + root.offsetDescendantRectToMyCoords(toProject, viewBounds); + root.offsetRectIntoDescendantCoords(viewWithCommonRoot, viewBounds); + + return new Projection(viewBounds.left, viewBounds.top, toProject.getWidth(), toProject.getHeight(), corners); + } + + public static @NonNull Projection translateFromRootToDescendantCoords(@NonNull Projection rootProjection, @NonNull View descendant) { + Rect viewBounds = new Rect(); + + viewBounds.set((int) rootProjection.x, (int) rootProjection.y, (int) rootProjection.x + rootProjection.width, (int) rootProjection.y + rootProjection.height); + + ((ViewGroup) descendant.getRootView()).offsetRectIntoDescendantCoords(descendant, viewBounds); + + return new Projection(viewBounds.left, viewBounds.top, rootProjection.width, rootProjection.height, rootProjection.corners); + } + + public static @NonNull Projection translateFromDescendantToParentCoords(@NonNull Projection descendantProjection, @NonNull View descendant, @NonNull ViewGroup parent) { + Rect viewBounds = new Rect(); + + viewBounds.set((int) descendantProjection.x, (int) descendantProjection.y, (int) descendantProjection.x + descendantProjection.width, (int) descendantProjection.y + descendantProjection.height); + + parent.offsetDescendantRectToMyCoords(descendant, viewBounds); + + return new Projection(viewBounds.left, viewBounds.top, descendantProjection.width, descendantProjection.height, descendantProjection.corners); + } + + public static final class Corners { + private final float topLeft; + private final float topRight; + private final float bottomRight; + private final float bottomLeft; + + public Corners(float topLeft, float topRight, float bottomRight, float bottomLeft) { + this.topLeft = topLeft; + this.topRight = topRight; + this.bottomRight = bottomRight; + this.bottomLeft = bottomLeft; + } + + public Corners(float[] radii) { + this.topLeft = radii[0]; + this.topRight = radii[2]; + this.bottomRight = radii[4]; + this.bottomLeft = radii[6]; + } + + public Corners(float radius) { + this(radius, radius, radius, radius); + } + + public float getTopLeft() { + return topLeft; + } + + public float getTopRight() { + return topRight; + } + + public float getBottomLeft() { + return bottomLeft; + } + + public float getBottomRight() { + return bottomRight; + } + + public float[] toRadii() { + float[] radii = new float[8]; + + radii[0] = radii[1] = topLeft; + radii[2] = radii[3] = topRight; + radii[4] = radii[5] = bottomRight; + radii[6] = radii[7] = bottomLeft; + + return radii; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final Corners corners = (Corners) o; + return Float.compare(corners.topLeft, topLeft) == 0 && + Float.compare(corners.topRight, topRight) == 0 && + Float.compare(corners.bottomRight, bottomRight) == 0 && + Float.compare(corners.bottomLeft, bottomLeft) == 0; + } + + @Override public int hashCode() { + return Objects.hash(topLeft, topRight, bottomRight, bottomLeft); + } + + @Override public String toString() { + return "Corners{" + + "topLeft=" + topLeft + + ", topRight=" + topRight + + ", bottomRight=" + bottomRight + + ", bottomLeft=" + bottomLeft + + '}'; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index afa678325d..06cc1b835a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -407,6 +407,10 @@ public class Util { return elements[new SecureRandom().nextInt(elements.length)]; } + public static T getRandomElement(List elements) { + return elements.get(new SecureRandom().nextInt(elements.size())); + } + public static boolean equals(@Nullable Object a, @Nullable Object b) { return a == b || (a != null && a.equals(b)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java index 914d4dd783..160034a1c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java @@ -5,6 +5,9 @@ import android.widget.ImageView; import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import java.util.Arrays; @@ -14,31 +17,44 @@ public interface ChatWallpaper extends Parcelable { float FIXED_DIM_LEVEL_FOR_DARK_THEME = 0.2f; - List BUILTINS = Arrays.asList(SingleColorChatWallpaper.SOLID_1, - SingleColorChatWallpaper.SOLID_2, - SingleColorChatWallpaper.SOLID_3, - SingleColorChatWallpaper.SOLID_4, - SingleColorChatWallpaper.SOLID_5, - SingleColorChatWallpaper.SOLID_6, - SingleColorChatWallpaper.SOLID_7, - SingleColorChatWallpaper.SOLID_8, - SingleColorChatWallpaper.SOLID_9, - SingleColorChatWallpaper.SOLID_10, - SingleColorChatWallpaper.SOLID_11, - SingleColorChatWallpaper.SOLID_12, - GradientChatWallpaper.GRADIENT_1, - GradientChatWallpaper.GRADIENT_2, - GradientChatWallpaper.GRADIENT_3, - GradientChatWallpaper.GRADIENT_4, - GradientChatWallpaper.GRADIENT_5, - GradientChatWallpaper.GRADIENT_6, - GradientChatWallpaper.GRADIENT_7, - GradientChatWallpaper.GRADIENT_8, - GradientChatWallpaper.GRADIENT_9); - float getDimLevelForDarkTheme(); + default @NonNull ChatColors getAutoChatColors() { + return ChatColorsMapper.getChatColors(this).withId(ChatColors.Id.Auto.INSTANCE); + } + + boolean isSameSource(@NonNull ChatWallpaper chatWallpaper); + void loadInto(@NonNull ImageView imageView); @NonNull Wallpaper serialize(); + + enum BuiltIns { + INSTANCE; + + @NonNull List getAllBuiltIns() { + return Arrays.asList( + SingleColorChatWallpaper.BLUSH, + SingleColorChatWallpaper.COPPER, + SingleColorChatWallpaper.DUST, + SingleColorChatWallpaper.CELADON, + SingleColorChatWallpaper.RAINFOREST, + SingleColorChatWallpaper.PACIFIC, + SingleColorChatWallpaper.FROST, + SingleColorChatWallpaper.NAVY, + SingleColorChatWallpaper.LILAC, + SingleColorChatWallpaper.PINK, + SingleColorChatWallpaper.EGGPLANT, + SingleColorChatWallpaper.SILVER, + GradientChatWallpaper.SUNSET, + GradientChatWallpaper.NOIR, + GradientChatWallpaper.HEATMAP, + GradientChatWallpaper.AQUA, + GradientChatWallpaper.IRIDESCENT, + GradientChatWallpaper.MONSTERA, + GradientChatWallpaper.BLISS, + GradientChatWallpaper.SKY, + GradientChatWallpaper.PEACH); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java index 681c290ba3..166340959b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperActivity.java @@ -41,14 +41,6 @@ public final class ChatWallpaperActivity extends PassphraseRequiredActivity { dynamicTheme.onCreate(this); setContentView(R.layout.chat_wallpaper_activity); - Toolbar toolbar = findViewById(R.id.toolbar); - - toolbar.setNavigationOnClickListener(unused -> { - if (!Navigation.findNavController(this, R.id.nav_host_fragment).popBackStack()) { - finish(); - } - }); - if (savedInstanceState == null) { Bundle extras = getIntent().getExtras(); NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java index 059c928790..7d7cf36a0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFragment.java @@ -1,6 +1,10 @@ package org.thoughtcrime.securesms.wallpaper; -import android.app.AlertDialog; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.util.DisplayMetrics; import android.view.LayoutInflater; @@ -11,23 +15,32 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; +import androidx.appcompat.widget.AppCompatImageView; import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; +import androidx.core.widget.ImageViewCompat; +import androidx.core.widget.TextViewCompat; 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.ColorizerView; import org.thoughtcrime.securesms.util.DisplayMetricsUtil; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Collections; public class ChatWallpaperFragment extends Fragment { private boolean isSettingDimFromViewModel; - private TextView clearWallpaper; - private View resetAllWallpaper; - private View divider; + + private ChatWallpaperViewModel viewModel; @Override public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -36,24 +49,37 @@ public class ChatWallpaperFragment extends Fragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - ChatWallpaperViewModel viewModel = ViewModelProviders.of(requireActivity()).get(ChatWallpaperViewModel.class); + viewModel = ViewModelProviders.of(requireActivity()).get(ChatWallpaperViewModel.class); + + ImageView portrait = view.findViewById(R.id.chat_wallpaper_preview_top_bar_portrait); + Toolbar toolbar = view.findViewById(R.id.toolbar); ImageView chatWallpaperPreview = view.findViewById(R.id.chat_wallpaper_preview_background); View setWallpaper = view.findViewById(R.id.chat_wallpaper_set_wallpaper); SwitchCompat dimInNightMode = view.findViewById(R.id.chat_wallpaper_dark_theme_dims_wallpaper); View chatWallpaperDim = view.findViewById(R.id.chat_wallpaper_dim); + TextView setChatColor = view.findViewById(R.id.chat_wallpaper_set_chat_color); + TextView resetChatColors = view.findViewById(R.id.chat_wallpaper_reset_chat_colors); + ImageView sentBubble = view.findViewById(R.id.chat_wallpaper_preview_bubble_2); + ColorizerView colorizerView = view.findViewById(R.id.colorizer); + TextView resetAllWallpaper = view.findViewById(R.id.chat_wallpaper_reset_all_wallpapers); + AppCompatImageView recvBubble = view.findViewById(R.id.chat_wallpaper_preview_bubble_1); - clearWallpaper = view.findViewById(R.id.chat_wallpaper_clear_wallpaper); - resetAllWallpaper = view.findViewById(R.id.chat_wallpaper_reset_all_wallpapers); - divider = view.findViewById(R.id.chat_wallpaper_divider); + toolbar.setTitle(R.string.preferences__chat_color_and_wallpaper); + toolbar.setNavigationOnClickListener(nav -> { + if (!Navigation.findNavController(nav).popBackStack()) { + requireActivity().finish(); + } + }); forceAspectRatioToScreenByAdjustingHeight(chatWallpaperPreview); viewModel.getCurrentWallpaper().observe(getViewLifecycleOwner(), wallpaper -> { if (wallpaper.isPresent()) { wallpaper.get().loadInto(chatWallpaperPreview); + ImageViewCompat.setImageTintList(recvBubble, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.conversation_item_wallpaper_bubble_color))); } else { chatWallpaperPreview.setImageDrawable(null); - chatWallpaperPreview.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_primary)); + ImageViewCompat.setImageTintList(recvBubble, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.signal_background_secondary))); } }); @@ -71,56 +97,106 @@ public class ChatWallpaperFragment extends Fragment { viewModel.getEnableWallpaperControls().observe(getViewLifecycleOwner(), enableWallpaperControls -> { dimInNightMode.setEnabled(enableWallpaperControls); dimInNightMode.setAlpha(enableWallpaperControls ? 1 : 0.5f); - clearWallpaper.setEnabled(enableWallpaperControls); - clearWallpaper.setAlpha(enableWallpaperControls ? 1 : 0.5f); }); chatWallpaperPreview.setOnClickListener(unused -> setWallpaper.performClick()); setWallpaper.setOnClickListener(unused -> Navigation.findNavController(view) .navigate(R.id.action_chatWallpaperFragment_to_chatWallpaperSelectionFragment)); + setChatColor.setOnClickListener(unused -> Navigation.findNavController(view) + .navigate(ChatWallpaperFragmentDirections.actionChatWallpaperFragmentToChatColorSelectionFragment(viewModel.getRecipientId()))); - resetAllWallpaper.setVisibility(viewModel.isGlobal() ? View.VISIBLE : View.GONE); + if (viewModel.isGlobal()) { + resetAllWallpaper.setOnClickListener(unused -> { + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.ChatWallpaperFragment__reset_wallpaper) + .setMessage(R.string.ChatWallpaperFragment__would_you_like_to_override_all_wallpapers) + .setPositiveButton(R.string.ChatWallpaperFragment__reset_default_wallpaper, (dialog, which) -> { + viewModel.setWallpaper(null); + viewModel.setDimInDarkTheme(true); + viewModel.saveWallpaperSelection(); + dialog.dismiss(); + }) + .setNeutralButton(R.string.ChatWallpaperFragment__reset_all_wallpapers, (dialog, which) -> { + viewModel.setWallpaper(null); + viewModel.setDimInDarkTheme(true); + viewModel.resetAllWallpaper(); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + }) + .show(); + }); - clearWallpaper.setOnClickListener(unused -> { - confirmAction(viewModel.isGlobal() ? R.string.ChatWallpaperFragment__clear_wallpaper_this_will_not - : R.string.ChatWallpaperFragment__clear_wallpaper_for_this_chat, - R.string.ChatWallpaperFragment__clear, - () -> { - viewModel.setWallpaper(null); - viewModel.setDimInDarkTheme(true); - viewModel.saveWallpaperSelection(); - }); - }); + resetChatColors.setOnClickListener(unused -> { + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.ChatWallpaperFragment__reset_chat_colors) + .setMessage(R.string.ChatWallpaperFragment__would_you_like_to_override_all_chat_colors) + .setPositiveButton(R.string.ChatWallpaperFragment__reset_default_colors, (dialog, which) -> { + viewModel.clearChatColor(); + dialog.dismiss(); + }) + .setNeutralButton(R.string.ChatWallpaperFragment__reset_all_colors, (dialog, which) -> { + viewModel.resetAllChatColors(); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + }) + .show(); + }); + } else { + resetAllWallpaper.setText(R.string.ChatWallpaperFragment__reset_wallpaper); + resetChatColors.setText(R.string.ChatWallpaperFragment__reset_chat_color); - resetAllWallpaper.setOnClickListener(unused -> { - confirmAction(R.string.ChatWallpaperFragment__reset_all_wallpapers_including_custom, - R.string.ChatWallpaperFragment__reset, - () -> { - viewModel.setWallpaper(null); - viewModel.setDimInDarkTheme(true); - viewModel.resetAllWallpaper(); - }); - }); + resetAllWallpaper.setOnClickListener(unused -> { + new MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.ChatWallpaperFragment__reset_wallpaper_question) + .setPositiveButton(R.string.ChatWallpaperFragment__reset, (dialog, which) -> { + viewModel.setWallpaper(null); + viewModel.setDimInDarkTheme(true); + viewModel.saveWallpaperSelection(); + viewModel.refreshChatColors(); + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + }); + + resetChatColors.setOnClickListener(unused -> { + new MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.ChatWallpaperFragment__reset_chat_color_question) + .setPositiveButton(R.string.ChatWallpaperFragment__reset, (dialog, which) -> viewModel.clearChatColor()) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + }); + } dimInNightMode.setOnCheckedChangeListener((buttonView, isChecked) -> { if (!isSettingDimFromViewModel) { viewModel.setDimInDarkTheme(isChecked); } }); + + viewModel.getCurrentChatColors().observe(getViewLifecycleOwner(), chatColors -> { + sentBubble.getDrawable().setColorFilter(chatColors.getChatBubbleColorFilter()); + colorizerView.setBackground(chatColors.getChatBubbleMask()); + Projection projection = Projection.relativeToViewWithCommonRoot(sentBubble, colorizerView, new Projection.Corners(ViewUtil.dpToPx(10))); + colorizerView.setProjections(Collections.singletonList(projection)); + + Drawable colorCircle = chatColors.asCircle(); + colorCircle.setBounds(0, 0, ViewUtil.dpToPx(16), ViewUtil.dpToPx(16)); + TextViewCompat.setCompoundDrawablesRelative(setChatColor, null, null, colorCircle, null); + + portrait.setImageDrawable(chatColors.asCircle()); + }); + + sentBubble.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> viewModel.refreshChatColors()); } - private void confirmAction(@StringRes int title, @StringRes int positiveActionLabel, @NonNull Runnable onPositiveAction) { - new AlertDialog.Builder(requireContext()) - .setMessage(title) - .setPositiveButton(positiveActionLabel, (dialog, which) -> { - onPositiveAction.run(); - dialog.dismiss(); - }) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> { - dialog.dismiss(); - }) - .setCancelable(true) - .show(); + @Override + public void onResume() { + super.onResume(); + viewModel.refreshChatColors(); } private void forceAspectRatioToScreenByAdjustingHeight(@NonNull View view) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java index 5c047694c7..7521ee89d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperPreviewActivity.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.wallpaper; import android.content.Context; import android.content.Intent; import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.view.View; import android.widget.TextView; @@ -15,15 +16,21 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ColorizerView; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.FullscreenHelper; import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.Projection; +import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.WindowUtil; import java.util.Collections; +import java.util.Objects; public class ChatWallpaperPreviewActivity extends PassphraseRequiredActivity { @@ -33,6 +40,12 @@ public class ChatWallpaperPreviewActivity extends PassphraseRequiredActivity { private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private ChatWallpaperPreviewAdapter adapter; + private ColorizerView colorizerView; + private View bubble2; + private OnPageChanged onPageChanged; + private ViewPager2 viewPager; + public static @NonNull Intent create(@NonNull Context context, @NonNull ChatWallpaper selection, @NonNull RecipientId recipientId, boolean dimInDarkMode) { Intent intent = new Intent(context, ChatWallpaperPreviewActivity.class); @@ -49,15 +62,17 @@ public class ChatWallpaperPreviewActivity extends PassphraseRequiredActivity { setContentView(R.layout.chat_wallpaper_preview_activity); - ViewPager2 viewPager = findViewById(R.id.preview_pager); - ChatWallpaperPreviewAdapter adapter = new ChatWallpaperPreviewAdapter(); - View submit = findViewById(R.id.preview_set_wallpaper); - ChatWallpaperRepository repository = new ChatWallpaperRepository(); - ChatWallpaper selected = getIntent().getParcelableExtra(EXTRA_CHAT_WALLPAPER); - boolean dim = getIntent().getBooleanExtra(EXTRA_DIM_IN_DARK_MODE, false); - Toolbar toolbar = findViewById(R.id.toolbar); - View bubble1 = findViewById(R.id.preview_bubble_1); - TextView bubble2 = findViewById(R.id.preview_bubble_2_text); + adapter = new ChatWallpaperPreviewAdapter(); + colorizerView = findViewById(R.id.colorizer); + bubble2 = findViewById(R.id.preview_bubble_2); + viewPager = findViewById(R.id.preview_pager); + + View submit = findViewById(R.id.preview_set_wallpaper); + ChatWallpaperRepository repository = new ChatWallpaperRepository(); + ChatWallpaper selected = getIntent().getParcelableExtra(EXTRA_CHAT_WALLPAPER); + boolean dim = getIntent().getBooleanExtra(EXTRA_DIM_IN_DARK_MODE, false); + Toolbar toolbar = findViewById(R.id.toolbar); + TextView bubble2Text = findViewById(R.id.preview_bubble_2_text); toolbar.setNavigationOnClickListener(unused -> { finish(); @@ -79,10 +94,23 @@ public class ChatWallpaperPreviewActivity extends PassphraseRequiredActivity { }); RecipientId recipientId = getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); - if (recipientId != null) { + + final ChatColors chatColors; + if (recipientId != null && Recipient.live(recipientId).get().hasOwnChatColors()) { Recipient recipient = Recipient.live(recipientId).get(); - bubble1.getBackground().setColorFilter(recipient.getColor().toConversationColor(this), PorterDuff.Mode.SRC_IN); - bubble2.setText(getString(R.string.ChatWallpaperPreviewActivity__set_wallpaper_for_s, recipient.getDisplayName(this))); + bubble2Text.setText(getString(R.string.ChatWallpaperPreviewActivity__set_wallpaper_for_s, recipient.getDisplayName(this))); + chatColors = recipient.getChatColors(); + bubble2.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + updateChatColors(chatColors); + }); + } else if (SignalStore.chatColorsValues().hasChatColors()) { + chatColors = Objects.requireNonNull(SignalStore.chatColorsValues().getChatColors()); + bubble2.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + updateChatColors(chatColors); + }); + } else { + onPageChanged = new OnPageChanged(); + viewPager.registerOnPageChangeCallback(onPageChanged); } new FullscreenHelper(this).showSystemUI(); @@ -90,6 +118,36 @@ public class ChatWallpaperPreviewActivity extends PassphraseRequiredActivity { WindowUtil.setLightNavigationBarFromTheme(this); } + @Override protected void onDestroy() { + if (onPageChanged != null) { + viewPager.unregisterOnPageChangeCallback(onPageChanged); + } + + super.onDestroy(); + } + + private class OnPageChanged extends ViewPager2.OnPageChangeCallback { + @Override + public void onPageSelected(int position) { + ChatWallpaperSelectionMappingModel model = (ChatWallpaperSelectionMappingModel) adapter.getCurrentList().get(position); + + + updateChatColors(model.getWallpaper().getAutoChatColors()); + } + } + + private void updateChatColors(@NonNull ChatColors chatColors) { + Drawable mask = chatColors.getChatBubbleMask(); + + colorizerView.setBackground(mask); + + colorizerView.setProjections( + Collections.singletonList(Projection.relativeToViewWithCommonRoot(bubble2, colorizerView, new Projection.Corners(ViewUtil.dpToPx(18)))) + ); + + bubble2.getBackground().setColorFilter(chatColors.getChatBubbleColorFilter()); + } + @Override protected void onResume() { super.onResume(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java index dd0a773c03..73d55a5bdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperRepository.java @@ -6,6 +6,8 @@ import androidx.annotation.Nullable; import androidx.core.util.Consumer; 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.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -17,6 +19,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.concurrent.Executor; class ChatWallpaperRepository { @@ -32,30 +35,54 @@ class ChatWallpaperRepository { } } + @MainThread + @NonNull ChatColors getCurrentChatColors(@Nullable RecipientId recipientId) { + if (recipientId != null) { + return Recipient.live(recipientId).get().getChatColors(); + } else if (SignalStore.chatColorsValues().hasChatColors()) { + return Objects.requireNonNull(SignalStore.chatColorsValues().getChatColors()); + } else if (SignalStore.wallpaper().hasWallpaperSet()) { + return Objects.requireNonNull(SignalStore.wallpaper().getWallpaper()).getAutoChatColors(); + } else { + return ChatColorsPalette.Bubbles.getDefault(); + } + } + void getAllWallpaper(@NonNull Consumer> consumer) { EXECUTOR.execute(() -> { - List wallpapers = new ArrayList<>(ChatWallpaper.BUILTINS); + List wallpapers = new ArrayList<>(ChatWallpaper.BuiltIns.INSTANCE.getAllBuiltIns()); wallpapers.addAll(WallpaperStorage.getAll(ApplicationDependencies.getApplication())); consumer.accept(wallpapers); }); } - void saveWallpaper(@Nullable RecipientId recipientId, @Nullable ChatWallpaper chatWallpaper) { + void saveWallpaper(@Nullable RecipientId recipientId, @Nullable ChatWallpaper chatWallpaper, @NonNull Runnable onWallpaperSaved) { if (recipientId != null) { //noinspection CodeBlock2Expr EXECUTOR.execute(() -> { DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).setWallpaper(recipientId, chatWallpaper); + onWallpaperSaved.run(); }); } else { SignalStore.wallpaper().setWallpaper(ApplicationDependencies.getApplication(), chatWallpaper); + onWallpaperSaved.run(); } } - void resetAllWallpaper() { + void resetAllWallpaper(@NonNull Runnable onWallpaperReset) { SignalStore.wallpaper().setWallpaper(ApplicationDependencies.getApplication(), null); EXECUTOR.execute(() -> { DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).resetAllWallpaper(); + onWallpaperReset.run(); + }); + } + + void resetAllChatColors(@NonNull Runnable onColorsReset) { + SignalStore.chatColorsValues().setChatColors(null); + EXECUTOR.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).clearAllColors(); + onColorsReset.run(); }); } @@ -79,4 +106,16 @@ class ChatWallpaperRepository { SignalStore.wallpaper().setDimInDarkTheme(dimInDarkTheme); } } + + public void clearChatColor(@Nullable RecipientId recipientId, @NonNull Runnable onChatColorCleared) { + if (recipientId == null) { + SignalStore.chatColorsValues().setChatColors(null); + onChatColorCleared.run(); + } else { + EXECUTOR.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).clearColor(recipientId); + onChatColorCleared.run(); + }); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java index 099c6fd775..3b0f232048 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionAdapter.java @@ -1,15 +1,12 @@ package org.thoughtcrime.securesms.wallpaper; -import android.util.DisplayMetrics; - -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.MappingAdapter; class ChatWallpaperSelectionAdapter extends MappingAdapter { - ChatWallpaperSelectionAdapter(@Nullable ChatWallpaperViewHolder.EventListener eventListener, @NonNull DisplayMetrics windowDisplayMetrics) { - registerFactory(ChatWallpaperSelectionMappingModel.class, ChatWallpaperViewHolder.createFactory(R.layout.chat_wallpaper_selection_fragment_adapter_item, eventListener, windowDisplayMetrics)); + ChatWallpaperSelectionAdapter(@Nullable ChatWallpaperViewHolder.EventListener eventListener) { + registerFactory(ChatWallpaperSelectionMappingModel.class, ChatWallpaperViewHolder.createFactory(R.layout.chat_wallpaper_selection_fragment_adapter_item, eventListener, null)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java index 61b2818dcb..9e5584ef02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java @@ -4,7 +4,6 @@ import android.Manifest; import android.app.Activity; import android.content.Intent; import android.os.Bundle; -import android.util.DisplayMetrics; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -12,14 +11,12 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProviders; import androidx.navigation.Navigation; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.flexbox.FlexboxLayoutManager; -import com.google.android.flexbox.JustifyContent; - import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.wallpaper.crop.WallpaperImageSelectionActivity; @@ -37,24 +34,22 @@ public class ChatWallpaperSelectionFragment extends Fragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.toolbar); View chooseFromPhotos = view.findViewById(R.id.chat_wallpaper_choose_from_photos); RecyclerView recyclerView = view.findViewById(R.id.chat_wallpaper_recycler); - FlexboxLayoutManager flexboxLayoutManager = new FlexboxLayoutManager(requireContext()); chooseFromPhotos.setOnClickListener(unused -> { askForPermissionIfNeededAndLaunchPhotoSelection(); }); - DisplayMetrics displayMetrics = new DisplayMetrics(); - requireActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + toolbar.setTitle(R.string.preferences__chat_color_and_wallpaper); + toolbar.setNavigationOnClickListener(nav -> Navigation.findNavController(nav).popBackStack()); @SuppressWarnings("CodeBlock2Expr") ChatWallpaperSelectionAdapter adapter = new ChatWallpaperSelectionAdapter(chatWallpaper -> { startActivityForResult(ChatWallpaperPreviewActivity.create(requireActivity(), chatWallpaper, viewModel.getRecipientId(), viewModel.getDimInDarkTheme().getValue()), CHOOSE_WALLPAPER); - }, displayMetrics); + }); - flexboxLayoutManager.setJustifyContent(JustifyContent.CENTER); - recyclerView.setLayoutManager(flexboxLayoutManager); recyclerView.setAdapter(adapter); recyclerView.addItemDecoration(new ChatWallpaperAlignmentDecoration()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java index 3c54e44923..cbd7c607a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java @@ -9,6 +9,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; + import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.DisplayMetricsUtil; import org.thoughtcrime.securesms.util.MappingAdapter; @@ -16,18 +18,22 @@ import org.thoughtcrime.securesms.util.MappingViewHolder; class ChatWallpaperViewHolder extends MappingViewHolder { - private final ImageView preview; - private final View dimmer; - private final EventListener eventListener; + private final AspectRatioFrameLayout frame; + private final ImageView preview; + private final View dimmer; + private final EventListener eventListener; public ChatWallpaperViewHolder(@NonNull View itemView, @Nullable EventListener eventListener, @Nullable DisplayMetrics windowDisplayMetrics) { super(itemView); + this.frame = itemView.findViewById(R.id.chat_wallpaper_preview_frame); this.preview = itemView.findViewById(R.id.chat_wallpaper_preview); this.dimmer = itemView.findViewById(R.id.chat_wallpaper_dim); this.eventListener = eventListener; if (windowDisplayMetrics != null) { DisplayMetricsUtil.forceAspectRatioToScreenByAdjustingHeight(windowDisplayMetrics, itemView); + } else if (frame != null) { + frame.setAspectRatio(1.0f); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java index b67651bbfb..1ccdcc3ca1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewModel.java @@ -4,13 +4,17 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import com.annimon.stream.Stream; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.MappingModel; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; @@ -26,7 +30,10 @@ public class ChatWallpaperViewModel extends ViewModel { private final MutableLiveData> builtins = new MutableLiveData<>(); private final MutableLiveData dimInDarkTheme = new MutableLiveData<>(); private final MutableLiveData enableWallpaperControls = new MutableLiveData<>(); + private final MutableLiveData chatColors = new MutableLiveData<>(); private final RecipientId recipientId; + private final LiveRecipient liveRecipient; + private final RecipientForeverObserver recipientObserver = r -> refreshChatColors(); private ChatWallpaperViewModel(@Nullable RecipientId recipientId) { this.recipientId = recipientId; @@ -35,12 +42,30 @@ public class ChatWallpaperViewModel extends ViewModel { dimInDarkTheme.setValue(currentWallpaper == null || currentWallpaper.getDimLevelForDarkTheme() > 0f); enableWallpaperControls.setValue(hasClearableWallpaper()); wallpaper.setValue(Optional.fromNullable(currentWallpaper)); + + if (recipientId != null) { + liveRecipient = Recipient.live(recipientId); + liveRecipient.observeForever(recipientObserver); + } else { + liveRecipient = null; + } + } + + @Override + protected void onCleared() { + if (liveRecipient != null) { + liveRecipient.removeForeverObserver(recipientObserver); + } } void refreshWallpaper() { repository.getAllWallpaper(builtins::postValue); } + void refreshChatColors() { + chatColors.postValue(repository.getCurrentChatColors(recipientId)); + } + void setDimInDarkTheme(boolean shouldDimInDarkTheme) { dimInDarkTheme.setValue(shouldDimInDarkTheme); @@ -59,7 +84,7 @@ public class ChatWallpaperViewModel extends ViewModel { boolean dimInDarkTheme = this.dimInDarkTheme.getValue(); if (!wallpaper.isPresent()) { - repository.saveWallpaper(recipientId, null); + repository.saveWallpaper(recipientId, null, this::refreshChatColors); if (recipientId != null) { ChatWallpaper globalWallpaper = SignalStore.wallpaper().getWallpaper(); @@ -77,12 +102,12 @@ public class ChatWallpaperViewModel extends ViewModel { Optional updated = wallpaper.transform(paper -> ChatWallpaperFactory.updateWithDimming(paper, dimInDarkTheme ? ChatWallpaper.FIXED_DIM_LEVEL_FOR_DARK_THEME : 0f)); if (updated.isPresent()) { - repository.saveWallpaper(recipientId, updated.get()); + repository.saveWallpaper(recipientId, updated.get(), this::refreshChatColors); } } void resetAllWallpaper() { - repository.resetAllWallpaper(); + repository.resetAllWallpaper(this::refreshChatColors); } @Nullable RecipientId getRecipientId() { @@ -92,6 +117,10 @@ public class ChatWallpaperViewModel extends ViewModel { @NonNull LiveData> getCurrentWallpaper() { return wallpaper; } + + @NonNull LiveData getCurrentChatColors() { + return chatColors; + } @NonNull LiveData>> getWallpapers() { return LiveDataUtil.combineLatest(builtins, dimInDarkTheme, (wallpapers, dimInDarkMode) -> @@ -113,11 +142,19 @@ public class ChatWallpaperViewModel extends ViewModel { return recipientId == null; } + void clearChatColor() { + repository.clearChatColor(recipientId, this::refreshChatColors); + } + private boolean hasClearableWallpaper() { return (isGlobal() && SignalStore.wallpaper().hasWallpaperSet()) || (recipientId != null && Recipient.live(recipientId).get().hasOwnWallpaper()); } + public void resetAllChatColors() { + repository.resetAllChatColors(this::refreshChatColors); + } + public static class Factory implements ViewModelProvider.Factory { private final RecipientId recipientId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java index bc8c262870..66fca85584 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java @@ -1,61 +1,53 @@ package org.thoughtcrime.securesms.wallpaper; -import android.graphics.Canvas; -import android.graphics.ColorFilter; -import android.graphics.LinearGradient; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.Shader; import android.graphics.drawable.Drawable; import android.os.Parcel; import android.os.Parcelable; import android.widget.ImageView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.components.RotatableGradientDrawable; import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import java.util.Arrays; import java.util.Objects; -final class GradientChatWallpaper implements ChatWallpaper, Parcelable { +public final class GradientChatWallpaper implements ChatWallpaper, Parcelable { - public static final ChatWallpaper GRADIENT_1 = new GradientChatWallpaper(167.96f, + public static final ChatWallpaper SUNSET = new GradientChatWallpaper(168f, new int[] { 0xFFF3DC47, 0xFFF3DA47, 0xFFF2D546, 0xFFF2CC46, 0xFFF1C146, 0xFFEFB445, 0xFFEEA544, 0xFFEC9644, 0xFFEB8743, 0xFFE97743, 0xFFE86942, 0xFFE65C41, 0xFFE55041, 0xFFE54841, 0xFFE44240, 0xFFE44040 }, new float[] { 0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1f }, 0f); - public static final ChatWallpaper GRADIENT_2 = new GradientChatWallpaper(180f, + public static final ChatWallpaper NOIR = new GradientChatWallpaper(180f, new int[] { 0xFF16161D, 0xFF17171E, 0xFF1A1A22, 0xFF1F1F28, 0xFF26262F, 0xFF2D2D38, 0xFF353542, 0xFF3E3E4C, 0xFF474757, 0xFF4F4F61, 0xFF57576B, 0xFF5F5F74, 0xFF65657C, 0xFF6A6A82, 0xFF6D6D85, 0xFF6E6E87 }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_3 = new GradientChatWallpaper(192.04f, + public static final ChatWallpaper HEATMAP = new GradientChatWallpaper(192f, new int[] { 0xFFF53844, 0xFFF33845, 0xFFEC3848, 0xFFE2384C, 0xFFD63851, 0xFFC73857, 0xFFB6385E, 0xFFA43866, 0xFF93376D, 0xFF813775, 0xFF70377C, 0xFF613782, 0xFF553787, 0xFF4B378B, 0xFF44378E, 0xFF42378F }, new float[] { 0.0000f, 0.0075f, 0.0292f, 0.0637f, 0.1097f, 0.1659f, 0.2310f, 0.3037f, 0.3827f, 0.4666f, 0.5541f, 0.6439f, 0.7347f, 0.8252f, 0.9141f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_4 = new GradientChatWallpaper(180f, + public static final ChatWallpaper AQUA = new GradientChatWallpaper(180f, new int[] { 0xFF0093E9, 0xFF0294E9, 0xFF0696E7, 0xFF0D99E5, 0xFF169EE3, 0xFF21A3E0, 0xFF2DA8DD, 0xFF3AAEDA, 0xFF46B5D6, 0xFF53BBD3, 0xFF5FC0D0, 0xFF6AC5CD, 0xFF73CACB, 0xFF7ACDC9, 0xFF7ECFC7, 0xFF80D0C7 }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_5 = new GradientChatWallpaper(192.04f, + public static final ChatWallpaper IRIDESCENT = new GradientChatWallpaper(192f, new int[] { 0xFFF04CE6, 0xFFEE4BE6, 0xFFE54AE5, 0xFFD949E5, 0xFFC946E4, 0xFFB644E3, 0xFFA141E3, 0xFF8B3FE2, 0xFF743CE1, 0xFF5E39E0, 0xFF4936DF, 0xFF3634DE, 0xFF2632DD, 0xFF1930DD, 0xFF112FDD, 0xFF0E2FDD }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_6 = new GradientChatWallpaper(180f, + public static final ChatWallpaper MONSTERA = new GradientChatWallpaper(180f, new int[] { 0xFF65CDAC, 0xFF64CDAB, 0xFF60CBA8, 0xFF5BC8A3, 0xFF55C49D, 0xFF4DC096, 0xFF45BB8F, 0xFF3CB687, 0xFF33B17F, 0xFF2AAC76, 0xFF21A76F, 0xFF1AA268, 0xFF139F62, 0xFF0E9C5E, 0xFF0B9A5B, 0xFF0A995A }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_7 = new GradientChatWallpaper(180f, + public static final ChatWallpaper BLISS = new GradientChatWallpaper(180f, new int[] { 0xFFD8E1FA, 0xFFD8E0F9, 0xFFD8DEF7, 0xFFD8DBF3, 0xFFD8D6EE, 0xFFD7D1E8, 0xFFD7CCE2, 0xFFD7C6DB, 0xFFD7BFD4, 0xFFD7B9CD, 0xFFD6B4C7, 0xFFD6AFC1, 0xFFD6AABC, 0xFFD6A7B8, 0xFFD6A5B6, 0xFFD6A4B5 }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_8 = new GradientChatWallpaper(180f, + public static final ChatWallpaper SKY = new GradientChatWallpaper(180f, new int[] { 0xFFD8EBFD, 0xFFD7EAFD, 0xFFD5E9FD, 0xFFD2E7FD, 0xFFCDE5FD, 0xFFC8E3FD, 0xFFC3E0FD, 0xFFBDDDFC, 0xFFB7DAFC, 0xFFB2D7FC, 0xFFACD4FC, 0xFFA7D1FC, 0xFFA3CFFB, 0xFFA0CDFB, 0xFF9ECCFB, 0xFF9DCCFB }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); - public static final ChatWallpaper GRADIENT_9 = new GradientChatWallpaper(192.04f, + public static final ChatWallpaper PEACH = new GradientChatWallpaper(192f, new int[] { 0xFFFFE5C2, 0xFFFFE4C1, 0xFFFFE2BF, 0xFFFFDFBD, 0xFFFEDBB9, 0xFFFED6B5, 0xFFFED1B1, 0xFFFDCCAC, 0xFFFDC6A8, 0xFFFDC0A3, 0xFFFCBB9F, 0xFFFCB69B, 0xFFFCB297, 0xFFFCAF95, 0xFFFCAD93, 0xFFFCAC92 }, new float[] { 0.0000f, 0.0807f, 0.1554f, 0.2250f, 0.2904f, 0.3526f, 0.4125f, 0.4710f, 0.5290f, 0.5875f, 0.6474f, 0.7096f, 0.7750f, 0.8446f, 0.9193f, 1.0000f }, 0f); @@ -107,6 +99,17 @@ final class GradientChatWallpaper implements ChatWallpaper, Parcelable { imageView.setImageDrawable(buildDrawable()); } + @Override + public boolean isSameSource(@NonNull ChatWallpaper chatWallpaper) { + if (this == chatWallpaper) return true; + if (getClass() != chatWallpaper.getClass()) return false; + GradientChatWallpaper that = (GradientChatWallpaper) chatWallpaper; + + return Float.compare(that.degrees, degrees) == 0 && + Arrays.equals(colors, that.colors) && + Arrays.equals(positions, that.positions); + } + @Override public @NonNull Wallpaper serialize() { Wallpaper.LinearGradient.Builder builder = Wallpaper.LinearGradient.newBuilder(); @@ -157,96 +160,4 @@ final class GradientChatWallpaper implements ChatWallpaper, Parcelable { return new GradientChatWallpaper[size]; } }; - - private static final class RotatableGradientDrawable extends Drawable { - - private final float degrees; - private final int[] colors; - private final float[] positions; - - private final Rect fillRect = new Rect(); - private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); - - private RotatableGradientDrawable(float degrees, int[] colors, @Nullable float[] positions) { - this.degrees = degrees + 225f; - this.colors = colors; - this.positions = positions; - } - - @Override - public void setBounds(int left, int top, int right, int bottom) { - super.setBounds(left, top, right, bottom); - - Point topLeft = new Point(left, top); - Point topRight = new Point(right, top); - Point bottomLeft = new Point(left, bottom); - Point bottomRight = new Point(right, bottom); - Point origin = new Point(getBounds().width() / 2, getBounds().height() / 2); - - Point rotationTopLeft = cornerPrime(origin, topLeft, degrees); - Point rotationTopRight = cornerPrime(origin, topRight, degrees); - Point rotationBottomLeft = cornerPrime(origin, bottomLeft, degrees); - Point rotationBottomRight = cornerPrime(origin, bottomRight, degrees); - - fillRect.left = Integer.MAX_VALUE; - fillRect.top = Integer.MAX_VALUE; - fillRect.right = Integer.MIN_VALUE; - fillRect.bottom = Integer.MIN_VALUE; - - for (Point point : Arrays.asList(topLeft, topRight, bottomLeft, bottomRight, rotationTopLeft, rotationTopRight, rotationBottomLeft, rotationBottomRight)) { - if (point.x < fillRect.left) { - fillRect.left = point.x; - } - - if (point.x > fillRect.right) { - fillRect.right = point.x; - } - - if (point.y < fillRect.top) { - fillRect.top = point.y; - } - - if (point.y > fillRect.bottom) { - fillRect.bottom = point.y; - } - } - - fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP)); - } - - private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) { - return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees))); - } - - private static int xPrime(@NonNull Point origin, @NonNull Point corner, double theta) { - return (int) Math.ceil(((corner.x - origin.x) * Math.cos(theta)) - ((corner.y - origin.y) * Math.sin(theta)) + origin.x); - } - - private static int yPrime(@NonNull Point origin, @NonNull Point corner, double theta) { - return (int) Math.ceil(((corner.x - origin.x) * Math.sin(theta)) + ((corner.y - origin.y) * Math.cos(theta)) + origin.y); - } - - @Override - public void draw(Canvas canvas) { - int save = canvas.save(); - canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f); - canvas.drawRect(fillRect, fillPaint); - canvas.restoreToCount(save); - } - - @Override - public void setAlpha(int alpha) { - // Not supported - } - - @Override - public void setColorFilter(@Nullable ColorFilter colorFilter) { - // Not supported - } - - @Override - public int getOpacity() { - return PixelFormat.OPAQUE; - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java index f337754c62..e1a2920a29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/SingleColorChatWallpaper.java @@ -12,20 +12,20 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import java.util.Objects; -final class SingleColorChatWallpaper implements ChatWallpaper, Parcelable { +public final class SingleColorChatWallpaper implements ChatWallpaper, Parcelable { - public static final ChatWallpaper SOLID_1 = new SingleColorChatWallpaper(0xFFE26983, 0f); - public static final ChatWallpaper SOLID_2 = new SingleColorChatWallpaper(0xFFDF9171, 0f); - public static final ChatWallpaper SOLID_3 = new SingleColorChatWallpaper(0xFF9E9887, 0f); - public static final ChatWallpaper SOLID_4 = new SingleColorChatWallpaper(0xFF89AE8F, 0f); - public static final ChatWallpaper SOLID_5 = new SingleColorChatWallpaper(0xFF32C7E2, 0f); - public static final ChatWallpaper SOLID_6 = new SingleColorChatWallpaper(0xFF7C99B6, 0f); - public static final ChatWallpaper SOLID_7 = new SingleColorChatWallpaper(0xFFC988E7, 0f); - public static final ChatWallpaper SOLID_8 = new SingleColorChatWallpaper(0xFFE297C3, 0f); - public static final ChatWallpaper SOLID_9 = new SingleColorChatWallpaper(0xFFA2A2AA, 0f); - public static final ChatWallpaper SOLID_10 = new SingleColorChatWallpaper(0xFF146148, 0f); - public static final ChatWallpaper SOLID_11 = new SingleColorChatWallpaper(0xFF403B91, 0f); - public static final ChatWallpaper SOLID_12 = new SingleColorChatWallpaper(0xFF624249, 0f); + public static final ChatWallpaper BLUSH = new SingleColorChatWallpaper(0xFFE26983, 0f); + public static final ChatWallpaper COPPER = new SingleColorChatWallpaper(0xFFDF9171, 0f); + public static final ChatWallpaper DUST = new SingleColorChatWallpaper(0xFF9E9887, 0f); + public static final ChatWallpaper CELADON = new SingleColorChatWallpaper(0xFF89AE8F, 0f); + public static final ChatWallpaper RAINFOREST = new SingleColorChatWallpaper(0xFF146148, 0f); + public static final ChatWallpaper PACIFIC = new SingleColorChatWallpaper(0xFF32C7E2, 0f); + public static final ChatWallpaper FROST = new SingleColorChatWallpaper(0xFF7C99B6, 0f); + public static final ChatWallpaper NAVY = new SingleColorChatWallpaper(0xFF403B91, 0f); + public static final ChatWallpaper LILAC = new SingleColorChatWallpaper(0xFFC988E7, 0f); + public static final ChatWallpaper PINK = new SingleColorChatWallpaper(0xFFE297C3, 0f); + public static final ChatWallpaper EGGPLANT = new SingleColorChatWallpaper(0xFF624249, 0f); + public static final ChatWallpaper SILVER = new SingleColorChatWallpaper(0xFFA2A2AA, 0f); private final @ColorInt int color; private final float dimLevelInDarkTheme; @@ -50,6 +50,15 @@ final class SingleColorChatWallpaper implements ChatWallpaper, Parcelable { imageView.setImageDrawable(new ColorDrawable(color)); } + @Override + public boolean isSameSource(@NonNull ChatWallpaper chatWallpaper) { + if (this == chatWallpaper) return true; + if (getClass() != chatWallpaper.getClass()) return false; + SingleColorChatWallpaper that = (SingleColorChatWallpaper) chatWallpaper; + + return color == that.color; + } + @Override public @NonNull Wallpaper serialize() { Wallpaper.SingleColor.Builder builder = Wallpaper.SingleColor.newBuilder(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java index 67c75709cc..9955058a2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java @@ -15,7 +15,10 @@ import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; +import org.jetbrains.annotations.NotNull; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.conversation.colors.ChatColors; +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; @@ -78,6 +81,15 @@ final class UriChatWallpaper implements ChatWallpaper, Parcelable { dest.writeFloat(dimLevelInDarkTheme); } + @Override + public boolean isSameSource(@NonNull ChatWallpaper chatWallpaper) { + if (this == chatWallpaper) return true; + if (getClass() != chatWallpaper.getClass()) return false; + UriChatWallpaper that = (UriChatWallpaper) chatWallpaper; + + return uri.equals(that.uri); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java index 895b596261..a4cfaee460 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java @@ -26,6 +26,7 @@ import androidx.lifecycle.ViewModelProviders; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BaseActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.colors.ColorizerView; import org.thoughtcrime.securesms.imageeditor.ImageEditorView; import org.thoughtcrime.securesms.imageeditor.model.EditorElement; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; @@ -34,10 +35,13 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.scribbles.UriGlideRenderer; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Projection; +import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.thoughtcrime.securesms.wallpaper.ChatWallpaperPreviewActivity; +import java.util.Collections; import java.util.Locale; import java.util.Objects; @@ -84,10 +88,11 @@ public final class WallpaperCropActivity extends BaseActivity { viewModel = ViewModelProviders.of(this, factory).get(WallpaperCropViewModel.class); imageEditor = findViewById(R.id.image_editor); - View receivedBubble = findViewById(R.id.preview_bubble_1); - TextView bubble2Text = findViewById(R.id.chat_wallpaper_bubble2_text); - View setWallPaper = findViewById(R.id.preview_set_wallpaper); - SwitchCompat blur = findViewById(R.id.preview_blur); + View sentBubble = findViewById(R.id.preview_bubble_2); + TextView bubble2Text = findViewById(R.id.chat_wallpaper_bubble2_text); + View setWallPaper = findViewById(R.id.preview_set_wallpaper); + SwitchCompat blur = findViewById(R.id.preview_blur); + ColorizerView colorizerView = findViewById(R.id.colorizer); setupImageEditor(inputImage); @@ -115,9 +120,14 @@ public final class WallpaperCropActivity extends BaseActivity { bubble2Text.setText(R.string.WallpaperCropActivity__set_wallpaper_for_all_chats); } else { bubble2Text.setText(getString(R.string.WallpaperCropActivity__set_wallpaper_for_s, r.getDisplayName(this))); - receivedBubble.getBackground().setColorFilter(r.getColor().toConversationColor(this), PorterDuff.Mode.SRC_IN); + sentBubble.getBackground().setColorFilter(r.getChatColors().getChatBubbleColorFilter()); + colorizerView.setBackground(r.getChatColors().getChatBubbleMask()); } }); + + sentBubble.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + colorizerView.setProjections(Collections.singletonList(Projection.relativeToViewWithCommonRoot(sentBubble, colorizerView, new Projection.Corners(ViewUtil.dpToPx(18))))); + }); } @Override diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index f182467b0c..872205234a 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -126,6 +126,27 @@ message Wallpaper { float dimLevelInDarkTheme = 4; } +message ChatColor { + message SingleColor { + int32 color = 1; + } + + message LinearGradient { + float rotation = 1; + repeated int32 colors = 2; + repeated float positions = 3; + } + + message File { + string uri = 1; + } + + oneof chatColor { + SingleColor singleColor = 1; + LinearGradient linearGradient = 2; + } +} + message RecipientExtras { bool manuallyShownAvatar = 1; } \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_color_selection_bg.xml b/app/src/main/res/drawable/chat_color_selection_bg.xml new file mode 100644 index 0000000000..39e0e215f7 --- /dev/null +++ b/app/src/main/res/drawable/chat_color_selection_bg.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_bubble_background.xml b/app/src/main/res/drawable/message_bubble_background.xml index cda641b1da..c21dab9d39 100644 --- a/app/src/main/res/drawable/message_bubble_background.xml +++ b/app/src/main/res/drawable/message_bubble_background.xml @@ -1,13 +1,6 @@ - - - - - - - - - + + + + diff --git a/app/src/main/res/layout/chat_color_custom_adapter_item.xml b/app/src/main/res/layout/chat_color_custom_adapter_item.xml new file mode 100644 index 0000000000..455bbeff97 --- /dev/null +++ b/app/src/main/res/layout/chat_color_custom_adapter_item.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/layout/chat_color_selection_adapter_item.xml b/app/src/main/res/layout/chat_color_selection_adapter_item.xml new file mode 100644 index 0000000000..7221b04563 --- /dev/null +++ b/app/src/main/res/layout/chat_color_selection_adapter_item.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/chat_color_selection_fragment.xml b/app/src/main/res/layout/chat_color_selection_fragment.xml new file mode 100644 index 0000000000..6b2a0ced60 --- /dev/null +++ b/app/src/main/res/layout/chat_color_selection_fragment.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_colors_fragment_context_menu.xml b/app/src/main/res/layout/chat_colors_fragment_context_menu.xml new file mode 100644 index 0000000000..66eb668f30 --- /dev/null +++ b/app/src/main/res/layout/chat_colors_fragment_context_menu.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/chat_colors_preview_view.xml b/app/src/main/res/layout/chat_colors_preview_view.xml new file mode 100644 index 0000000000..3c7ad3c1d1 --- /dev/null +++ b/app/src/main/res/layout/chat_colors_preview_view.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_wallpaper_activity.xml b/app/src/main/res/layout/chat_wallpaper_activity.xml index af0690daa4..da72af9722 100644 --- a/app/src/main/res/layout/chat_wallpaper_activity.xml +++ b/app/src/main/res/layout/chat_wallpaper_activity.xml @@ -1,26 +1,9 @@ - - - - - - - + app:defaultNavHost="true" + app:navGraph="@navigation/chat_wallpaper" /> \ No newline at end of file diff --git a/app/src/main/res/layout/chat_wallpaper_crop_activity.xml b/app/src/main/res/layout/chat_wallpaper_crop_activity.xml index cfc2807909..0ce47c4a22 100644 --- a/app/src/main/res/layout/chat_wallpaper_crop_activity.xml +++ b/app/src/main/res/layout/chat_wallpaper_crop_activity.xml @@ -5,6 +5,11 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + android:textColor="@color/signal_text_primary" /> + android:textColor="@color/signal_inverse_transparent_80" /> @@ -96,7 +101,7 @@ android:layout_marginStart="48dp" android:layout_marginTop="12dp" android:layout_marginEnd="16dp" - android:background="@drawable/chat_wallpaper_preview_bubble_background" + android:background="@drawable/chat_wallpaper_preview_bubble_background_accent" android:orientation="vertical" android:paddingStart="12dp" android:paddingTop="7dp" @@ -114,7 +119,7 @@ android:layout_height="wrap_content" android:text="@string/WallpaperCropActivity__set_wallpaper_for_all_chats" android:textAppearance="@style/Signal.Text.Body" - android:textColor="@color/signal_text_primary" /> + android:textColor="@color/core_white" /> + app:drawableTint="@color/transparent_white_80" /> diff --git a/app/src/main/res/layout/chat_wallpaper_fragment.xml b/app/src/main/res/layout/chat_wallpaper_fragment.xml index ea405fb8d3..3343f0c965 100644 --- a/app/src/main/res/layout/chat_wallpaper_fragment.xml +++ b/app/src/main/res/layout/chat_wallpaper_fragment.xml @@ -1,279 +1,311 @@ - + android:layout_height="wrap_content" + android:orientation="vertical"> - + + + android:layout_height="match_parent" + android:fillViewport="true"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"> - + - + - + - + - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/chat_wallpaper_preview_activity.xml b/app/src/main/res/layout/chat_wallpaper_preview_activity.xml index b95800afd8..77b3dc6585 100644 --- a/app/src/main/res/layout/chat_wallpaper_preview_activity.xml +++ b/app/src/main/res/layout/chat_wallpaper_preview_activity.xml @@ -18,6 +18,11 @@ app:layout_constraintTop_toTopOf="parent" tools:background="@drawable/test_gradient" /> + + + android:textColor="@color/signal_text_primary" /> + android:textColor="@color/signal_text_secondary" /> @@ -87,7 +92,7 @@ android:layout_marginStart="48dp" android:layout_marginTop="12dp" android:layout_marginEnd="16dp" - android:background="@drawable/chat_wallpaper_preview_bubble_background" + android:background="@drawable/chat_wallpaper_preview_bubble_background_accent" android:orientation="vertical" android:paddingStart="12dp" android:paddingTop="7dp" @@ -105,7 +110,7 @@ android:layout_height="wrap_content" android:text="@string/ChatWallpaperPreviewActivity__set_wallpaper_for_all_chats" android:textAppearance="@style/Signal.Text.Body" - android:textColor="@color/signal_text_primary" /> + android:textColor="@color/core_white" /> + app:drawableTint="@color/core_white" /> - @@ -20,4 +20,4 @@ android:visibility="gone" tools:alpha="0.2f" tools:visibility="visible" /> - + diff --git a/app/src/main/res/layout/chat_wallpaper_selection_fragment.xml b/app/src/main/res/layout/chat_wallpaper_selection_fragment.xml index ea4712cb69..92c4a54493 100644 --- a/app/src/main/res/layout/chat_wallpaper_selection_fragment.xml +++ b/app/src/main/res/layout/chat_wallpaper_selection_fragment.xml @@ -1,64 +1,73 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - + + + android:layout_height="match_parent"> - + - + - + - \ No newline at end of file + + + + diff --git a/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml b/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml index 273b5dd1e5..37908f0181 100644 --- a/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml +++ b/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml @@ -1,11 +1,14 @@ - + android:layout_marginBottom="4dp" + app:resize_mode="fixed_width"> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index 27a787f853..93256720e6 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -6,6 +6,16 @@ android:layout_width="fill_parent" android:layout_height="match_parent"> + + - + diff --git a/app/src/main/res/layout/conversation_item_received_document.xml b/app/src/main/res/layout/conversation_item_received_document.xml index 81a5ac835a..1ef3d4bcaf 100644 --- a/app/src/main/res/layout/conversation_item_received_document.xml +++ b/app/src/main/res/layout/conversation_item_received_document.xml @@ -1,13 +1,12 @@ - + diff --git a/app/src/main/res/layout/conversation_item_received_multimedia.xml b/app/src/main/res/layout/conversation_item_received_multimedia.xml index 82c26da5e3..ece13844ad 100644 --- a/app/src/main/res/layout/conversation_item_received_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_received_multimedia.xml @@ -87,7 +87,7 @@ android:layout_marginEnd="4sp" android:ellipsize="end" android:maxLines="1" - android:textColor="@color/conversation_item_received_text_primary_color" + android:textColor="@color/signal_text_primary" android:textStyle="bold" tools:text="+14152222222" tools:visibility="visible" /> @@ -173,8 +173,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" android:ellipsize="end" - android:textColor="@color/conversation_item_received_text_primary_color" - android:textColorLink="@color/conversation_item_received_text_primary_color" + android:textColor="@color/signal_text_primary" + android:textColorLink="@color/signal_text_primary" app:emoji_maxLength="1000" app:scaleEmojis="true" tools:text="Mango pickle lorem ipsum" /> @@ -191,9 +191,9 @@ android:clipChildren="false" android:clipToPadding="false" android:gravity="start" - app:footer_icon_color="@color/conversation_item_received_text_secondary_color" + app:footer_icon_color="@color/conversation_item_sent_text_secondary_color" app:footer_reveal_dot_color="@color/core_white" - app:footer_text_color="@color/conversation_item_received_text_secondary_color" /> + app:footer_text_color="@color/conversation_item_sent_text_secondary_color" /> diff --git a/app/src/main/res/layout/conversation_item_received_shared_contact.xml b/app/src/main/res/layout/conversation_item_received_shared_contact.xml index ba438c7334..edd4159632 100644 --- a/app/src/main/res/layout/conversation_item_received_shared_contact.xml +++ b/app/src/main/res/layout/conversation_item_received_shared_contact.xml @@ -5,7 +5,7 @@ android:id="@+id/shared_contact_view" android:layout_width="@dimen/media_bubble_default_dimens" android:layout_height="wrap_content" - app:contact_titleColor="@color/conversation_item_received_text_primary_color" - app:contact_captionColor="@color/conversation_item_received_text_secondary_color" - app:contact_footerIconColor="@color/conversation_item_received_text_secondary_color" + app:contact_titleColor="@color/signal_text_primary" + app:contact_captionColor="@color/signal_text_secondary" + app:contact_footerIconColor="@color/conversation_item_recv_icon_color" app:contact_footerAlpha="0.7"/> diff --git a/app/src/main/res/layout/conversation_item_received_text_only.xml b/app/src/main/res/layout/conversation_item_received_text_only.xml index e94956828c..ef503651d7 100644 --- a/app/src/main/res/layout/conversation_item_received_text_only.xml +++ b/app/src/main/res/layout/conversation_item_received_text_only.xml @@ -87,7 +87,7 @@ android:layout_marginEnd="4sp" android:ellipsize="end" android:maxLines="1" - android:textColor="@color/conversation_item_received_text_primary_color" + android:textColor="@color/signal_text_primary" android:textStyle="bold" tools:text="+14152222222" tools:visibility="visible" /> @@ -104,8 +104,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" android:ellipsize="end" - android:textColor="@color/conversation_item_received_text_primary_color" - android:textColorLink="@color/conversation_item_received_text_primary_color" + android:textColor="@color/signal_text_primary" + android:textColorLink="@color/signal_text_primary" app:emoji_maxLength="1000" app:scaleEmojis="true" tools:text="Mango pickle lorem ipsum" /> @@ -122,8 +122,8 @@ android:clipChildren="false" android:clipToPadding="false" android:gravity="start" - app:footer_icon_color="@color/conversation_item_received_text_secondary_color" - app:footer_text_color="@color/conversation_item_received_text_secondary_color" /> + app:footer_icon_color="@color/signal_icon_tint_secondary" + app:footer_text_color="@color/signal_text_secondary" /> - + diff --git a/app/src/main/res/layout/conversation_item_sent_document.xml b/app/src/main/res/layout/conversation_item_sent_document.xml index fb1dc7456e..cbabeae29b 100644 --- a/app/src/main/res/layout/conversation_item_sent_document.xml +++ b/app/src/main/res/layout/conversation_item_sent_document.xml @@ -1,13 +1,12 @@ - + diff --git a/app/src/main/res/layout/conversation_item_sent_multimedia.xml b/app/src/main/res/layout/conversation_item_sent_multimedia.xml index 192242334a..dff032e45b 100644 --- a/app/src/main/res/layout/conversation_item_sent_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_sent_multimedia.xml @@ -125,8 +125,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" android:ellipsize="end" - android:textColor="@color/signal_text_primary" - android:textColorLink="@color/signal_text_primary" + android:textColor="@color/conversation_item_sent_text_primary_color" + android:textColorLink="@color/conversation_item_sent_text_primary_color" app:emoji_maxLength="1000" app:scaleEmojis="true" tools:text="Mango pickle lorem ipsum" /> diff --git a/app/src/main/res/layout/conversation_item_sent_revealable.xml b/app/src/main/res/layout/conversation_item_sent_revealable.xml index 916085d890..49270c832e 100644 --- a/app/src/main/res/layout/conversation_item_sent_revealable.xml +++ b/app/src/main/res/layout/conversation_item_sent_revealable.xml @@ -7,6 +7,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" - app:revealable_unopenedForegroundColor="@color/signal_text_primary" + app:revealable_unopenedForegroundColor="@color/conversation_item_sent_text_primary_color" app:revealable_openedForegroundColor="@color/signal_text_primary" tools:visibility="visible"/> diff --git a/app/src/main/res/layout/conversation_item_sent_shared_contact.xml b/app/src/main/res/layout/conversation_item_sent_shared_contact.xml index e8cec047dc..c29bd26d72 100644 --- a/app/src/main/res/layout/conversation_item_sent_shared_contact.xml +++ b/app/src/main/res/layout/conversation_item_sent_shared_contact.xml @@ -5,6 +5,6 @@ android:id="@+id/shared_contact_view" android:layout_width="@dimen/media_bubble_default_dimens" android:layout_height="wrap_content" - app:contact_titleColor="@color/signal_text_primary" - app:contact_captionColor="@color/signal_text_secondary" - app:contact_footerIconColor="@color/conversation_item_sent_icon_color"/> + app:contact_titleColor="@color/conversation_item_sent_text_primary_color" + app:contact_captionColor="@color/conversation_item_sent_text_secondary_color" + app:contact_footerIconColor="@color/conversation_item_sent_text_secondary_color"/> diff --git a/app/src/main/res/layout/conversation_item_sent_text_only.xml b/app/src/main/res/layout/conversation_item_sent_text_only.xml index d4ad550ca0..f31ebeb97a 100644 --- a/app/src/main/res/layout/conversation_item_sent_text_only.xml +++ b/app/src/main/res/layout/conversation_item_sent_text_only.xml @@ -56,8 +56,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" android:ellipsize="end" - android:textColor="@color/signal_text_primary" - android:textColorLink="@color/signal_text_primary" + android:textColor="@color/conversation_item_sent_text_primary_color" + android:textColorLink="@color/conversation_item_sent_text_primary_color" app:emoji_maxLength="1000" app:scaleEmojis="true" tools:text="Mango pickle lorem ipsum" /> @@ -73,8 +73,8 @@ android:clipChildren="false" android:clipToPadding="false" android:gravity="end" - app:footer_icon_color="@color/signal_icon_tint_secondary" - app:footer_text_color="@color/signal_text_secondary" /> + app:footer_icon_color="@color/conversation_item_sent_text_secondary_color" + app:footer_text_color="@color/conversation_item_sent_text_secondary_color" /> - - + android:layout_marginTop="1dp" + android:layout_marginBottom="1dp" + app:cardCornerRadius="@dimen/message_corner_radius" + app:cardElevation="0dp" + app:contentPadding="0dp"> - + - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/custom_chat_color_creator_fragment.xml b/app/src/main/res/layout/custom_chat_color_creator_fragment.xml new file mode 100644 index 0000000000..4b5c8aec13 --- /dev/null +++ b/app/src/main/res/layout/custom_chat_color_creator_fragment.xml @@ -0,0 +1,36 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/custom_chat_color_creator_fragment_page.xml b/app/src/main/res/layout/custom_chat_color_creator_fragment_page.xml new file mode 100644 index 0000000000..272711263a --- /dev/null +++ b/app/src/main/res/layout/custom_chat_color_creator_fragment_page.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_settings_toolbar.xml b/app/src/main/res/layout/dsl_settings_toolbar.xml index 2758c1ad41..bc8c1e6e03 100644 --- a/app/src/main/res/layout/dsl_settings_toolbar.xml +++ b/app/src/main/res/layout/dsl_settings_toolbar.xml @@ -14,8 +14,7 @@ app:layout_constraintTop_toTopOf="parent" app:navigationIcon="@drawable/ic_arrow_left_24" app:titleTextAppearance="@style/Signal.Text.Title" - tools:title="Settings" - tools:menu="@menu/help_settings" /> + tools:title="Settings" /> diff --git a/app/src/main/res/layout/longmessage_activity.xml b/app/src/main/res/layout/longmessage_activity.xml index a507a603fa..623af3d86e 100644 --- a/app/src/main/res/layout/longmessage_activity.xml +++ b/app/src/main/res/layout/longmessage_activity.xml @@ -18,6 +18,12 @@ android:layout_height="wrap_content" android:padding="16dp"> + + @@ -38,7 +38,7 @@ android:clipChildren="false" android:clipToPadding="false" android:alpha="0.7" - app:footer_text_color="@color/conversation_item_received_text_secondary_color" - app:footer_icon_color="@color/conversation_item_received_text_secondary_color"/> + app:footer_text_color="@color/signal_text_secondary" + app:footer_icon_color="@color/signal_icon_tint_secondary"/> \ No newline at end of file diff --git a/app/src/main/res/layout/longmessage_bubble_sent.xml b/app/src/main/res/layout/longmessage_bubble_sent.xml index 75efb79d4f..c5d78f31ab 100644 --- a/app/src/main/res/layout/longmessage_bubble_sent.xml +++ b/app/src/main/res/layout/longmessage_bubble_sent.xml @@ -19,8 +19,8 @@ android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" style="@style/Signal.Text.Body" - android:textColor="@color/signal_text_primary" - android:textColorLink="@color/signal_text_primary" + android:textColor="@color/conversation_item_sent_text_primary_color" + android:textColorLink="@color/conversation_item_sent_text_primary_color" android:textIsSelectable="true" app:scaleEmojis="true" tools:text="With great power comes great responsibility."/> @@ -37,7 +37,7 @@ android:gravity="end" android:clipChildren="false" android:clipToPadding="false" - app:footer_text_color="@color/signal_text_secondary" - app:footer_icon_color="@color/signal_icon_tint_secondary"/> + app:footer_text_color="@color/conversation_item_sent_text_secondary_color" + app:footer_icon_color="@color/conversation_item_sent_text_secondary_color"/> \ No newline at end of file diff --git a/app/src/main/res/layout/message_details_activity.xml b/app/src/main/res/layout/message_details_activity.xml index fcc3ac922f..f8fce17f33 100644 --- a/app/src/main/res/layout/message_details_activity.xml +++ b/app/src/main/res/layout/message_details_activity.xml @@ -5,6 +5,11 @@ android:layout_height="match_parent" android:background="@drawable/preference_divider"> + + - + android:orientation="horizontal"> + + - - + android:orientation="vertical"> - - + android:orientation="horizontal"> + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:layout_marginBottom="8dp" + android:layout_weight="1" + android:orientation="vertical"> - + + + + + + + + + + + + android:maxLines="2" + app:emoji_renderMentions="false" + tools:text="With great power comes great responsibility." + tools:visibility="visible" /> - - - - - - - - - - + android:layout_width="wrap_content" + android:layout_height="match_parent"> - + + + + + + + - + - - - - - - - + android:orientation="horizontal" + android:padding="8dp" + android:visibility="gone" + tools:visibility="visible"> + + + + + + + - - + diff --git a/app/src/main/res/layout/recipient_manage_fragment.xml b/app/src/main/res/layout/recipient_manage_fragment.xml index 5736cc792d..2e41876b51 100644 --- a/app/src/main/res/layout/recipient_manage_fragment.xml +++ b/app/src/main/res/layout/recipient_manage_fragment.xml @@ -489,40 +489,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - - - - - - - - diff --git a/app/src/main/res/navigation/chat_wallpaper.xml b/app/src/main/res/navigation/chat_wallpaper.xml index 1a9d32378b..605fbb8e87 100644 --- a/app/src/main/res/navigation/chat_wallpaper.xml +++ b/app/src/main/res/navigation/chat_wallpaper.xml @@ -17,12 +17,59 @@ app:enterAnim="@anim/fragment_open_enter" app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" - app:popExitAnim="@anim/fragment_close_exit" /> + app:popExitAnim="@anim/fragment_close_exit" /> + + + tools:layout="@layout/chat_wallpaper_selection_fragment" /> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 4866c34b33..f0d12d7b4b 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -50,6 +50,7 @@ @color/transparent @color/transparent_white_05 + @color/transparent_white_10 @color/transparent_white_15 @color/transparent_white_20 @color/transparent_white_40 @@ -67,10 +68,10 @@ @color/core_grey_25 @color/core_grey_60 - @color/transparent_white_90 - @color/transparent_white_60 + @color/transparent_white_90 + @color/transparent_white_60 @color/core_grey_45 - @color/core_grey_25 + @color/core_grey_25 @color/core_grey_05 @color/core_grey_95 @@ -131,4 +132,9 @@ @color/core_grey_55 @color/transparent_white_10 + + @color/transparent_black_40 + @color/core_grey_25 + @color/transparent_white_60 + @color/core_grey_80 diff --git a/app/src/main/res/values-sw360dp/dimens.xml b/app/src/main/res/values-sw360dp/dimens.xml index 81bed47eb7..307f5a0ef5 100644 --- a/app/src/main/res/values-sw360dp/dimens.xml +++ b/app/src/main/res/values-sw360dp/dimens.xml @@ -17,4 +17,6 @@ 260dp 24dp + + 260dp \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index c768662b5c..1c0077b012 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -280,4 +280,8 @@ + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 0d08f873dd..37c1532977 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -9,6 +9,7 @@ #18000000 #26000000 #33000000 + #40000000 #66000000 #99000000 #CC000000 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index a6505fa079..455e22c5ee 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -180,4 +180,6 @@ 32dp 16dp + + 240dp diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index 89ace9c5c4..66a0f8b509 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -50,6 +50,7 @@ @color/transparent_black @color/transparent_black_05 + @color/transparent_black_10 @color/transparent_black_15 @color/transparent_black_20 @color/transparent_black_40 @@ -67,10 +68,10 @@ @color/core_grey_60 @color/core_grey_15 - @color/core_white - @color/transparent_white_80 + @color/core_white + @color/transparent_white_80 @color/core_grey_60 - @color/core_grey_60 + @color/core_grey_60 @color/core_grey_90 @color/core_white @@ -131,4 +132,9 @@ @color/core_grey_45 @color/transparent_black_10 + + @color/transparent_white_60 + @color/core_grey_60 + @color/transparent_white_80 + @color/core_grey_15 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79bc9b0df4..b2eb22cad5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -716,7 +716,7 @@ Mute notifications Custom notifications Mentions - Chat wallpaper + Chat color & wallpaper Until %1$s Always Off @@ -2311,6 +2311,7 @@ Appearance Theme Chat wallpaper + Chat color & wallpaper Disable PIN Enable PIN If you disable the PIN, you will lose all data when you re-register Signal unless you manually back up and restore. You can not turn on Registration Lock while the PIN is disabled. @@ -3242,17 +3243,25 @@ Chat wallpaper + Chat color + Reset chat colors + Reset chat color + Reset chat color? Set wallpaper - Dark theme dims wallpaper - Clear wallpaper - Clear wallpaper for this chat? - Clear wallpaper? This will not clear custom wallpapers you’ve set for your chats. - Reset all wallpapers - Reset all wallpapers, including custom wallpapers you’ve set for your chats? + Dark mode dims wallpaper Contact name Reset Clear Wallpaper preview + Would you like to override all chat colors? + Would you like to override all wallpapers? + Reset default colors + Reset all colors + Reset default wallpaper + Reset all wallpapers + Reset wallpapers + Reset wallpaper + Reset wallpaper? Choose from photos @@ -3379,6 +3388,8 @@ About message requests Okay https://support.signal.org/hc/articles/360007459591 + Here\'s a preview of the chat color. + The color is visible to only you. Group description @@ -3460,6 +3471,37 @@ Calls + + Auto + Use custom colors + Chat color + Edit + Duplicate + Delete + Delete color + + This custom color is used in %1$d chat. Do you want to delete it for all chats? + This custom color is used in %1$d chats. Do you want to delete it for all chats? + + + + Solid + Gradient + Hue + Saturation + + + Save + Edit color + + This color is used in %1$d chat. Do you want to save changes for all chats? + This color is used in %1$d chat. Do you want to save changes for all chats? + + + + Top edge selector + Bottom edge selector + diff --git a/app/src/main/res/values/text_styles.xml b/app/src/main/res/values/text_styles.xml index ad297be533..8401f40e78 100644 --- a/app/src/main/res/values/text_styles.xml +++ b/app/src/main/res/values/text_styles.xml @@ -52,7 +52,7 @@