Merge branch 'dev'

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java
#	app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
#	app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
#	libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt
#	libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt
This commit is contained in:
Harris 2022-09-04 21:10:08 +10:00
commit 2bd078a441
232 changed files with 10819 additions and 1645 deletions

View File

@ -90,10 +90,7 @@ dependencies {
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
}
implementation 'com.annimon:stream:1.1.8'
implementation ('com.takisoft.fix:colorpicker:0.9.1') {
exclude group: 'com.android.support', module: 'appcompat-v7'
exclude group: 'com.android.support', module: 'recyclerview-v7'
}
implementation 'com.takisoft.fix:colorpicker:1.0.1'
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'org.signal:android-database-sqlcipher:3.5.9-S3'

View File

@ -221,7 +221,7 @@
android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2"
android:screenOrientation="portrait"
android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity"
android:theme="@style/Theme.Session.DayNight.FlatActionBar">
android:theme="@style/Theme.Session.DayNight.NoActionBar">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.home.HomeActivity" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -47,16 +47,20 @@ import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.ThreadUtils;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
import org.thoughtcrime.securesms.home.HomeActivity;
@ -90,12 +94,16 @@ import org.webrtc.voiceengine.WebRtcAudioManager;
import org.webrtc.voiceengine.WebRtcAudioUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.Security;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Timer;
import java.util.concurrent.Executors;
import javax.inject.Inject;
@ -224,6 +232,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
initializeWebRtc();
initializeBlobProvider();
resubmitProfilePictureIfNeeded();
loadEmojiSearchIndexIfNeeded();
EmojiSource.refresh();
}
@Override
@ -493,6 +503,20 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
});
}
private void loadEmojiSearchIndexIfNeeded() {
Executors.newSingleThreadExecutor().execute(() -> {
EmojiSearchDatabase emojiSearchDb = getDatabaseComponent().emojiSearchDatabase();
if (emojiSearchDb.query("face", 1).isEmpty()) {
try (InputStream inputStream = getAssets().open("emoji/emoji_search_index.json")) {
List<EmojiSearchData> searchIndex = Arrays.asList(JsonUtil.fromJson(inputStream, EmojiSearchData[].class));
emojiSearchDb.setSearchIndex(searchIndex);
} catch (IOException e) {
Log.e("Loki", "Failed to load emoji search index");
}
}
});
}
public void clearAllData(boolean isMigratingToV2KeyPair) {
String token = TextSecurePreferences.getFCMToken(this);
if (token != null && !token.isEmpty()) {

View File

@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.animation;
import android.animation.Animator;
public abstract class AnimationCompleteListener implements Animator.AnimatorListener {
@Override
public final void onAnimationStart(Animator animation) {}
@Override
public abstract void onAnimationEnd(Animator animation);
@Override
public final void onAnimationCancel(Animator animation) {}
@Override
public final void onAnimationRepeat(Animator animation) {}
}

View File

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.animation;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.Transformation;
import androidx.annotation.NonNull;
public class ResizeAnimation extends Animation {
private final View target;
private final int targetWidthPx;
private final int targetHeightPx;
private int startWidth;
private int startHeight;
public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
this.target = target;
this.targetWidthPx = targetWidthPx;
this.targetHeightPx = targetHeightPx;
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
int newWidth = (int) (startWidth + (targetWidthPx - startWidth) * interpolatedTime);
int newHeight = (int) (startHeight + (targetHeightPx - startHeight) * interpolatedTime);
ViewGroup.LayoutParams params = target.getLayoutParams();
params.width = newWidth;
params.height = newHeight;
target.setLayoutParams(params);
}
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
this.startWidth = width;
this.startHeight = height;
}
@Override
public boolean willChangeBounds() {
return true;
}
}

View File

@ -1,153 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.utilities.Stub;
import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView;
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 java.util.List;
import network.loki.messenger.R;
public class AlbumThumbnailView extends FrameLayout {
private @Nullable SlideClickListener thumbnailClickListener;
private @Nullable SlidesClickedListener downloadClickListener;
private int currentSizeClass;
private ViewGroup albumCellContainer;
private Stub<TransferControlView> transferControls;
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
if (thumbnailClickListener != null) {
thumbnailClickListener.onClick(v, slide);
}
};
private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick();
public AlbumThumbnailView(@NonNull Context context) {
super(context);
initialize();
}
public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize();
}
private void initialize() {
inflate(getContext(), R.layout.album_thumbnail_view, this);
albumCellContainer = findViewById(R.id.albumCellContainer);
transferControls = new Stub<>(findViewById(R.id.albumTransferControlsStub));
}
public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides, boolean showControls) {
if (slides.size() < 2) {
throw new IllegalStateException("Provided less than two slides.");
}
if (showControls) {
transferControls.get().setShowDownloadText(true);
transferControls.get().setSlides(slides);
transferControls.get().setDownloadClickListener(v -> {
if (downloadClickListener != null) {
downloadClickListener.onClick(v, slides);
}
});
} else {
if (transferControls.resolved()) {
transferControls.get().setVisibility(GONE);
}
}
int sizeClass = Math.min(slides.size(), 6);
if (sizeClass != currentSizeClass) {
inflateLayout(sizeClass);
currentSizeClass = sizeClass;
}
showSlides(glideRequests, slides);
}
public void setCellBackgroundColor(@ColorInt int color) {
ViewGroup cellRoot = findViewById(R.id.album_thumbnail_root);
if (cellRoot != null) {
for (int i = 0; i < cellRoot.getChildCount(); i++) {
cellRoot.getChildAt(i).setBackgroundColor(color);
}
}
}
public void setThumbnailClickListener(@Nullable SlideClickListener listener) {
thumbnailClickListener = listener;
}
public void setDownloadClickListener(@Nullable SlidesClickedListener listener) {
downloadClickListener = listener;
}
private void inflateLayout(int sizeClass) {
albumCellContainer.removeAllViews();
switch (sizeClass) {
case 2:
inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer);
break;
case 3:
inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer);
break;
case 4:
inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer);
break;
case 5:
inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer);
break;
default:
inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer);
break;
}
}
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);
if (slides.size() >= 3) {
setSlide(glideRequests, slides.get(2), R.id.album_cell_3);
}
if (slides.size() >= 4) {
setSlide(glideRequests, slides.get(3), R.id.album_cell_4);
}
if (slides.size() >= 5) {
setSlide(glideRequests, slides.get(4), R.id.album_cell_5);
}
if (slides.size() > 5) {
TextView text = findViewById(R.id.album_cell_overflow_text);
text.setText(getContext().getString(R.string.AlbumThumbnailView_plus, slides.size() - 5));
}
}
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) {
}
}

View File

@ -1,158 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView;
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.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.utilities.ThemeUtil;
import java.util.List;
import network.loki.messenger.R;
public class ConversationItemThumbnail extends FrameLayout {
private ThumbnailView thumbnail;
private AlbumThumbnailView album;
private ImageView shade;
private ConversationItemFooter footer;
private CornerMask cornerMask;
private Outliner outliner;
private boolean borderless;
public ConversationItemThumbnail(Context context) {
super(context);
init(null);
}
public ConversationItemThumbnail(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ConversationItemThumbnail(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_thumbnail, this);
this.thumbnail = findViewById(R.id.conversation_thumbnail_image);
this.album = findViewById(R.id.conversation_thumbnail_album);
this.shade = findViewById(R.id.conversation_thumbnail_shade);
this.footer = findViewById(R.id.conversation_thumbnail_footer);
this.cornerMask = new CornerMask(this);
this.outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
typedArray.recycle();
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (!borderless) {
cornerMask.mask(canvas);
if (album.getVisibility() != VISIBLE) {
outliner.draw(canvas);
}
}
}
@Override
public void setFocusable(boolean focusable) {
thumbnail.setFocusable(focusable);
album.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
thumbnail.setClickable(clickable);
album.setClickable(clickable);
}
@Override
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
thumbnail.setOnLongClickListener(l);
album.setOnLongClickListener(l);
}
public void showShade(boolean show) {
shade.setVisibility(show ? VISIBLE : GONE);
forceLayout();
}
public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
}
public void setBorderless(boolean borderless) {
this.borderless = borderless;
}
public ConversationItemFooter getFooter() {
return footer;
}
@UiThread
public void setImageResource(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides,
boolean showControls, boolean isPreview)
{
if (slides.size() == 1) {
thumbnail.setVisibility(VISIBLE);
album.setVisibility(GONE);
Slide slide = slides.get(0);
Attachment attachment = slide.asAttachment();
thumbnail.setImageResource(glideRequests, slide, showControls, isPreview, attachment.getWidth(), attachment.getHeight());
thumbnail.setLoadIndicatorVisibile(slide.isInProgress());
setTouchDelegate(thumbnail.getTouchDelegate());
} else {
thumbnail.setVisibility(GONE);
album.setVisibility(VISIBLE);
album.setSlides(glideRequests, slides, showControls);
setTouchDelegate(album.getTouchDelegate());
}
}
public void setConversationColor(@ColorInt int color) {
if (album.getVisibility() == VISIBLE) {
album.setCellBackgroundColor(color);
}
}
public void setThumbnailClickListener(SlideClickListener listener) {
thumbnail.setThumbnailClickListener(listener);
album.setThumbnailClickListener(listener);
}
public void setDownloadClickListener(SlidesClickedListener listener) {
thumbnail.setDownloadClickListener(listener);
album.setDownloadClickListener(listener);
}
}

View File

@ -1,21 +1,30 @@
package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.v2.Util;
import java.util.LinkedList;
import java.util.List;
public class CompositeEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final EmojiPageModel[] models;
@AttrRes private final int iconAttr;
@NonNull private final List<EmojiPageModel> models;
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull EmojiPageModel... models) {
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List<EmojiPageModel> models) {
this.iconAttr = iconAttr;
this.models = models;
}
@Override
public String getKey() {
return Util.hasItems(models) ? models.get(0).getKey() : "";
}
public int getIconAttr() {
return iconAttr;
}
@ -44,7 +53,7 @@ public class CompositeEmojiPageModel implements EmojiPageModel {
}
@Override
public @Nullable String getSprite() {
public @Nullable Uri getSpriteUri() {
return null;
}

View File

@ -1,14 +1,27 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Emoji {
private final List<String> variations;
private final List<String> rawVariations;
public Emoji(String... variations) {
this.variations = Arrays.asList(variations);
this(Arrays.asList(variations), Collections.emptyList());
}
public Emoji(List<String> variations) {
this(variations, Collections.emptyList());
}
public Emoji(List<String> variations, List<String> rawVariations) {
this.variations = variations;
this.rawVariations = rawVariations;
}
public String getValue() {
@ -18,4 +31,15 @@ public class Emoji {
public List<String> getVariations() {
return variations;
}
public boolean hasMultipleVariations() {
return variations.size() > 1;
}
public @Nullable String getRawVariation(int variationIndex) {
if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) {
return rawVariations.get(variationIndex);
}
return null;
}
}

View File

@ -1,20 +1,23 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatEditText;
import android.text.InputFilter;
import android.util.AttributeSet;
import network.loki.messenger.R;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatEditText;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.session.libsession.utilities.TextSecurePreferences;
import network.loki.messenger.R;
public class EmojiEditText extends AppCompatEditText {
private static final String TAG = EmojiEditText.class.getSimpleName();
private static final String TAG = Log.tag(EmojiEditText.class);
public EmojiEditText(Context context) {
this(context, null);
@ -26,8 +29,14 @@ public class EmojiEditText extends AppCompatEditText {
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (!TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
setFilters(appendEmojiFilter(this.getFilters()));
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
if (!isInEditMode() && forceCustom) {
setFilters(appendEmojiFilter(this.getFilters(), jumboEmoji));
}
}
@ -45,7 +54,7 @@ public class EmojiEditText extends AppCompatEditText {
else super.invalidateDrawable(drawable);
}
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) {
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) {
InputFilter[] result;
if (originalFilters != null) {
@ -55,7 +64,7 @@ public class EmojiEditText extends AppCompatEditText {
result = new InputFilter[1];
}
result[0] = new EmojiFilter(this);
result[0] = new EmojiFilter(this, jumboEmoji);
return result;
}

View File

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.emoji;
import android.view.KeyEvent;
public interface EmojiEventListener {
void onEmojiSelected(String emoji);
void onKeyEvent(KeyEvent keyEvent);
}

View File

@ -8,9 +8,11 @@ import android.widget.TextView;
public class EmojiFilter implements InputFilter {
private TextView view;
private boolean jumboEmoji;
public EmojiFilter(TextView view) {
this.view = view;
public EmojiFilter(TextView view, boolean jumboEmoji) {
this.view = view;
this.jumboEmoji = jumboEmoji;
}
@Override
@ -19,7 +21,7 @@ public class EmojiFilter implements InputFilter {
char[] v = new char[end - start];
TextUtils.getChars(source, start, end, v, 0);
Spannable emojified = EmojiProvider.getInstance(view.getContext()).emojify(new String(v), view);
Spannable emojified = EmojiProvider.emojify(new String(v), view, jumboEmoji);
if (source instanceof Spanned && emojified != null) {
TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0);

View File

@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageView;
import network.loki.messenger.R;
public class EmojiImageView extends AppCompatImageView {
private final boolean forceJumboEmoji;
public EmojiImageView(Context context) {
this(context, null);
}
public EmojiImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public EmojiImageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiImageView, 0, 0);
forceJumboEmoji = a.getBoolean(R.styleable.EmojiImageView_forceJumbo, false);
a.recycle();
}
public void setImageEmoji(CharSequence emoji) {
if (isInEditMode()) {
setImageResource(R.drawable.ic_emoji);
} else {
Drawable emojiDrawable = EmojiProvider.getEmojiDrawable(getContext(), emoji);
if (emojiDrawable == null) {
// fallback
setImageResource(R.drawable.ic_outline_disabled_by_default_24);
} else {
setImageDrawable(emojiDrawable);
}
}
}
}

View File

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.components.emoji
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiModel
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
import org.thoughtcrime.securesms.util.InsetItemDecoration
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(6)
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(6)
private val EMOJI_VERTICAL_INSET: Int = ViewUtil.dpToPx(5)
private val HEADER_VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
/**
* Use super class to add insets to the emojis and use the [onDrawOver] to draw the variation
* hint if the emoji has more than one variation.
*/
class EmojiItemDecoration(private val allowVariations: Boolean, private val variationsDrawable: Drawable) : InsetItemDecoration(SetInset()) {
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(canvas, parent, state)
val adapter: EmojiPageViewGridAdapter? = parent.adapter as? EmojiPageViewGridAdapter
if (allowVariations && adapter != null) {
for (i in 0 until parent.childCount) {
val child: View = parent.getChildAt(i)
val position: Int = parent.getChildAdapterPosition(child)
if (position >= 0 && position <= adapter.itemCount) {
val model = adapter.currentList[position]
if (model is EmojiModel && model.emoji.hasMultipleVariations()) {
variationsDrawable.setBounds(child.right, child.bottom - EDGE_LENGTH, child.right + EDGE_LENGTH, child.bottom)
variationsDrawable.draw(canvas)
}
}
}
}
}
private class SetInset : InsetItemDecoration.SetInset() {
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
val isHeader = view.javaClass == AppCompatTextView::class.java
outRect.left = HORIZONTAL_INSET
outRect.right = HORIZONTAL_INSET
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
}
}
}

View File

@ -136,8 +136,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
@Override
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener);
page.setModel(pages.get(position));
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, false);
container.addView(page);
return page;
}
@ -160,8 +159,4 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
}
}
public interface EmojiEventListener {
void onEmojiSelected(String emoji);
void onKeyEvent(KeyEvent keyEvent);
}
}

View File

@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.Nullable;
import java.util.List;
public interface EmojiPageModel {
String getKey();
int getIconAttr();
List<String> getEmoji();
List<Emoji> getDisplayEmoji();
boolean hasSpriteMap();
String getSprite();
@Nullable Uri getSpriteUri();
boolean isDynamic();
}

View File

@ -1,60 +1,136 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import java.util.List;
import java.util.Optional;
import network.loki.messenger.R;
public class EmojiPageView extends FrameLayout implements VariationSelectorListener {
private static final String TAG = EmojiPageView.class.getSimpleName();
private EmojiPageModel model;
private EmojiPageViewGridAdapter adapter;
private RecyclerView recyclerView;
private GridLayoutManager layoutManager;
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
private AdapterFactory adapterFactory;
private LinearLayoutManager layoutManager;
private RecyclerView.OnItemTouchListener scrollDisabler;
private VariationSelectorListener variationSelectorListener;
private EmojiVariationSelectorPopup popup;
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public EmojiPageView(@NonNull Context context,
@NonNull EmojiKeyboardProvider.EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener)
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations)
{
super(context);
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
initialize(emojiSelectionListener, variationSelectorListener, allowVariations);
}
public EmojiPageView(@NonNull Context context,
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@NonNull LinearLayoutManager layoutManager,
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
super(context);
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayEmojiLayoutResId, displayEmoticonLayoutResId);
}
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations)
{
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(getContext(), 8), R.layout.emoji_display_item_grid, R.layout.emoji_text_display_item_grid);
}
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@NonNull LinearLayoutManager layoutManager,
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
this.variationSelectorListener = variationSelectorListener;
recyclerView = view.findViewById(R.id.emoji);
layoutManager = new GridLayoutManager(context, 8);
scrollDisabler = new ScrollDisabler();
popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
adapter = new EmojiPageViewGridAdapter(EmojiProvider.getInstance(context),
popup,
emojiSelectionListener,
this);
this.layoutManager = layoutManager;
this.scrollDisabler = new ScrollDisabler();
this.popup = new EmojiVariationSelectorPopup(getContext(), emojiSelectionListener);
this.adapterFactory = () -> new EmojiPageViewGridAdapter(popup,
emojiSelectionListener,
this,
allowVariations,
displayEmojiLayoutResId,
displayEmoticonLayoutResId);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAdapter(adapter);
if (this.layoutManager instanceof GridLayoutManager) {
GridLayoutManager gridLayout = (GridLayoutManager) this.layoutManager;
gridLayout.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
if (getAdapter() != null) {
Optional<MappingModel<?>> model = getAdapter().getModel(position);
if (model.isPresent() && (model.get() instanceof EmojiHeader || model.get() instanceof EmojiNoResultsModel)) {
return gridLayout.getSpanCount();
}
}
return 1;
}
});
}
setLayoutManager(layoutManager);
Drawable drawable = DrawableUtil.tint(ContextUtil.requireDrawable(getContext(), R.drawable.triangle_bottom_right_corner), ContextCompat.getColor(getContext(), R.color.signal_button_secondary_text_disabled));
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
}
public void presentForEmojiKeyboard() {
setPadding(getPaddingLeft(),
getPaddingTop(),
getPaddingRight(),
getPaddingBottom() + ViewUtil.dpToPx(56));
setClipToPadding(false);
}
public void onSelected() {
if (model.isDynamic() && adapter != null) {
adapter.notifyDataSetChanged();
if (getAdapter() != null) {
getAdapter().notifyDataSetChanged();
}
}
public void setModel(EmojiPageModel model) {
this.model = model;
adapter.setEmoji(model.getDisplayEmoji());
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(list, commitCallback);
}
@Override
@ -66,16 +142,21 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
layoutManager.setSpanCount(Math.max(w / idealWidth, 1));
if (layoutManager instanceof GridLayoutManager) {
int viewWidth = w - getPaddingStart() - getPaddingEnd();
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
int spanCount = Math.max(viewWidth / idealWidth, 1);
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
}
}
@Override
public void onVariationSelectorStateChanged(boolean open) {
if (open) {
recyclerView.addOnItemTouchListener(scrollDisabler);
addOnItemTouchListener(scrollDisabler);
} else {
post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler));
post(() -> removeOnItemTouchListener(scrollDisabler));
}
if (variationSelectorListener != null) {
@ -83,6 +164,32 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
}
}
public void setRecyclerNestedScrollingEnabled(boolean enabled) {
setNestedScrollingEnabled(enabled);
}
public void smoothScrollToPositionTop(int position) {
int currentPosition = layoutManager.findFirstCompletelyVisibleItemPosition();
boolean shortTrip = Math.abs(currentPosition - position) < 475;
if (shortTrip) {
RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) {
@Override
protected int getVerticalSnapPreference() {
return LinearSmoothScroller.SNAP_TO_START;
}
};
smoothScroller.setTargetPosition(position);
layoutManager.startSmoothScroll(smoothScroller);
} else {
layoutManager.scrollToPositionWithOffset(position, 0);
}
}
public @Nullable EmojiPageViewGridAdapter getAdapter() {
return (EmojiPageViewGridAdapter) super.getAdapter();
}
private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
@ -95,4 +202,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
@Override
public void onRequestDisallowInterceptTouchEvent(boolean b) { }
}
private interface AdapterFactory {
EmojiPageViewGridAdapter create();
}
}

View File

@ -1,94 +1,40 @@
package org.thoughtcrime.securesms.components.emoji;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.TextView;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
import network.loki.messenger.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import java.util.ArrayList;
import java.util.List;
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageViewGridAdapter.EmojiViewHolder> implements PopupWindow.OnDismissListener {
private final VariationSelectorListener variationSelectorListener;
private final List<Emoji> emojiList;
private final EmojiProvider emojiProvider;
private final EmojiVariationSelectorPopup popup;
private final VariationSelectorListener variationSelectorListener;
private final EmojiEventListener emojiEventListener;
public EmojiPageViewGridAdapter(@NonNull EmojiProvider emojiProvider,
@NonNull EmojiVariationSelectorPopup popup,
public EmojiPageViewGridAdapter(@NonNull EmojiVariationSelectorPopup popup,
@NonNull EmojiEventListener emojiEventListener,
@NonNull VariationSelectorListener variationSelectorListener)
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
this.emojiList = new ArrayList<>();
this.emojiProvider = emojiProvider;
this.popup = popup;
this.emojiEventListener = emojiEventListener;
this.variationSelectorListener = variationSelectorListener;
popup.setOnDismissListener(this);
}
@NonNull
@Override
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new EmojiViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.emoji_display_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull EmojiViewHolder viewHolder, int i) {
Emoji emoji = emojiList.get(i);
Drawable drawable = emojiProvider.getEmojiDrawable(emoji.getValue());
if (drawable != null) {
viewHolder.textView.setVisibility(View.GONE);
viewHolder.imageView.setVisibility(View.VISIBLE);
viewHolder.imageView.setImageDrawable(drawable);
} else {
viewHolder.textView.setVisibility(View.VISIBLE);
viewHolder.imageView.setVisibility(View.GONE);
viewHolder.textView.setEmoji(emoji.getValue());
}
viewHolder.itemView.setOnClickListener(v -> {
emojiEventListener.onEmojiSelected(emoji.getValue());
});
if (emoji.getVariations().size() > 1) {
viewHolder.itemView.setOnLongClickListener(v -> {
popup.dismiss();
popup.setVariations(emoji.getVariations());
popup.showAsDropDown(viewHolder.itemView, 0, -(2 * viewHolder.itemView.getHeight()));
variationSelectorListener.onVariationSelectorStateChanged(true);
return true;
});
viewHolder.hintCorner.setVisibility(View.VISIBLE);
} else {
viewHolder.itemView.setOnLongClickListener(null);
viewHolder.hintCorner.setVisibility(View.GONE);
}
}
@Override
public int getItemCount() {
return emojiList.size();
}
public void setEmoji(@NonNull List<Emoji> emojiList) {
this.emojiList.clear();
this.emojiList.addAll(emojiList);
notifyDataSetChanged();
registerFactory(EmojiHeader.class, new LayoutFactory<>(EmojiHeaderViewHolder::new, R.layout.emoji_grid_header));
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayEmojiLayoutResId));
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), displayEmoticonLayoutResId));
registerFactory(EmojiNoResultsModel.class, new LayoutFactory<>(MappingViewHolder.SimpleViewHolder::new, R.layout.emoji_grid_no_results));
}
@Override
@ -96,18 +42,196 @@ public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageView
variationSelectorListener.onVariationSelectorStateChanged(false);
}
static class EmojiViewHolder extends RecyclerView.ViewHolder {
public static class EmojiHeader implements MappingModel<EmojiHeader>, HasKey {
private final ImageView imageView;
private final AsciiEmojiView textView;
private final ImageView hintCorner;
private final String key;
private final int title;
public EmojiViewHolder(@NonNull View itemView) {
super(itemView);
this.imageView = itemView.findViewById(R.id.emoji_image);
this.textView = itemView.findViewById(R.id.emoji_text);
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
public EmojiHeader(@NonNull String key, int title) {
this.key = key;
this.title = title;
}
@Override
public @NonNull String getKey() {
return key;
}
@Override
public boolean areItemsTheSame(@NonNull EmojiHeader newItem) {
return title == newItem.title;
}
@Override
public boolean areContentsTheSame(@NonNull EmojiHeader newItem) {
return areItemsTheSame(newItem);
}
}
static class EmojiHeaderViewHolder extends MappingViewHolder<EmojiHeader> {
private final TextView title;
public EmojiHeaderViewHolder(@NonNull View itemView) {
super(itemView);
title = findViewById(R.id.emoji_grid_header_title);
}
@Override
public void bind(@NonNull EmojiHeader model) {
title.setText(model.title);
}
}
public static class EmojiModel implements MappingModel<EmojiModel>, HasKey {
private final String key;
private final Emoji emoji;
public EmojiModel(@NonNull String key, @NonNull Emoji emoji) {
this.key = key;
this.emoji = emoji;
}
@Override
public @NonNull String getKey() {
return key;
}
public @NonNull Emoji getEmoji() {
return emoji;
}
@Override
public boolean areItemsTheSame(@NonNull EmojiModel newItem) {
return newItem.emoji.getValue().equals(emoji.getValue());
}
@Override
public boolean areContentsTheSame(@NonNull EmojiModel newItem) {
return areItemsTheSame(newItem);
}
}
static class EmojiViewHolder extends MappingViewHolder<EmojiModel> {
private final EmojiVariationSelectorPopup popup;
private final VariationSelectorListener variationSelectorListener;
private final EmojiEventListener emojiEventListener;
private final boolean allowVariations;
private final ImageView imageView;
public EmojiViewHolder(@NonNull View itemView,
@NonNull EmojiEventListener emojiEventListener,
@NonNull VariationSelectorListener variationSelectorListener,
@NonNull EmojiVariationSelectorPopup popup,
boolean allowVariations)
{
super(itemView);
this.popup = popup;
this.variationSelectorListener = variationSelectorListener;
this.emojiEventListener = emojiEventListener;
this.allowVariations = allowVariations;
this.imageView = itemView.findViewById(R.id.emoji_image);
}
@Override
public void bind(@NonNull EmojiModel model) {
final Drawable drawable = EmojiProvider.getEmojiDrawable(imageView.getContext(), model.emoji.getValue());
if (drawable != null) {
imageView.setVisibility(View.VISIBLE);
imageView.setImageDrawable(drawable);
}
itemView.setOnClickListener(v -> {
emojiEventListener.onEmojiSelected(model.emoji.getValue());
});
if (allowVariations && model.emoji.hasMultipleVariations()) {
itemView.setOnLongClickListener(v -> {
popup.dismiss();
popup.setVariations(model.emoji.getVariations());
popup.showAsDropDown(itemView, 0, -(2 * itemView.getHeight()));
variationSelectorListener.onVariationSelectorStateChanged(true);
return true;
});
} else {
itemView.setOnLongClickListener(null);
}
}
}
public static class EmojiTextModel implements MappingModel<EmojiTextModel>, HasKey {
private final String key;
private final Emoji emoji;
public EmojiTextModel(@NonNull String key, @NonNull Emoji emoji) {
this.key = key;
this.emoji = emoji;
}
@Override
public @NonNull String getKey() {
return key;
}
public @NonNull Emoji getEmoji() {
return emoji;
}
@Override
public boolean areItemsTheSame(@NonNull EmojiTextModel newItem) {
return newItem.emoji.getValue().equals(emoji.getValue());
}
@Override
public boolean areContentsTheSame(@NonNull EmojiTextModel newItem) {
return areItemsTheSame(newItem);
}
}
static class EmojiTextViewHolder extends MappingViewHolder<EmojiTextModel> {
private final EmojiEventListener emojiEventListener;
private final AsciiEmojiView textView;
public EmojiTextViewHolder(@NonNull View itemView,
@NonNull EmojiEventListener emojiEventListener)
{
super(itemView);
this.emojiEventListener = emojiEventListener;
this.textView = itemView.findViewById(R.id.emoji_text);
}
@Override
public void bind(@NonNull EmojiTextModel model) {
textView.setEmoji(model.emoji.getValue());
itemView.setOnClickListener(v -> {
emojiEventListener.onEmojiSelected(model.emoji.getValue());
});
}
}
public static class EmojiNoResultsModel implements MappingModel<EmojiNoResultsModel> {
@Override
public boolean areItemsTheSame(@NonNull EmojiNoResultsModel newItem) {
return true;
}
@Override
public boolean areContentsTheSame(@NonNull EmojiNoResultsModel newItem) {
return true;
}
}
public interface HasKey {
@NonNull String getKey();
}
public interface VariationSelectorListener {

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.TargetApi;
import static org.session.libsession.utilities.Util.runOnMain;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
@ -9,145 +10,156 @@ import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.widget.TextView;
import network.loki.messenger.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiPageBitmap;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree;
import org.session.libsignal.utilities.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.utilities.FutureTaskListener;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.Pair;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.emoji.EmojiPageCache;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.util.Util;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
class EmojiProvider {
public class EmojiProvider {
private static final String TAG = EmojiProvider.class.getSimpleName();
private static volatile EmojiProvider instance = null;
private static final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
private static final String TAG = Log.tag(EmojiProvider.class);
private static final Paint PAINT = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
private final EmojiTree emojiTree = new EmojiTree();
private static final int EMOJI_RAW_HEIGHT = 64;
private static final int EMOJI_RAW_WIDTH = 64;
private static final int EMOJI_VERT_PAD = 0;
private static final int EMOJI_PER_ROW = 32;
private final float decodeScale;
private final float verticalPad;
public static EmojiProvider getInstance(Context context) {
if (instance == null) {
synchronized (EmojiProvider.class) {
if (instance == null) {
instance = new EmojiProvider(context);
}
}
}
return instance;
}
private EmojiProvider(Context context) {
this.decodeScale = Math.min(1f, context.getResources().getDimension(R.dimen.emoji_drawer_size) / EMOJI_RAW_HEIGHT);
this.verticalPad = EMOJI_VERT_PAD * this.decodeScale;
for (EmojiPageModel page : EmojiPages.DATA_PAGES) {
if (page.hasSpriteMap()) {
EmojiPageBitmap pageBitmap = new EmojiPageBitmap(context, page, decodeScale);
List<String> emojis = page.getEmoji();
for (int i = 0; i < emojis.size(); i++) {
emojiTree.add(emojis.get(i), new EmojiDrawInfo(pageBitmap, i));
}
}
}
for (Pair<String,String> obsolete : EmojiPages.OBSOLETE) {
emojiTree.add(obsolete.first(), emojiTree.getEmoji(obsolete.second(), 0, obsolete.second().length()));
}
}
@Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
public static @Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
if (text == null) return null;
return new EmojiParser(emojiTree).findCandidates(text);
return new EmojiParser(EmojiSource.getLatest().getEmojiTree()).findCandidates(text);
}
@Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
return emojify(getCandidates(text), text, tv);
static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv, boolean jumboEmoji) {
if (tv.isInEditMode()) {
return null;
} else {
return emojify(getCandidates(text), text, tv, jumboEmoji);
}
}
@Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
@NonNull TextView tv) {
if (matches == null || text == null) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
static @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
@NonNull TextView tv,
boolean jumboEmoji)
{
if (matches == null || text == null || tv.isInEditMode()) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(candidate.getDrawInfo());
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout, jumboEmoji);
if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
return builder;
}
@Nullable Drawable getEmojiDrawable(CharSequence emoji) {
EmojiDrawInfo drawInfo = emojiTree.getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(drawInfo);
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
return getEmojiDrawable(context, emoji, false);
}
private @Nullable Drawable getEmojiDrawable(@Nullable EmojiDrawInfo drawInfo) {
if (drawInfo == null) {
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji, boolean jumboEmoji) {
if (TextUtils.isEmpty(emoji)) {
return null;
}
final EmojiDrawable drawable = new EmojiDrawable(drawInfo, decodeScale);
drawInfo.getPage().get().addListener(new FutureTaskListener<Bitmap>() {
@Override public void onSuccess(final Bitmap result) {
Util.runOnMain(() -> drawable.setBitmap(result));
}
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(context, drawInfo, null, jumboEmoji);
}
/**
* Gets an EmojiDrawable from the Page Cache
*
* @param context Context object used in reading and writing from disk
* @param drawInfo Information about the emoji being displayed
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
*/
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded, boolean jumboEmoji) {
if (drawInfo == null) {
return null;
}
final int lowMemoryDecodeScale = Util.isLowMemory(context) ? 2 : 1;
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
final AtomicBoolean jumboLoaded = new AtomicBoolean(false);
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
runOnMain(() -> drawable.setBitmap(((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap()));
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
((EmojiPageCache.LoadResult.Async) loadResult).getTask().addListener(new FutureTaskListener<Bitmap>() {
@Override
public void onSuccess(Bitmap result) {
runOnMain(() -> {
if (!jumboLoaded.get()) {
drawable.setBitmap(result);
if (onEmojiLoaded != null) {
onEmojiLoaded.run();
}
}
});
}
@Override
public void onFailure(ExecutionException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
}
});
} else {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
@Override public void onFailure(ExecutionException error) {
Log.w(TAG, error);
}
});
return drawable;
}
class EmojiDrawable extends Drawable {
private final EmojiDrawInfo info;
private Bitmap bmp;
private float intrinsicWidth;
private float intrinsicHeight;
static final class EmojiDrawable extends Drawable {
private final float intrinsicWidth;
private final float intrinsicHeight;
private final Rect emojiBounds;
private Bitmap bmp;
private boolean isSingleBitmap;
@Override
public int getIntrinsicWidth() {
return (int)intrinsicWidth;
return (int) intrinsicWidth;
}
@Override
public int getIntrinsicHeight() {
return (int)intrinsicHeight;
return (int) intrinsicHeight;
}
EmojiDrawable(EmojiDrawInfo info, float decodeScale) {
this.info = info;
this.intrinsicWidth = EMOJI_RAW_WIDTH * decodeScale;
this.intrinsicHeight = EMOJI_RAW_HEIGHT * decodeScale;
EmojiDrawable(@NonNull EmojiSource source, @NonNull EmojiDrawInfo info, int lowMemoryDecodeScale) {
this.intrinsicWidth = (source.getMetrics().getRawWidth() * source.getDecodeScale()) / lowMemoryDecodeScale;
this.intrinsicHeight = (source.getMetrics().getRawHeight() * source.getDecodeScale()) / lowMemoryDecodeScale;
final int glyphWidth = (int) (intrinsicWidth);
final int glyphHeight = (int) (intrinsicHeight);
final int index = info.getIndex();
final int emojiPerRow = source.getMetrics().getPerRow();
final int xStart = (index % emojiPerRow) * glyphWidth;
final int yStart = (index / emojiPerRow) * glyphHeight;
this.emojiBounds = new Rect(xStart + 1,
yStart + 1,
xStart + glyphWidth - 1,
yStart + glyphHeight - 1);
}
@Override
@ -156,22 +168,23 @@ class EmojiProvider {
return;
}
final int row = info.getIndex() / EMOJI_PER_ROW;
final int row_index = info.getIndex() % EMOJI_PER_ROW;
canvas.drawBitmap(bmp,
new Rect((int)(row_index * intrinsicWidth),
(int)(row * intrinsicHeight + row * verticalPad)+1,
(int)(((row_index + 1) * intrinsicWidth)-1),
(int)((row + 1) * intrinsicHeight + row * verticalPad)-1),
getBounds(),
paint);
isSingleBitmap ? null : emojiBounds,
getBounds(),
PAINT);
}
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
public void setBitmap(Bitmap bitmap) {
Util.assertMainThread();
if (VERSION.SDK_INT < VERSION_CODES.HONEYCOMB_MR1 || bmp == null || !bmp.sameAs(bitmap)) {
setBitmap(bitmap, false);
}
public void setSingleBitmap(Bitmap bitmap) {
setBitmap(bitmap, true);
}
private void setBitmap(Bitmap bitmap, boolean isSingleBitmap) {
this.isSingleBitmap = isSingleBitmap;
if (bmp == null || !bmp.sameAs(bitmap)) {
bmp = bitmap;
invalidateSelf();
}

View File

@ -1,41 +1,56 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.FontMetricsInt;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import android.widget.TextView;
import androidx.annotation.NonNull;
import network.loki.messenger.R;
public class EmojiSpan extends AnimatingImageSpan {
private final float SHIFT_FACTOR = 1.5f;
private final int size;
private final FontMetricsInt fm;
private int size;
private FontMetricsInt fontMetrics;
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
super(drawable, tv);
fm = tv.getPaint().getFontMetricsInt();
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
fontMetrics = tv.getPaint().getFontMetricsInt();
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
getDrawable().setBounds(0, 0, size, size);
}
public EmojiSpan(@NonNull Context context, @NonNull Drawable drawable, @NonNull Paint paint) {
super(drawable, null);
fontMetrics = paint.getFontMetricsInt();
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
: context.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
getDrawable().setBounds(0, 0, size, size);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
if (fm != null && this.fm != null) {
fm.ascent = this.fm.ascent;
fm.descent = this.fm.descent;
fm.top = this.fm.top;
fm.bottom = this.fm.bottom;
fm.leading = this.fm.leading;
return size;
if (fm != null && this.fontMetrics != null) {
fm.ascent = this.fontMetrics.ascent;
fm.descent = this.fontMetrics.descent;
fm.top = this.fontMetrics.top;
fm.bottom = this.fontMetrics.bottom;
fm.leading = this.fontMetrics.leading;
} else {
return super.getSize(paint, text, start, end, fm);
this.fontMetrics = paint.getFontMetricsInt();
this.size = Math.abs(this.fontMetrics.descent) + Math.abs(this.fontMetrics.ascent);
getDrawable().setBounds(0, 0, size, size);
}
return size;
}
@Override
@ -43,6 +58,7 @@ public class EmojiSpan extends AnimatingImageSpan {
int height = bottom - top;
int centeringMargin = (height - size) / 2;
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
int adjustedBottom = bottom - adjustedMargin;
super.draw(canvas, text, start, end, x, top, y, bottom - adjustedMargin, paint);
}
}

View File

@ -49,8 +49,7 @@ public class EmojiTextView extends AppCompatTextView {
}
@Override public void setText(@Nullable CharSequence text, BufferType type) {
EmojiProvider provider = EmojiProvider.getInstance(getContext());
EmojiParser.CandidateList candidates = provider.getCandidates(text);
EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text);
if (scaleEmojis && candidates != null && candidates.allEmojis) {
int emojis = candidates.size();
@ -82,7 +81,7 @@ public class EmojiTextView extends AppCompatTextView {
ellipsizeAnyTextForMaxLength();
}
} else {
CharSequence emojified = provider.emojify(candidates, text, this);
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, false);
super.setText(new SpannableStringBuilder(emojified).append(Optional.fromNullable(overflowText).or("")), BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
@ -107,12 +106,12 @@ public class EmojiTextView extends AppCompatTextView {
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or(""));
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent);
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
super.setText(newContent, BufferType.NORMAL);
} else {
CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this);
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false);
super.setText(emojified, BufferType.SPANNABLE);
}
}
@ -141,8 +140,8 @@ public class EmojiTextView extends AppCompatTextView {
.append(ellipsized.subSequence(0, ellipsized.length()))
.append(Optional.fromNullable(overflowText).or(""));
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this);
EmojiParser.CandidateList newCandidates = EmojiProvider.getCandidates(newContent);
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, false);
super.setText(emojified, BufferType.SPANNABLE);
}

View File

@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.ObsoleteEmoji;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
public final class EmojiUtil {
private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$");
private static final String EMOJI_REGEX = "[^\\p{L}\\p{M}\\p{N}\\p{P}\\p{Z}\\p{Cf}\\p{Cs}\\s]";
private EmojiUtil() {}
/**
* This will return all ways we know of expressing a singular emoji. This is to aid in search,
* where some platforms may send an emoji we've locally marked as 'obsolete'.
*/
public static @NonNull Set<String> getAllRepresentations(@NonNull String emoji) {
Set<String> out = new HashSet<>();
out.add(emoji);
for (ObsoleteEmoji obsoleteEmoji : EmojiSource.getLatest().getObsolete()) {
if (obsoleteEmoji.getObsolete().equals(emoji)) {
out.add(obsoleteEmoji.getReplaceWith());
} else if (obsoleteEmoji.getReplaceWith().equals(emoji)) {
out.add(obsoleteEmoji.getObsolete());
}
}
return out;
}
/**
* When provided an emoji that is a skin variation of another, this will return the default yellow
* version. This is to aid in search, so using a variation will still find all emojis tagged with
* the default version.
*
* If the emoji has no skin variations, this function will return the original emoji.
*/
public static @NonNull String getCanonicalRepresentation(@NonNull String emoji) {
String canonical = EmojiSource.getLatest().getVariationsToCanonical().get(emoji);
return canonical != null ? canonical : emoji;
}
public static boolean isCanonicallyEqual(@NonNull String left, @NonNull String right) {
return getCanonicalRepresentation(left).equals(getCanonicalRepresentation(right));
}
}

View File

@ -8,8 +8,6 @@ import android.widget.PopupWindow;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import java.util.List;
import network.loki.messenger.R;
@ -37,7 +35,7 @@ public class EmojiVariationSelectorPopup extends PopupWindow {
for (String variation : variations) {
ImageView imageView = (ImageView) LayoutInflater.from(context).inflate(R.layout.emoji_variation_selector_item, list, false);
imageView.setImageDrawable(EmojiProvider.getInstance(context).getEmojiDrawable(variation));
imageView.setImageDrawable(EmojiProvider.getEmojiDrawable(context, variation));
imageView.setOnClickListener(v -> {
listener.onEmojiSelected(variation);
dismiss();

View File

@ -2,30 +2,36 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.AsyncTask;
import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import network.loki.messenger.R;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import network.loki.messenger.R;
public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
private static final int EMOJI_LRU_SIZE = 50;
public static final String KEY = "Recents";
public static final List<String> DEFAULT_REACTIONS_LIST =
Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08");
private final SharedPreferences prefs;
private final LinkedHashSet<String> recentlyUsed;
@ -47,14 +53,28 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
}
@Override
public String getKey() {
return KEY;
}
@Override public int getIconAttr() {
return R.attr.emoji_category_recent;
}
@Override public List<String> getEmoji() {
List<String> emoji = new ArrayList<>(recentlyUsed);
Collections.reverse(emoji);
return emoji;
List<String> recent = new ArrayList<>(recentlyUsed);
List<String> out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size());
for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) {
if (recent.size() > i) {
out.add(recent.get(i));
} else {
out.add(DEFAULT_REACTIONS_LIST.get(i));
}
}
return out;
}
@Override public List<Emoji> getDisplayEmoji() {
@ -65,7 +85,9 @@ public class RecentEmojiPageModel implements EmojiPageModel {
return false;
}
@Override public String getSprite() {
@Nullable
@Override
public Uri getSpriteUri() {
return null;
}

View File

@ -1,38 +1,40 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.AttrRes;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import org.thoughtcrime.securesms.emoji.EmojiCategory;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
public class StaticEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final List<Emoji> emoji;
@Nullable private final String sprite;
private final @NonNull EmojiCategory category;
private final @NonNull List<Emoji> emoji;
private final @Nullable Uri sprite;
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable String sprite) {
List<Emoji> emoji = new ArrayList<>(strings.length);
for (String s : strings) {
emoji.add(new Emoji(s));
}
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull String[] strings, @Nullable Uri sprite) {
this(category, Arrays.stream(strings).map(s -> new Emoji(Collections.singletonList(s))).collect(Collectors.toList()), sprite);
}
this.iconAttr = iconAttr;
this.emoji = emoji;
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
this.category = category;
this.emoji = Collections.unmodifiableList(emoji);
this.sprite = sprite;
}
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull Emoji[] emoji, @Nullable String sprite) {
this.iconAttr = iconAttr;
this.emoji = Arrays.asList(emoji);
this.sprite = sprite;
@Override
public String getKey() {
return category.getKey();
}
public int getIconAttr() {
return iconAttr;
return category.getIcon();
}
@Override
@ -55,7 +57,7 @@ public class StaticEmojiPageModel implements EmojiPageModel {
}
@Override
public @Nullable String getSprite() {
public @Nullable Uri getSpriteUri() {
return sprite;
}

View File

@ -1,31 +0,0 @@
package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
public class EmojiDrawInfo {
private final EmojiPageBitmap page;
private final int index;
public EmojiDrawInfo(final @NonNull EmojiPageBitmap page, final int index) {
this.page = page;
this.index = index;
}
public @NonNull EmojiPageBitmap getPage() {
return page;
}
public int getIndex() {
return index;
}
@Override
public @NonNull String toString() {
return "DrawInfo{" +
"page=" + page +
", index=" + index +
'}';
}
}

View File

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.emoji.parsing
import org.thoughtcrime.securesms.emoji.EmojiPage
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?)

View File

@ -48,7 +48,7 @@ public class EmojiPageBitmap {
} else {
Callable<Bitmap> callable = () -> {
try {
Log.i(TAG, "loading page " + model.getSprite());
Log.i(TAG, "loading page " + model.getSpriteUri().toString());
return loadPage();
} catch (IOException ioe) {
Log.w(TAG, ioe);
@ -76,7 +76,7 @@ public class EmojiPageBitmap {
float scale = decodeScale;
AssetManager assetManager = context.getAssets();
InputStream assetStream = assetManager.open(model.getSprite());
InputStream assetStream = assetManager.open(model.getSpriteUri().toString());
BitmapFactory.Options options = new BitmapFactory.Options();
if (org.thoughtcrime.securesms.util.Util.isLowMemory(context)) {
@ -85,7 +85,7 @@ public class EmojiPageBitmap {
scale = decodeScale * 2;
}
Stopwatch stopwatch = new Stopwatch(model.getSprite());
Stopwatch stopwatch = new Stopwatch(model.getSpriteUri().toString());
Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options);
stopwatch.split("decode");
@ -94,7 +94,7 @@ public class EmojiPageBitmap {
stopwatch.stop(TAG);
bitmapReference = new SoftReference<>(scaledBitmap);
Log.i(TAG, "onPageLoaded(" + model.getSprite() + ") originalByteCount: " + bitmap.getByteCount()
Log.i(TAG, "onPageLoaded(" + model.getSpriteUri().toString() + ") originalByteCount: " + bitmap.getByteCount()
+ " scaledByteCount: " + scaledBitmap.getByteCount()
+ " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight());
return scaledBitmap;
@ -102,6 +102,6 @@ public class EmojiPageBitmap {
@Override
public @NonNull String toString() {
return model.getSprite();
return model.getSpriteUri().toString();
}
}

View File

@ -74,10 +74,10 @@ public class EmojiTree {
}
}
public @Nullable EmojiDrawInfo getEmoji(CharSequence unicode, int startPosition, int endPostiion) {
public @Nullable EmojiDrawInfo getEmoji(CharSequence unicode, int startPosition, int endPosition) {
EmojiTreeNode tree = root;
for (int i=startPosition; i<endPostiion; i++) {
for (int i=startPosition; i<endPosition; i++) {
char character = unicode.charAt(i);
if (!tree.hasChild(character)) {
@ -88,7 +88,7 @@ public class EmojiTree {
}
if (tree.getEmoji() != null) return tree.getEmoji();
else if (unicode.charAt(endPostiion-1) != TERMINATOR && tree.hasChild(TERMINATOR)) return tree.getChild(TERMINATOR).getEmoji();
else if (unicode.charAt(endPosition-1) != TERMINATOR && tree.hasChild(TERMINATOR)) return tree.getChild(TERMINATOR).getEmoji();
else return null;
}

View File

@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.menu
import androidx.annotation.AttrRes
/**
* Represents an action to be rendered
*/
data class ActionItem(
@AttrRes val iconRes: Int,
val title: CharSequence,
val action: Runnable
)

View File

@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.components.menu
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Handles the setup and display of actions shown in a context menu.
*/
class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
private val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.context_menu_item))
}
init {
recyclerView.apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
}
fun setItems(items: List<ActionItem>) {
mappingAdapter.submitList(items.toAdapterItems())
}
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
return this.mapIndexed { index, item ->
val displayType: DisplayType = when {
this.size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE
}
DisplayItem(item, displayType)
}
}
private data class DisplayItem(
val item: ActionItem,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
}
private enum class DisplayType {
TOP, BOTTOM, MIDDLE, ONLY
}
private class ItemViewHolder(
itemView: View,
private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.context_menu_item_title)
override fun bind(model: DisplayItem) {
if (model.item.iconRes > 0) {
val typedValue = TypedValue()
context.theme.resolveAttribute(model.item.iconRes, typedValue, true)
icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId))
}
title.text = model.item.title
itemView.setOnClickListener {
model.item.action.run()
onItemClick()
}
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only)
}
}
}
}

View File

@ -34,6 +34,7 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.annotation.DimenRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.drawToBitmap
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
@ -47,18 +48,20 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2ActionBarBinding
import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.control.DataExtractionNotification
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
@ -68,6 +71,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.MediaTypes
import org.session.libsession.utilities.Stub
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsession.utilities.recipients.Recipient
@ -82,6 +86,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
@ -92,9 +98,8 @@ import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCand
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VoiceMessageViewDelegate
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
@ -110,13 +115,17 @@ import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId
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.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
@ -132,6 +141,8 @@ import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
@ -154,12 +165,12 @@ import kotlin.math.sqrt
// price we pay is a bit of back and forth between the input bar and the conversation activity.
@AndroidEntryPoint
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, VoiceMessageViewDelegate, LoaderManager.LoaderCallbacks<Cursor> {
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback {
private var binding: ActivityConversationV2Binding? = null
private var actionBarBinding: ActivityConversationV2ActionBarBinding? = null
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var threadDb: ThreadDatabase
@ -172,6 +183,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase
@Inject lateinit var storage: Storage
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
@ -203,7 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
} ?: finish()
}
viewModelFactory.create(threadId)
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
}
private var actionMode: ActionMode? = null
private var unreadCount = 0
@ -252,21 +264,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
onItemPress = { message, position, view, event ->
handlePress(message, position, view, event)
},
onItemSwipeToReply = { message, position ->
handleSwipeToReply(message, position)
onItemSwipeToReply = { message, _ ->
handleSwipeToReply(message)
},
onItemLongPress = { message, position ->
handleLongPress(message, position)
onItemLongPress = { message, position, view ->
if (!isMessageRequestThread() &&
(viewModel.openGroup == null || Capability.REACTIONS.name.lowercase() in viewModel.serverCapabilities)
) {
showEmojiPicker(message, view)
} else {
handleLongPress(message, position)
}
},
glide,
onDeselect = { message, position ->
actionMode?.let {
onDeselect(message, position, it)
}
},
glide = glide,
lifecycleCoroutineScope = lifecycleScope
)
adapter.visibleMessageContentViewDelegate = this
adapter.visibleMessageViewDelegate = this
adapter
}
@ -279,6 +297,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null)
private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1
// region Settings
companion object {
// Extras
@ -294,8 +315,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124
//flag
const val IS_UNSEND_REQUESTS_ENABLED = true
}
// endregion
@ -339,14 +358,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showOrHideInputIfNeeded()
setUpMessageRequestsBar()
viewModel.recipient?.let { recipient ->
if (recipient.isOpenGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId)
if (openGroup == null) {
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
return finish()
}
if (recipient.isOpenGroupRecipient && viewModel.openGroup == null) {
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
return finish()
}
}
val reactionOverlayStub: Stub<ConversationReactionOverlay> =
ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub)
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this)
}
override fun onResume() {
@ -413,22 +434,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate
private fun setUpToolBar() {
setSupportActionBar(binding?.toolbar)
val actionBar = supportActionBar ?: return
actionBarBinding = ActivityConversationV2ActionBarBinding.inflate(layoutInflater)
actionBar.title = ""
actionBar.customView = actionBarBinding!!.root
actionBar.setDisplayShowCustomEnabled(true)
actionBarBinding!!.conversationTitleView.text = viewModel.recipient?.toShortString()
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setHomeButtonEnabled(true)
binding!!.toolbarContent.conversationTitleView.text = viewModel.recipient?.toShortString()
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) {
R.dimen.medium_profile_picture_size
} else {
R.dimen.small_profile_picture_size
}
val size = resources.getDimension(sizeID).roundToInt()
actionBarBinding!!.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
actionBarBinding!!.profilePictureView.root.glide = glide
binding!!.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
binding!!.toolbarContent.profilePictureView.root.glide = glide
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
val profilePictureView = actionBarBinding!!.profilePictureView.root
val profilePictureView = binding!!.toolbarContent.profilePictureView.root
viewModel.recipient?.let { recipient ->
profilePictureView.update(recipient)
}
@ -529,8 +550,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private fun getLatestOpenGroupInfoIfNeeded() {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() }
viewModel.openGroup?.let {
OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() }
}
}
// called from onCreate
@ -609,7 +631,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
tearDownRecipientObserver()
super.onDestroy()
binding = null
actionBarBinding = null
// actionBarBinding = null
}
// endregion
@ -625,9 +647,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updateSubtitle()
showOrHideInputIfNeeded()
if (recipient != null) {
actionBarBinding?.profilePictureView?.root?.update(recipient)
binding?.toolbarContent?.profilePictureView?.root?.update(recipient)
}
actionBarBinding?.conversationTitleView?.text = recipient?.toShortString()
binding?.toolbarContent?.conversationTitleView?.text = recipient?.toShortString()
}
}
@ -865,12 +887,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
binding.typingIndicatorViewContainer.isVisible
binding.scrollToBottomButton.isVisible = !isScrolledToBottom && adapter.itemCount > 0
showOrHidScrollToBottomButton()
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0)
updateUnreadCountIndicator()
}
private fun showOrHidScrollToBottomButton(show: Boolean = true) {
binding?.scrollToBottomButton?.isVisible = show && !isScrolledToBottom && adapter.itemCount > 0
}
private fun updateUnreadCountIndicator() {
val binding = binding ?: return
val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+"
@ -882,7 +908,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
private fun updateSubtitle() {
val actionBarBinding = actionBarBinding ?: return
val actionBarBinding = binding?.toolbarContent ?: return
val recipient = viewModel.recipient ?: return
actionBarBinding.muteIconImageView.isVisible = recipient.isMuted
actionBarBinding.conversationSubtitleView.isVisible = true
@ -893,11 +919,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
}
} else if (recipient.isGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId)
if (openGroup != null) {
viewModel.openGroup?.let { openGroup ->
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
} else {
} ?: run {
actionBarBinding.conversationSubtitleView.isVisible = false
}
} else {
@ -942,7 +967,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
// `position` is the adapter position; not the visual position
private fun handleSwipeToReply(message: MessageRecord, position: Int) {
private fun handleSwipeToReply(message: MessageRecord) {
val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, message, glide)
}
@ -966,6 +991,164 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
private fun showEmojiPicker(message: MessageRecord, visibleMessageView: VisibleMessageView) {
val messageContentBitmap = try {
visibleMessageView.messageContentView.drawToBitmap()
} catch (e: Exception) {
Log.e("Loki", "Failed to show emoji picker", e)
return
}
ViewUtil.hideKeyboard(this, visibleMessageView);
binding?.reactionsShade?.isVisible = true
showOrHidScrollToBottomButton(false)
binding?.conversationRecyclerView?.suppressLayout(true)
reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message))
reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener {
override fun startHide() {
binding?.reactionsShade?.let {
ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
}
showOrHidScrollToBottomButton(true)
}
override fun onHide() {
binding?.conversationRecyclerView?.suppressLayout(false)
WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2);
WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2);
}
})
val contentBounds = Rect()
visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds)
val selectedConversationModel = SelectedConversationModel(
messageContentBitmap,
contentBounds.left.toFloat(),
contentBounds.top.toFloat(),
visibleMessageView.messageContentView.width,
message.isOutgoing,
visibleMessageView.messageContentView
)
reactionDelegate.show(this, message, selectedConversationModel)
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
return reactionDelegate.applyTouchEvent(ev) || super.dispatchTouchEvent(ev)
}
override fun onReactionSelected(messageRecord: MessageRecord, emoji: String) {
reactionDelegate.hide()
val oldRecord = messageRecord.reactions.find { it.author == textSecurePreferences.getLocalNumber() }
if (oldRecord != null && oldRecord.emoji == emoji) {
sendEmojiRemoval(emoji, messageRecord)
} else {
sendEmojiReaction(emoji, messageRecord)
}
}
private fun sendEmojiReaction(emoji: String, originalMessage: MessageRecord) {
// Create the message
val recipient = viewModel.recipient ?: return
val reactionMessage = VisibleMessage()
val emojiTimestamp = System.currentTimeMillis()
reactionMessage.sentTimestamp = emojiTimestamp
val author = textSecurePreferences.getLocalNumber()!!
// Put the message in the database
val reaction = ReactionRecord(
messageId = originalMessage.id,
isMms = originalMessage.isMms,
author = author,
emoji = emoji,
count = 1,
dateSent = emojiTimestamp,
dateReceived = emojiTimestamp
)
reactionDb.addReaction(MessageId(originalMessage.id, originalMessage.isMms), reaction)
// Send it
reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalMessage.recipient.address.serialize(), emoji, true)
if (recipient.isOpenGroupRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let {
OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji)
}
} else {
MessageSender.send(reactionMessage, recipient.address)
}
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) {
val recipient = viewModel.recipient ?: return
val message = VisibleMessage()
val emojiTimestamp = System.currentTimeMillis()
message.sentTimestamp = emojiTimestamp
val author = textSecurePreferences.getLocalNumber()!!
reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author)
message.reaction = Reaction.from(originalMessage.timestamp, author, emoji, false)
if (recipient.isOpenGroupRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let {
OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji)
}
} else {
MessageSender.send(message, recipient.address)
}
LoaderManager.getInstance(this).restartLoader(0, null, this)
}
override fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) {
val oldRecord = messageRecord.reactions.find { record -> record.author == textSecurePreferences.getLocalNumber() }
if (oldRecord != null && hasAddedCustomEmoji) {
reactionDelegate.hide()
sendEmojiRemoval(oldRecord.emoji, messageRecord)
} else {
reactionDelegate.hideForReactWithAny()
ReactWithAnyEmojiDialogFragment
.createForMessageRecord(messageRecord, reactWithAnyEmojiStartPage)
.show(supportFragmentManager, "BOTTOM");
}
}
override fun onReactWithAnyEmojiDialogDismissed() {
reactionDelegate.hide()
}
override fun onReactWithAnyEmojiSelected(emoji: String, messageId: MessageId) {
reactionDelegate.hide()
val message = if (messageId.mms) {
mmsDb.getMessageRecord(messageId.id)
} else {
smsDb.getMessageRecord(messageId.id)
}
val oldRecord = reactionDb.getReactions(messageId).find { it.author == textSecurePreferences.getLocalNumber() }
if (oldRecord?.emoji == emoji) {
sendEmojiRemoval(emoji, message)
} else {
sendEmojiReaction(emoji, message)
}
}
override fun onRemoveReaction(emoji: String, messageId: MessageId) {
val message = if (messageId.mms) {
mmsDb.getMessageRecord(messageId.id)
} else {
smsDb.getMessageRecord(messageId.id)
}
sendEmojiRemoval(emoji, message)
}
override fun onClearAll(emoji: String, messageId: MessageId) {
reactionDb.deleteEmojiReactions(emoji, messageId)
viewModel.openGroup?.let { openGroup ->
lokiMessageDb.getServerID(messageId.id, !messageId.mms)?.let { serverId ->
OpenGroupApi.deleteAllReactions(openGroup.room, openGroup.server, serverId, emoji)
}
}
threadDb.notifyThreadUpdated(viewModel.threadId)
}
override fun onMicrophoneButtonMove(event: MotionEvent) {
val rawX = event.rawX
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return
@ -1047,6 +1230,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
}
override fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean) {
val message = if (messageId.mms) {
mmsDb.getMessageRecord(messageId.id)
} else {
smsDb.getMessageRecord(messageId.id)
}
if (userWasSender) {
sendEmojiRemoval(emoji, message)
} else {
sendEmojiReaction(emoji, message)
}
}
override fun onReactionLongClicked(messageId: MessageId) {
if (viewModel.recipient?.isGroupRecipient == true) {
val isUserModerator = viewModel.openGroup?.let { openGroup ->
val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false
OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey)
} ?: false
val fragment = ReactionsDialogFragment.create(messageId, isUserModerator)
fragment.show(supportFragmentManager, null)
}
}
override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) {
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
@ -1303,34 +1510,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
}
// Remove this after the unsend request is enabled
fun deleteMessagesWithoutUnsendRequest(messages: Set<MessageRecord>) {
val messageCount = messages.size
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
builder.setCancelable(true)
builder.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteMessagesWithoutUnsendRequest(messages)
endActionMode()
}
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
override fun selectMessages(messages: Set<MessageRecord>) {
handleLongPress(messages.first(), 0) //TODO: begin selection mode
}
override fun deleteMessages(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient ?: return
if (!IS_UNSEND_REQUESTS_ENABLED) {
deleteMessagesWithoutUnsendRequest(messages)
return
}
val allSentByCurrentUser = messages.all { it.isOutgoing }
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null }
if (recipient.isOpenGroupRecipient) {
val messageCount = messages.size
val messageCount = 1
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
@ -1369,7 +1558,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
} else {
val messageCount = messages.size
val messageCount = 1
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
@ -1466,9 +1655,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun showMessageDetail(messages: Set<MessageRecord>) {
val message = messages.first()
val intent = Intent(this, MessageDetailActivity::class.java)
intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, message.timestamp)
intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp)
push(intent)
endActionMode()
}
@ -1549,8 +1737,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (result == null) return@Observer
if (result.getResults().isNotEmpty()) {
result.getResults()[result.position]?.let {
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs,
{ searchViewModel.onMissingResult() })
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs) {
searchViewModel.onMissingResult() }
}
}
binding?.searchBottomBar?.setData(result.position, result.getResults().size)
@ -1600,4 +1788,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
// endregion
inner class ReactionsToolbarListener constructor(val message: MessageRecord) : OnActionSelectedListener {
override fun onActionSelected(action: ConversationReactionOverlay.Action) {
val selectedItems = setOf(message)
when (action) {
ConversationReactionOverlay.Action.REPLY -> reply(selectedItems)
ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems)
ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems)
ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems)
ConversationReactionOverlay.Action.VIEW_INFO -> showMessageDetail(selectedItems)
ConversationReactionOverlay.Action.SELECT -> selectMessages(selectedItems)
ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems)
}
}
}
}

View File

@ -24,23 +24,30 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit,
private val glide: GlideRequests, private val onDeselect: (MessageRecord, Int) -> Unit, lifecycleCoroutineScope: LifecycleCoroutineScope)
class ConversationAdapter(
context: Context,
cursor: Cursor,
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
private val onDeselect: (MessageRecord, Int) -> Unit,
private val glide: GlideRequests,
lifecycleCoroutineScope: LifecycleCoroutineScope
)
: CursorRecyclerViewAdapter<ViewHolder>(context, cursor) {
private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() }
private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() }
var selectedItems = mutableSetOf<MessageRecord>()
private var searchQuery: String? = null
var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null
var visibleMessageViewDelegate: VisibleMessageViewDelegate? = null
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val contactCache = SparseArray<Contact>(100)
@ -99,7 +106,6 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
val messageBefore = getMessageBefore(position, cursor)
when (viewHolder) {
is VisibleMessageViewHolder -> {
val view = viewHolder.view
val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView
val isSelected = selectedItems.contains(message)
visibleMessageView.snIsSelected = isSelected
@ -114,17 +120,16 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
}
val contact = contactCache[senderIdHash]
visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId)
visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId, visibleMessageViewDelegate)
if (!message.isDeleted) {
visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) }
visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) }
visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) }
visibleMessageView.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) }
} else {
visibleMessageView.onPress = null
visibleMessageView.onSwipeToReply = null
visibleMessageView.onLongPress = null
}
visibleMessageView.contentViewDelegate = visibleMessageContentViewDelegate
}
is ControlMessageViewHolder -> {
viewHolder.view.bind(message, messageBefore)
@ -149,6 +154,11 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
}
}
fun toggleSelection(message: MessageRecord, position: Int) {
if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message)
notifyItemChanged(position)
}
override fun onItemViewRecycled(viewHolder: ViewHolder?) {
when (viewHolder) {
is VisibleMessageViewHolder -> viewHolder.view.findViewById<VisibleMessageView>(R.id.visibleMessageView).recycle()
@ -196,11 +206,6 @@ class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPr
}
}
fun toggleSelection(message: MessageRecord, position: Int) {
if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message)
notifyItemChanged(position)
}
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
val cursor = this.cursor
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null

View File

@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import network.loki.messenger.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.ContextMenuList
/**
* The context menu shown after long pressing a message in ConversationActivity.
*/
class ConversationContextMenu(private val anchor: View, items: List<ActionItem>) : PopupWindow(
LayoutInflater.from(anchor.context).inflate(R.layout.context_menu, null),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
) {
val context: Context = anchor.context
private val contextMenuList = ContextMenuList(
recyclerView = contentView.findViewById(R.id.context_menu_list),
onItemClick = { dismiss() },
)
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.context_menu_background))
animationStyle = R.style.ConversationContextMenuAnimation
isFocusable = false
isOutsideTouchable = true
elevation = 20f
setTouchInterceptor { _, event ->
event.action == MotionEvent.ACTION_OUTSIDE
}
contextMenuList.setItems(items)
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
}
fun getMaxWidth(): Int = contentView.measuredWidth
fun getMaxHeight(): Int = contentView.measuredHeight
fun show(offsetX: Int, offsetY: Int) {
showAsDropDown(anchor, offsetX, offsetY, Gravity.TOP or Gravity.START)
}
}

View File

@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.graphics.PointF
import android.view.MotionEvent
import org.session.libsession.utilities.Stub
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnHideListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.database.model.MessageRecord
/**
* Delegate class that mimics the ConversationReactionOverlay public API
*
* This allows us to properly stub out the ConversationReactionOverlay View class while still
* respecting listeners and other positional information that can be set BEFORE we want to actually
* resolve the view.
*/
internal class ConversationReactionDelegate(private val overlayStub: Stub<ConversationReactionOverlay>) {
private val lastSeenDownPoint = PointF()
private var onReactionSelectedListener: OnReactionSelectedListener? = null
private var onActionSelectedListener: OnActionSelectedListener? = null
private var onHideListener: OnHideListener? = null
val isShowing: Boolean
get() = overlayStub.resolved() && overlayStub.get().isShowing
fun show(
activity: Activity,
messageRecord: MessageRecord,
selectedConversationModel: SelectedConversationModel
) {
resolveOverlay().show(
activity,
messageRecord,
lastSeenDownPoint,
selectedConversationModel
)
}
fun hide() {
overlayStub.get().hide()
}
fun hideForReactWithAny() {
overlayStub.get().hideForReactWithAny()
}
fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener) {
this.onReactionSelectedListener = onReactionSelectedListener
if (overlayStub.resolved()) {
overlayStub.get().setOnReactionSelectedListener(onReactionSelectedListener)
}
}
fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener
if (overlayStub.resolved()) {
overlayStub.get().setOnActionSelectedListener(onActionSelectedListener)
}
}
fun setOnHideListener(onHideListener: OnHideListener) {
this.onHideListener = onHideListener
if (overlayStub.resolved()) {
overlayStub.get().setOnHideListener(onHideListener)
}
}
val messageRecord: MessageRecord
get() {
check(overlayStub.resolved()) { "Cannot call getMessageRecord right now." }
return overlayStub.get().messageRecord
}
fun applyTouchEvent(motionEvent: MotionEvent): Boolean {
return if (!overlayStub.resolved() || !overlayStub.get().isShowing) {
if (motionEvent.action == MotionEvent.ACTION_DOWN) {
lastSeenDownPoint[motionEvent.x] = motionEvent.y
}
false
} else {
overlayStub.get().applyTouchEvent(motionEvent)
}
}
private fun resolveOverlay(): ConversationReactionOverlay {
val overlay = overlayStub.get()
overlay.requestFitSystemWindows()
overlay.setOnHideListener(onHideListener)
overlay.setOnActionSelectedListener(onActionSelectedListener)
overlay.setOnReactionSelectedListener(onReactionSelectedListener)
return overlay
}
}

View File

@ -0,0 +1,882 @@
package org.thoughtcrime.securesms.conversation.v2;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.session.libsession.messaging.open_groups.OpenGroup;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.ThemeUtil;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.AnimationCompleteListener;
import org.thoughtcrime.securesms.util.DateUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R;
public final class ConversationReactionOverlay extends FrameLayout {
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private final Rect emojiViewGlobalRect = new Rect();
private final Rect emojiStripViewBounds = new Rect();
private float segmentSize;
private final Boundary horizontalEmojiBoundary = new Boundary();
private final Boundary verticalScrubBoundary = new Boundary();
private final PointF deadzoneTouchPoint = new PointF();
private Activity activity;
private MessageRecord messageRecord;
private SelectedConversationModel selectedConversationModel;
private OverlayState overlayState = OverlayState.HIDDEN;
private RecentEmojiPageModel recentEmojiPageModel;
private boolean downIsOurs;
private int selected = -1;
private int customEmojiIndex;
private int originalStatusBarColor;
private int originalNavigationBarColor;
private View dropdownAnchor;
private LinearLayout conversationItem;
private View backgroundView;
private ConstraintLayout foregroundView;
private EmojiImageView[] emojiViews;
private ConversationContextMenu contextMenu;
private float touchDownDeadZoneSize;
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
private int scrubberWidth;
private int selectedVerticalTranslation;
private int scrubberHorizontalMargin;
private int animationEmojiStartDelayFactor;
private int statusBarHeight;
private OnReactionSelectedListener onReactionSelectedListener;
private OnActionSelectedListener onActionSelectedListener;
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
public ConversationReactionOverlay(@NonNull Context context) {
super(context);
}
public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
dropdownAnchor = findViewById(R.id.dropdown_anchor);
conversationItem = findViewById(R.id.conversation_item);
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
findViewById(R.id.reaction_2),
findViewById(R.id.reaction_3),
findViewById(R.id.reaction_4),
findViewById(R.id.reaction_5),
findViewById(R.id.reaction_6),
findViewById(R.id.reaction_7) };
customEmojiIndex = emojiViews.length - 1;
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
initAnimators();
}
public void show(@NonNull Activity activity,
@NonNull MessageRecord messageRecord,
@NonNull PointF lastSeenDownPoint,
@NonNull SelectedConversationModel selectedConversationModel)
{
if (overlayState != OverlayState.HIDDEN) {
return;
}
this.messageRecord = messageRecord;
this.selectedConversationModel = selectedConversationModel;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
recentEmojiPageModel = new RecentEmojiPageModel(activity);
setupSelectedEmoji();
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble);
conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp()));
updateConversationTimestamp(messageRecord);
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR);
conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR);
setVisibility(View.INVISIBLE);
this.activity = activity;
updateSystemUiOnShow(activity);
ViewKt.doOnLayout(this, v -> {
showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft);
return Unit.INSTANCE;
});
}
private void updateConversationTimestamp(MessageRecord message) {
View bubble = conversationItem.findViewById(R.id.conversation_item_bubble);
View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp);
conversationItem.removeAllViewsInLayout();
conversationItem.addView(message.isOutgoing() ? timestamp : bubble);
conversationItem.addView(message.isOutgoing() ? bubble : timestamp);
conversationItem.requestLayout();
}
private void showAfterLayout(@NonNull MessageRecord messageRecord,
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft) {
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord));
float itemX = isMessageOnLeft ? scrubberHorizontalMargin :
selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth();
conversationItem.setX(itemX);
conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight);
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
int overlayHeight = getHeight();
int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endX = itemX;
float endY = conversationItem.getY();
float endApparentTop = endY;
float endScale = 1f;
float menuPadding = DimensionUnit.DP.toPixels(12f);
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
int reactionBarHeight = backgroundView.getHeight();
float reactionBarBackgroundY;
if (isWideLayout) {
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
if (everythingFitsVertically) {
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
if (reactionBarFitsAboveItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
} else {
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItem.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0);
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
if (everythingFitsVertically) {
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
if (menuFitsBelowItem) {
if (conversationItem.getY() < 0) {
endY = 0;
}
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
if (reactionBarBackgroundY <= reactionBarTopPadding) {
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
}
} else {
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
}
endApparentTop = endY;
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
} else {
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
int menuHeight = contextMenu.getHeight();
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
if (fitsVertically) {
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
if (menuFitsBelowItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
if (reactionBarBackgroundY < reactionBarTopPadding) {
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
}
endApparentTop = endY;
} else {
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
}
}
}
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
hideAnimatorSet.end();
setVisibility(View.VISIBLE);
float scrubberX;
if (isMessageOnLeft) {
scrubberX = scrubberHorizontalMargin;
} else {
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
}
foregroundView.setX(scrubberX);
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
backgroundView.setX(scrubberX);
backgroundView.setY(reactionBarBackgroundY);
verticalScrubBoundary.update(reactionBarBackgroundY,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
updateBoundsOnLayoutChanged();
revealAnimatorSet.start();
if (isWideLayout) {
float scrubberRight = scrubberX + scrubberWidth;
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
} else {
float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX();
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
}
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
conversationItem.animate()
.x(endX)
.y(endY)
.scaleX(endScale)
.scaleY(endScale)
.setDuration(revealDuration);
}
private float getReactionBarOffsetForTouch(float itemY,
float contextMenuTop,
float contextMenuPadding,
float reactionBarOffset,
int reactionBarHeight,
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
float messageTop)
{
float adjustedTouchY = itemY - statusBarHeight;
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
}
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
}
private void updateSystemUiOnShow(@NonNull Activity activity) {
Window window = activity.getWindow();
int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color);
originalStatusBarColor = window.getStatusBarColor();
WindowUtil.setStatusBarColor(window, barColor);
originalNavigationBarColor = window.getNavigationBarColor();
WindowUtil.setNavigationBarColor(window, barColor);
if (!ThemeUtil.isDarkTheme(getContext())) {
WindowUtil.clearLightStatusBar(window);
WindowUtil.clearLightNavigationBar(window);
}
}
public void hide() {
hideInternal(onHideListener);
}
public void hideForReactWithAny() {
hideInternal(onHideListener);
}
private void hideInternal(@Nullable OnHideListener onHideListener) {
overlayState = OverlayState.HIDDEN;
AnimatorSet animatorSet = newHideAnimatorSet();
hideAnimatorSet = animatorSet;
revealAnimatorSet.end();
animatorSet.start();
if (onHideListener != null) {
onHideListener.startHide();
}
if (selectedConversationModel.getFocusedView() != null) {
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
}
animatorSet.addListener(new AnimationCompleteListener() {
@Override public void onAnimationEnd(Animator animation) {
animatorSet.removeListener(this);
if (onHideListener != null) {
onHideListener.onHide();
}
}
});
if (contextMenu != null) {
contextMenu.dismiss();
}
}
public boolean isShowing() {
return overlayState != OverlayState.HIDDEN;
}
public @NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
updateBoundsOnLayoutChanged();
}
private void updateBoundsOnLayoutChanged() {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.right = getEnd(emojiViewGlobalRect);
segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length;
}
private int getStart(@NonNull Rect rect) {
if (ViewUtil.isLtr(this)) {
return rect.left;
} else {
return rect.right;
}
}
private int getEnd(@NonNull Rect rect) {
if (ViewUtil.isLtr(this)) {
return rect.right;
} else {
return rect.left;
}
}
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
if (!isShowing()) {
throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber.");
}
if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) {
return true;
}
if (overlayState == OverlayState.UNINITAILIZED) {
downIsOurs = false;
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
overlayState = OverlayState.DEADZONE;
}
if (overlayState == OverlayState.DEADZONE) {
float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX());
float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY());
if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) {
overlayState = OverlayState.SCRUB;
} else {
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
overlayState = OverlayState.TAP;
if (downIsOurs) {
handleUpEvent();
return true;
}
}
return MotionEvent.ACTION_MOVE == motionEvent.getAction();
}
}
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
selected = getSelectedIndexViaDownEvent(motionEvent);
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
overlayState = OverlayState.DEADZONE;
downIsOurs = true;
return true;
case MotionEvent.ACTION_MOVE:
selected = getSelectedIndexViaMoveEvent(motionEvent);
return true;
case MotionEvent.ACTION_UP:
handleUpEvent();
return downIsOurs;
case MotionEvent.ACTION_CANCEL:
hide();
return downIsOurs;
default:
return false;
}
}
private void setupSelectedEmoji() {
final List<String> emojis = recentEmojiPageModel.getEmoji();
for (int i = 0; i < emojiViews.length; i++) {
final EmojiImageView view = emojiViews[i];
view.setScaleX(1.0f);
view.setScaleY(1.0f);
view.setTranslationY(0);
boolean isAtCustomIndex = i == customEmojiIndex;
if (isAtCustomIndex) {
view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24));
view.setTag(null);
} else {
view.setImageEmoji(emojis.get(i));
}
}
}
private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) {
return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom));
}
private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) {
return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary);
}
private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) {
int selected = -1;
if (backgroundView.getVisibility() != View.VISIBLE) {
return selected;
}
for (int i = 0; i < emojiViews.length; i++) {
final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left;
horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize);
if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) {
selected = i;
}
}
if (this.selected != -1 && this.selected != selected) {
shrinkView(emojiViews[this.selected]);
}
if (this.selected != selected && selected != -1) {
growView(emojiViews[selected]);
}
return selected;
}
private void growView(@NonNull View view) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
view.animate()
.scaleY(1.5f)
.scaleX(1.5f)
.translationY(-selectedVerticalTranslation)
.setDuration(200)
.setInterpolator(INTERPOLATOR)
.start();
}
private void shrinkView(@NonNull View view) {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationY(0)
.setDuration(200)
.setInterpolator(INTERPOLATOR)
.start();
}
private void handleUpEvent() {
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
if (selected == customEmojiIndex) {
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
} else {
onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected));
}
} else {
hide();
}
}
public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) {
this.onReactionSelectedListener = onReactionSelectedListener;
}
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
}
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
this.onHideListener = onHideListener;
}
private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
return Stream.of(messageRecord.getReactions())
.filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext())))
.findFirst()
.map(ReactionRecord::getEmoji)
.orElse(null);
}
private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) {
List<ActionItem> items = new ArrayList<>();
// Prepare
boolean containsControlMessage = message.isUpdate();
boolean hasText = !message.getBody().isEmpty();
OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId());
Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId());
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
// Select message
items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT)));
// Reply
if (!message.isPending() && !message.isFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
// Copy message text
if (!containsControlMessage && hasText) {
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE)));
}
// Copy Session ID
if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) {
items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)));
}
// Delete message
if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey)) {
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), () -> handleActionItemClicked(Action.DELETE)));
}
// Ban user
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey)) {
items.add(new ActionItem(0, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER)));
}
// Ban and delete all
if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey)) {
items.add(new ActionItem(0, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL)));
}
// Message detail
if (message.isFailed()) {
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
}
// Resend
if (message.isFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
}
// Save media
if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) {
items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD)));
}
backgroundView.setVisibility(View.VISIBLE);
foregroundView.setVisibility(View.VISIBLE);
return items;
}
private void handleActionItemClicked(@NonNull Action action) {
hideInternal(new OnHideListener() {
@Override public void startHide() {
if (onHideListener != null) {
onHideListener.startHide();
}
}
@Override public void onHide() {
if (onHideListener != null) {
onHideListener.onHide();
}
if (onActionSelectedListener != null) {
onActionSelectedListener.onActionSelected(action);
}
}
});
}
private void initAnimators() {
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
List<Animator> reveals = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
backgroundRevealAnim.setTarget(backgroundView);
backgroundRevealAnim.setDuration(revealDuration);
backgroundRevealAnim.setStartDelay(revealOffset);
reveals.add(backgroundRevealAnim);
revealAnimatorSet.setInterpolator(INTERPOLATOR);
revealAnimatorSet.playTogether(reveals);
}
private @NonNull AnimatorSet newHideAnimatorSet() {
AnimatorSet set = new AnimatorSet();
set.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.GONE);
}
});
set.setInterpolator(INTERPOLATOR);
set.playTogether(newHideAnimators());
return set;
}
private @NonNull List<Animator> newHideAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
return anim;
})
.toList());
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
backgroundHideAnim.setTarget(backgroundView);
backgroundHideAnim.setDuration(duration);
animators.add(backgroundHideAnim);
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
itemScaleXAnim.setProperty(View.SCALE_X);
itemScaleXAnim.setFloatValues(1f);
itemScaleXAnim.setTarget(conversationItem);
itemScaleXAnim.setDuration(duration);
animators.add(itemScaleXAnim);
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
itemScaleYAnim.setProperty(View.SCALE_Y);
itemScaleYAnim.setFloatValues(1f);
itemScaleYAnim.setTarget(conversationItem);
itemScaleYAnim.setDuration(duration);
animators.add(itemScaleYAnim);
ObjectAnimator itemXAnim = new ObjectAnimator();
itemXAnim.setProperty(View.X);
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
itemXAnim.setTarget(conversationItem);
itemXAnim.setDuration(duration);
animators.add(itemXAnim);
ObjectAnimator itemYAnim = new ObjectAnimator();
itemYAnim.setProperty(View.Y);
itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight);
itemYAnim.setTarget(conversationItem);
itemYAnim.setDuration(duration);
animators.add(itemYAnim);
if (activity != null) {
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
statusBarAnim.setDuration(duration);
statusBarAnim.addUpdateListener(animation -> {
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(statusBarAnim);
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
navigationBarAnim.setDuration(duration);
navigationBarAnim.addUpdateListener(animation -> {
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(navigationBarAnim);
}
return animators;
}
public interface OnHideListener {
void startHide();
void onHide();
}
public interface OnReactionSelectedListener {
void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji);
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
}
public interface OnActionSelectedListener {
void onActionSelected(@NonNull Action action);
}
private static class Boundary {
private float min;
private float max;
Boundary() {}
Boundary(float min, float max) {
update(min, max);
}
private void update(float min, float max) {
this.min = min;
this.max = max;
}
public boolean contains(float value) {
if (min < max) {
return this.min < value && this.max > value;
} else {
return this.min > value && this.max < value;
}
}
}
private enum OverlayState {
HIDDEN,
UNINITAILIZED,
DEADZONE,
SCRUB,
TAP
}
public enum Action {
REPLY,
RESEND,
DOWNLOAD,
COPY_MESSAGE,
COPY_SESSION_ID,
VIEW_INFO,
SELECT,
DELETE,
BAN_USER,
BAN_AND_DELETE_ALL,
}
}

View File

@ -3,21 +3,29 @@ package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID
class ConversationViewModel(
val threadId: Long,
private val repository: ConversationRepository
val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationUiState())
@ -26,6 +34,18 @@ class ConversationViewModel(
val recipient: Recipient?
get() = repository.maybeGetRecipientForThreadId(threadId)
val openGroup: OpenGroup?
get() = storage.getOpenGroup(threadId)
val serverCapabilities: List<String>
get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf()
val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
}
init {
_uiState.update {
it.copy(isOxenHostedOpenGroup = repository.isOxenHostedOpenGroup(threadId))
@ -137,17 +157,19 @@ class ConversationViewModel(
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(threadId: Long): Factory
fun create(threadId: Long, edKeyPair: KeyPair?): Factory
}
@Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor(
@Assisted private val threadId: Long,
private val repository: ConversationRepository
@Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, repository) as T
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
}
}
}

View File

@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.conversation.v2;
import android.content.res.Resources;
import androidx.annotation.Dimension;
import androidx.annotation.Px;
/**
* Core utility for converting different dimensional values.
*/
public enum DimensionUnit {
PIXELS {
@Override
@Px
public float toPixels(@Px float pixels) {
return pixels;
}
@Override
@Dimension(unit = Dimension.DP)
public float toDp(@Px float pixels) {
return pixels / Resources.getSystem().getDisplayMetrics().density;
}
@Override
@Dimension(unit = Dimension.SP)
public float toSp(@Px float pixels) {
return pixels / Resources.getSystem().getDisplayMetrics().scaledDensity;
}
},
DP {
@Override
@Px
public float toPixels(@Dimension(unit = Dimension.DP) float dp) {
return dp * Resources.getSystem().getDisplayMetrics().density;
}
@Override
@Dimension(unit = Dimension.DP)
public float toDp(@Dimension(unit = Dimension.DP) float dp) {
return dp;
}
@Override
@Dimension(unit = Dimension.SP)
public float toSp(@Dimension(unit = Dimension.DP) float dp) {
return PIXELS.toSp(toPixels(dp));
}
},
SP {
@Override
@Px
public float toPixels(@Dimension(unit = Dimension.SP) float sp) {
return sp * Resources.getSystem().getDisplayMetrics().scaledDensity;
}
@Override
@Dimension(unit = Dimension.DP)
public float toDp(@Dimension(unit = Dimension.SP) float sp) {
return PIXELS.toDp(toPixels(sp));
}
@Override
@Dimension(unit = Dimension.SP)
public float toSp(@Dimension(unit = Dimension.SP) float sp) {
return sp;
}
};
public abstract float toPixels(float value);
public abstract float toDp(float value);
public abstract float toSp(float value);
}

View File

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

View File

@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.conversation.v2
import android.graphics.Bitmap
import android.view.View
/**
* Contains information on a single selected conversation item. This is used when transitioning
* between selected and unselected states.
*/
data class SelectedConversationModel(
val bitmap: Bitmap,
val bubbleX: Float,
val bubbleY: Float,
val bubbleWidth: Int,
val isOutgoing: Boolean,
val focusedView: View?,
)

View File

@ -0,0 +1,381 @@
/*
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.conversation.v2;
import android.annotation.TargetApi;
import android.app.ActivityManager;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.CharacterSets;
import com.google.android.mms.pdu_alt.EncodedStringValue;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.components.ComposeText;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class Util {
private static final String TAG = Log.tag(Util.class);
private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90);
public static <T> List<T> asList(T... elements) {
List<T> result = new LinkedList<>();
Collections.addAll(result, elements);
return result;
}
public static String join(String[] list, String delimiter) {
return join(Arrays.asList(list), delimiter);
}
public static <T> String join(Collection<T> list, String delimiter) {
StringBuilder result = new StringBuilder();
int i = 0;
for (T item : list) {
result.append(item);
if (++i < list.size())
result.append(delimiter);
}
return result.toString();
}
public static String join(long[] list, String delimeter) {
List<Long> boxed = new ArrayList<>(list.length);
for (int i = 0; i < list.length; i++) {
boxed.add(list[i]);
}
return join(boxed, delimeter);
}
@SafeVarargs
public static @NonNull <E> List<E> join(@NonNull List<E>... lists) {
int totalSize = Stream.of(lists).reduce(0, (sum, list) -> sum + list.size());
List<E> joined = new ArrayList<>(totalSize);
for (List<E> list : lists) {
joined.addAll(list);
}
return joined;
}
public static String join(List<Long> list, String delimeter) {
StringBuilder sb = new StringBuilder();
for (int j = 0; j < list.size(); j++) {
if (j != 0) sb.append(delimeter);
sb.append(list.get(j));
}
return sb.toString();
}
public static String rightPad(String value, int length) {
if (value.length() >= length) {
return value;
}
StringBuilder out = new StringBuilder(value);
while (out.length() < length) {
out.append(" ");
}
return out.toString();
}
public static boolean isEmpty(EncodedStringValue[] value) {
return value == null || value.length == 0;
}
public static boolean isEmpty(ComposeText value) {
return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed());
}
public static boolean isEmpty(Collection<?> collection) {
return collection == null || collection.isEmpty();
}
public static boolean isEmpty(@Nullable CharSequence charSequence) {
return charSequence == null || charSequence.length() == 0;
}
public static boolean hasItems(@Nullable Collection<?> collection) {
return collection != null && !collection.isEmpty();
}
public static <K, V> V getOrDefault(@NonNull Map<K, V> map, K key, V defaultValue) {
return map.containsKey(key) ? map.get(key) : defaultValue;
}
public static String getFirstNonEmpty(String... values) {
for (String value : values) {
if (!Util.isEmpty(value)) {
return value;
}
}
return "";
}
public static @NonNull String emptyIfNull(@Nullable String value) {
return value != null ? value : "";
}
public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) {
return value != null ? value : "";
}
public static CharSequence getBoldedString(String value) {
SpannableString spanned = new SpannableString(value);
spanned.setSpan(new StyleSpan(Typeface.BOLD), 0,
spanned.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spanned;
}
public static @NonNull String toIsoString(byte[] bytes) {
try {
return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1);
} catch (UnsupportedEncodingException e) {
throw new AssertionError("ISO_8859_1 must be supported!");
}
}
public static byte[] toIsoBytes(String isoString) {
try {
return isoString.getBytes(CharacterSets.MIMENAME_ISO_8859_1);
} catch (UnsupportedEncodingException e) {
throw new AssertionError("ISO_8859_1 must be supported!");
}
}
public static byte[] toUtf8Bytes(String utf8String) {
try {
return utf8String.getBytes(CharacterSets.MIMENAME_UTF_8);
} catch (UnsupportedEncodingException e) {
throw new AssertionError("UTF_8 must be supported!");
}
}
public static void wait(Object lock, long timeout) {
try {
lock.wait(timeout);
} catch (InterruptedException ie) {
throw new AssertionError(ie);
}
}
public static List<String> split(String source, String delimiter) {
List<String> results = new LinkedList<>();
if (TextUtils.isEmpty(source)) {
return results;
}
String[] elements = source.split(delimiter);
Collections.addAll(results, elements);
return results;
}
public static byte[][] split(byte[] input, int firstLength, int secondLength) {
byte[][] parts = new byte[2][];
parts[0] = new byte[firstLength];
System.arraycopy(input, 0, parts[0], 0, firstLength);
parts[1] = new byte[secondLength];
System.arraycopy(input, firstLength, parts[1], 0, secondLength);
return parts;
}
public static byte[] combine(byte[]... elements) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
for (byte[] element : elements) {
baos.write(element);
}
return baos.toByteArray();
} catch (IOException e) {
throw new AssertionError(e);
}
}
public static byte[] trim(byte[] input, int length) {
byte[] result = new byte[length];
System.arraycopy(input, 0, result, 0, result.length);
return result;
}
public static byte[] getSecretBytes(int size) {
return getSecretBytes(new SecureRandom(), size);
}
public static byte[] getSecretBytes(@NonNull SecureRandom secureRandom, int size) {
byte[] secret = new byte[size];
secureRandom.nextBytes(secret);
return secret;
}
public static <T> T getRandomElement(T[] elements) {
return elements[new SecureRandom().nextInt(elements.length)];
}
public static <T> T getRandomElement(List<T> 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));
}
public static int hashCode(@Nullable Object... objects) {
return Arrays.hashCode(objects);
}
public static @Nullable Uri uri(@Nullable String uri) {
if (uri == null) return null;
else return Uri.parse(uri);
}
@TargetApi(VERSION_CODES.KITKAT)
public static boolean isLowMemory(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) ||
activityManager.getLargeMemoryClass() <= 64;
}
public static int clamp(int value, int min, int max) {
return Math.min(Math.max(value, min), max);
}
public static long clamp(long value, long min, long max) {
return Math.min(Math.max(value, min), max);
}
public static float clamp(float value, float min, float max) {
return Math.min(Math.max(value, min), max);
}
/**
* Returns half of the difference between the given length, and the length when scaled by the
* given scale.
*/
public static float halfOffsetFromScale(int length, float scale) {
float scaledLength = length * scale;
return (length - scaledLength) / 2;
}
public static @Nullable String readTextFromClipboard(@NonNull Context context) {
{
ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboardManager.hasPrimaryClip() && clipboardManager.getPrimaryClip().getItemCount() > 0) {
return clipboardManager.getPrimaryClip().getItemAt(0).getText().toString();
} else {
return null;
}
}
}
public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) {
writeTextToClipboard(context, context.getString(R.string.app_name), text);
}
public static void writeTextToClipboard(@NonNull Context context, @NonNull String label, @NonNull String text) {
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(label, text);
clipboard.setPrimaryClip(clip);
}
public static int toIntExact(long value) {
if ((int)value != value) {
throw new ArithmeticException("integer overflow");
}
return (int)value;
}
public static boolean isEquals(@Nullable Long first, long second) {
return first != null && first == second;
}
@SafeVarargs
public static <T> List<T> concatenatedList(Collection <T>... items) {
final List<T> concat = new ArrayList<>(Stream.of(items).reduce(0, (sum, list) -> sum + list.size()));
for (Collection<T> list : items) {
concat.addAll(list);
}
return concat;
}
public static boolean isLong(String value) {
try {
Long.parseLong(value);
return true;
} catch (NumberFormatException e) {
return false;
}
}
public static int parseInt(String integer, int defaultValue) {
try {
return Integer.parseInt(integer);
} catch (NumberFormatException e) {
return defaultValue;
}
}
}

View File

@ -0,0 +1,380 @@
/**
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.conversation.v2;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.ViewTreeObserver;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.IdRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ContextThemeWrapper;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import androidx.lifecycle.Lifecycle;
import org.session.libsession.utilities.ServiceUtil;
import org.session.libsession.utilities.Stub;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.SettableFuture;
public final class ViewUtil {
private ViewUtil() {
}
public static void focusAndMoveCursorToEndAndOpenKeyboard(@NonNull EditText input) {
int numberLength = input.getText().length();
input.setSelection(numberLength, numberLength);
focusAndShowKeyboard(input);
}
public static void focusAndShowKeyboard(@NonNull View view) {
view.requestFocus();
if (view.hasWindowFocus()) {
showTheKeyboardNow(view);
} else {
view.getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
if (hasFocus) {
showTheKeyboardNow(view);
view.getViewTreeObserver().removeOnWindowFocusChangeListener(this);
}
}
});
}
}
private static void showTheKeyboardNow(@NonNull View view) {
if (view.isFocused()) {
view.post(() -> {
InputMethodManager inputMethodManager = ServiceUtil.getInputMethodManager(view.getContext());
inputMethodManager.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT);
});
}
}
@SuppressWarnings("unchecked")
public static <T extends View> T inflateStub(@NonNull View parent, @IdRes int stubId) {
return (T)((ViewStub)parent.findViewById(stubId)).inflate();
}
public static <T extends View> Stub<T> findStubById(@NonNull Activity parent, @IdRes int resId) {
return new Stub<>(parent.findViewById(resId));
}
public static <T extends View> Stub<T> findStubById(@NonNull View parent, @IdRes int resId) {
return new Stub<>(parent.findViewById(resId));
}
private static Animation getAlphaAnimation(float from, float to, int duration) {
final Animation anim = new AlphaAnimation(from, to);
anim.setInterpolator(new FastOutSlowInInterpolator());
anim.setDuration(duration);
return anim;
}
public static void fadeIn(final @NonNull View view, final int duration) {
animateIn(view, getAlphaAnimation(0f, 1f, duration));
}
public static ListenableFuture<Boolean> fadeOut(final @NonNull View view, final int duration) {
return fadeOut(view, duration, View.GONE);
}
public static ListenableFuture<Boolean> fadeOut(@NonNull View view, int duration, int visibility) {
return animateOut(view, getAlphaAnimation(1f, 0f, duration), visibility);
}
public static ListenableFuture<Boolean> animateOut(final @NonNull View view, final @NonNull Animation animation) {
return animateOut(view, animation, View.GONE);
}
public static ListenableFuture<Boolean> animateOut(final @NonNull View view, final @NonNull Animation animation, final int visibility) {
final SettableFuture future = new SettableFuture();
if (view.getVisibility() == visibility) {
future.set(true);
} else {
view.clearAnimation();
animation.reset();
animation.setStartTime(0);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {}
@Override
public void onAnimationRepeat(Animation animation) {}
@Override
public void onAnimationEnd(Animation animation) {
view.setVisibility(visibility);
future.set(true);
}
});
view.startAnimation(animation);
}
return future;
}
public static void animateIn(final @NonNull View view, final @NonNull Animation animation) {
if (view.getVisibility() == View.VISIBLE) return;
view.clearAnimation();
animation.reset();
animation.setStartTime(0);
view.setVisibility(View.VISIBLE);
view.startAnimation(animation);
}
@SuppressWarnings("unchecked")
public static <T extends View> T inflate(@NonNull LayoutInflater inflater,
@NonNull ViewGroup parent,
@LayoutRes int layoutResId)
{
return (T)(inflater.inflate(layoutResId, parent, false));
}
@SuppressLint("RtlHardcoded")
public static void setTextViewGravityStart(final @NonNull TextView textView, @NonNull Context context) {
if (isRtl(context)) {
textView.setGravity(Gravity.RIGHT);
} else {
textView.setGravity(Gravity.LEFT);
}
}
public static void mirrorIfRtl(View view, Context context) {
if (isRtl(context)) {
view.setScaleX(-1.0f);
}
}
public static boolean isLtr(@NonNull View view) {
return isLtr(view.getContext());
}
public static boolean isLtr(@NonNull Context context) {
return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR;
}
public static boolean isRtl(@NonNull View view) {
return isRtl(view.getContext());
}
public static boolean isRtl(@NonNull Context context) {
return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
}
public static float pxToDp(float px) {
return px / Resources.getSystem().getDisplayMetrics().density;
}
public static int dpToPx(Context context, int dp) {
return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5);
}
public static int dpToPx(int dp) {
return Math.round(dp * Resources.getSystem().getDisplayMetrics().density);
}
public static int dpToSp(int dp) {
return (int) (dpToPx(dp) / Resources.getSystem().getDisplayMetrics().scaledDensity);
}
public static int spToPx(float sp) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, Resources.getSystem().getDisplayMetrics());
}
public static void updateLayoutParams(@NonNull View view, int width, int height) {
view.getLayoutParams().width = width;
view.getLayoutParams().height = height;
view.requestLayout();
}
public static void updateLayoutParamsIfNonNull(@Nullable View view, int width, int height) {
if (view != null) {
updateLayoutParams(view, width, height);
}
}
public static void setVisibilityIfNonNull(@Nullable View view, int visibility) {
if (view != null) {
view.setVisibility(visibility);
}
}
public static int getLeftMargin(@NonNull View view) {
if (isLtr(view)) {
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin;
}
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin;
}
public static int getRightMargin(@NonNull View view) {
if (isLtr(view)) {
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin;
}
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin;
}
public static int getTopMargin(@NonNull View view) {
return ((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin;
}
public static void setLeftMargin(@NonNull View view, int margin) {
if (isLtr(view)) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
} else {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
}
view.forceLayout();
view.requestLayout();
}
public static void setRightMargin(@NonNull View view, int margin) {
if (isLtr(view)) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).rightMargin = margin;
} else {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).leftMargin = margin;
}
view.forceLayout();
view.requestLayout();
}
public static void setTopMargin(@NonNull View view, int margin) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).topMargin = margin;
view.requestLayout();
}
public static void setBottomMargin(@NonNull View view, int margin) {
((ViewGroup.MarginLayoutParams) view.getLayoutParams()).bottomMargin = margin;
view.requestLayout();
}
public static int getWidth(@NonNull View view) {
return view.getLayoutParams().width;
}
public static void setPaddingTop(@NonNull View view, int padding) {
view.setPadding(view.getPaddingLeft(), padding, view.getPaddingRight(), view.getPaddingBottom());
}
public static void setPaddingBottom(@NonNull View view, int padding) {
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), padding);
}
public static void setPadding(@NonNull View view, int padding) {
view.setPadding(padding, padding, padding, padding);
}
public static void setPaddingStart(@NonNull View view, int padding) {
if (isLtr(view)) {
view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom());
} else {
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), padding, view.getPaddingBottom());
}
}
public static void setPaddingEnd(@NonNull View view, int padding) {
if (isLtr(view)) {
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), padding, view.getPaddingBottom());
} else {
view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom());
}
}
public static boolean isPointInsideView(@NonNull View view, float x, float y) {
int[] location = new int[2];
view.getLocationOnScreen(location);
int viewX = location[0];
int viewY = location[1];
return x > viewX && x < viewX + view.getWidth() &&
y > viewY && y < viewY + view.getHeight();
}
public static int getStatusBarHeight(@NonNull View view) {
int result = 0;
int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0) {
result = view.getResources().getDimensionPixelSize(resourceId);
}
return result;
}
public static int getNavigationBarHeight(@NonNull View view) {
int result = 0;
int resourceId = view.getResources().getIdentifier("navigation_bar_height", "dimen", "android");
if (resourceId > 0) {
result = view.getResources().getDimensionPixelSize(resourceId);
}
return result;
}
public static void hideKeyboard(@NonNull Context context, @NonNull View view) {
InputMethodManager inputManager = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE);
inputManager.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
/**
* Enables or disables a view and all child views recursively.
*/
public static void setEnabledRecursive(@NonNull View view, boolean enabled) {
view.setEnabled(enabled);
if (view instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
setEnabledRecursive(viewGroup.getChildAt(i), enabled);
}
}
}
public static @Nullable Lifecycle getActivityLifecycle(@NonNull View view) {
return getActivityLifecycle(view.getContext());
}
private static @Nullable Lifecycle getActivityLifecycle(@Nullable Context context) {
if (context instanceof ContextThemeWrapper) {
return getActivityLifecycle(((ContextThemeWrapper) context).getBaseContext());
}
if (context instanceof AppCompatActivity) {
return ((AppCompatActivity) context).getLifecycle();
}
return null;
}
}

View File

@ -0,0 +1,98 @@
package org.thoughtcrime.securesms.conversation.v2;
import android.app.Activity;
import android.graphics.Rect;
import android.os.Build;
import android.view.View;
import android.view.Window;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.ThemeUtil;
public final class WindowUtil {
private WindowUtil() {
}
public static void setLightNavigationBarFromTheme(@NonNull Activity activity) {
if (Build.VERSION.SDK_INT < 27) return;
final boolean isLightNavigationBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightNavigationBar);
if (isLightNavigationBar) setLightNavigationBar(activity.getWindow());
else clearLightNavigationBar(activity.getWindow());
}
public static void clearLightNavigationBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 27) return;
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
}
public static void setLightNavigationBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 27) return;
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
}
public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) {
if (Build.VERSION.SDK_INT < 21) return;
window.setNavigationBarColor(color);
}
public static void setLightStatusBarFromTheme(@NonNull Activity activity) {
if (Build.VERSION.SDK_INT < 23) return;
final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar);
if (isLightStatusBar) setLightStatusBar(activity.getWindow());
else clearLightStatusBar(activity.getWindow());
}
public static void clearLightStatusBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 23) return;
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
public static void setLightStatusBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 23) return;
setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) {
if (Build.VERSION.SDK_INT < 21) return;
window.setStatusBarColor(color);
}
/**
* A sort of roundabout way of determining if the status bar is present by seeing if there's a
* vertical window offset.
*/
public static boolean isStatusBarPresent(@NonNull Window window) {
Rect rectangle = new Rect();
window.getDecorView().getWindowVisibleDisplayFrame(rectangle);
return rectangle.top > 0;
}
private static void clearSystemUiFlags(@NonNull Window window, int flags) {
View view = window.getDecorView();
int uiFlags = view.getSystemUiVisibility();
uiFlags &= ~flags;
view.setSystemUiVisibility(uiFlags);
}
private static void setSystemUiFlags(@NonNull Window window, int flags) {
View view = window.getDecorView();
int uiFlags = view.getSystemUiVisibility();
uiFlags |= flags;
view.setSystemUiVisibility(uiFlags);
}
}

View File

@ -31,7 +31,7 @@ class AlbumThumbnailView : FrameLayout {
private lateinit var binding: AlbumThumbnailViewBinding
companion object {
const val MAX_ALBUM_DISPLAY_SIZE = 5
const val MAX_ALBUM_DISPLAY_SIZE = 3
}
// region Lifecycle
@ -130,18 +130,13 @@ class AlbumThumbnailView : FrameLayout {
fun layoutRes(slideCount: Int) = when (slideCount) {
1 -> R.layout.album_thumbnail_1 // single
2 -> R.layout.album_thumbnail_2// two sidebyside
3 -> R.layout.album_thumbnail_3// three stacked
4 -> R.layout.album_thumbnail_4// four square
5 -> R.layout.album_thumbnail_5//
else -> R.layout.album_thumbnail_many// five or more
else -> R.layout.album_thumbnail_3 // three stacked with additional text
}
fun getThumbnailView(position: Int): KThumbnailView = when (position) {
0 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_1)
1 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_2)
2 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_3)
3 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_4)
4 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_5)
else -> throw Exception("Can't get thumbnail view for non-existent thumbnail at position: $position")
}

View File

@ -10,7 +10,6 @@ import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
@ -43,14 +42,6 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
fun userCanDeleteSelectedItems(): Boolean {
val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
// Remove this after the unsend request is enabled
if (!ConversationActivityV2.IS_UNSEND_REQUESTS_ENABLED) {
if (openGroup == null) { return true }
if (allSentByCurrentUser) { return true }
return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey, blindedPublicKey)
}
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser }
if (allSentByCurrentUser) { return true }
@ -115,6 +106,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
interface ConversationActionModeCallbackDelegate {
fun selectMessages(messages: Set<MessageRecord>)
fun deleteMessages(messages: Set<MessageRecord>)
fun banUser(messages: Set<MessageRecord>)
fun banAndDeleteAll(messages: Set<MessageRecord>)

View File

@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.conversation.v2.menus
import android.content.Context
import org.session.libsession.messaging.open_groups.OpenGroup
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.groups.OpenGroupManager
object ConversationMenuItemHelper {
@JvmStatic
fun userCanDeleteSelectedItems(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String): Boolean {
if (openGroup == null) return message.isOutgoing || !message.isOutgoing
if (message.isOutgoing) return true
return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey)
}
@JvmStatic
fun userCanBanSelectedUsers(context: Context, message: MessageRecord, openGroup: OpenGroup?, userPublicKey: String): Boolean {
if (openGroup == null) return false
if (message.isOutgoing) return false // Users can't ban themselves
return OpenGroupManager.isUserModerator(context, openGroup.groupId, userPublicKey)
}
}

View File

@ -0,0 +1,343 @@
package org.thoughtcrime.securesms.conversation.v2.messages;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.Group;
import androidx.core.content.ContextCompat;
import com.google.android.flexbox.FlexboxLayout;
import com.google.android.flexbox.JustifyContent;
import org.session.libsession.utilities.TextSecurePreferences;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.conversation.v2.ViewUtil;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.util.NumberUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import network.loki.messenger.R;
public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener {
// Normally 6dp, but we have 1dp left+right margin on the pills themselves
private final int OUTER_MARGIN = ViewUtil.dpToPx(2);
private static final int DEFAULT_THRESHOLD = 5;
private List<ReactionRecord> records;
private long messageId;
private ViewGroup container;
private Group showLess;
private VisibleMessageViewDelegate delegate;
private Handler gestureHandler = new Handler(Looper.getMainLooper());
private Runnable pressCallback;
private Runnable longPressCallback;
private long onDownTimestamp = 0;
private static long longPressDurationThreshold = 250;
private static long maxDoubleTapInterval = 200;
private boolean extended = false;
public EmojiReactionsView(Context context) {
super(context);
init(null);
}
public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.view_emoji_reactions, this);
this.container = findViewById(R.id.layout_emoji_container);
this.showLess = findViewById(R.id.group_show_less);
records = new ArrayList<>();
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0);
typedArray.recycle();
}
}
public void clear() {
this.records.clear();
container.removeAllViews();
}
public void setReactions(long messageId, @NonNull List<ReactionRecord> records, boolean outgoing, VisibleMessageViewDelegate delegate) {
this.delegate = delegate;
if (records.equals(this.records)) {
return;
}
FlexboxLayout containerLayout = (FlexboxLayout) this.container;
containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START);
this.records.clear();
this.records.addAll(records);
if (this.messageId != messageId) {
extended = false;
}
this.messageId = messageId;
displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
if (v.getTag() == null) return false;
Reaction reaction = (Reaction) v.getTag();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms));
else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback();
else if (action == MotionEvent.ACTION_UP) onUp(reaction);
return true;
}
private void displayReactions(int threshold) {
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
List<Reaction> reactions = buildSortedReactionsList(records, userPublicKey, threshold);
container.removeAllViews();
LinearLayout overflowContainer = new LinearLayout(getContext());
overflowContainer.setOrientation(LinearLayout.HORIZONTAL);
int innerPadding = ViewUtil.dpToPx(4);
overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding);
for (Reaction reaction : reactions) {
if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) {
if (overflowContainer.getParent() == null) {
container.addView(overflowContainer);
ViewGroup.LayoutParams overflowParams = overflowContainer.getLayoutParams();
overflowParams.height = ViewUtil.dpToPx(26);
overflowContainer.setLayoutParams(overflowParams);
overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_dialog_background));
}
View pill = buildPill(getContext(), this, reaction, true);
pill.setOnClickListener(v -> {
extended = true;
displayReactions(Integer.MAX_VALUE);
});
pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE);
pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE);
overflowContainer.addView(pill);
} else {
View pill = buildPill(getContext(), this, reaction, false);
pill.setTag(reaction);
pill.setOnTouchListener(this);
container.addView(pill);
int pixelSize = ViewUtil.dpToPx(1);
MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams();
params.setMargins(pixelSize, 0, pixelSize, 0);
pill.setLayoutParams(params);
}
}
int overflowChildren = overflowContainer.getChildCount();
int negativeMargin = ViewUtil.dpToPx(-8);
for (int i = 0; i < overflowChildren; i++) {
View child = overflowContainer.getChildAt(i);
MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams();
if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) {
// if first and there is more than one child, or we are not the last child then set negative right margin
childParams.setMargins(0,0, negativeMargin, 0);
child.setLayoutParams(childParams);
}
}
if (threshold == Integer.MAX_VALUE) {
showLess.setVisibility(VISIBLE);
for (int id : showLess.getReferencedIds()) {
findViewById(id).setOnClickListener(view -> {
extended = false;
displayReactions(DEFAULT_THRESHOLD);
});
}
} else {
showLess.setVisibility(GONE);
}
}
private void onReactionClicked(Reaction reaction) {
if (reaction.messageId != 0) {
MessageId messageId = new MessageId(reaction.messageId, reaction.isMms);
delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender);
}
}
private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records, String userPublicKey, int threshold) {
Map<String, Reaction> counters = new LinkedHashMap<>();
for (ReactionRecord record : records) {
String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji());
Reaction info = counters.get(baseEmoji);
if (info == null) {
info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
} else {
info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor()));
}
counters.put(baseEmoji, info);
}
List<Reaction> reactions = new ArrayList<>(counters.values());
Collections.sort(reactions, Collections.reverseOrder());
if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) {
List<Reaction> shortened = new ArrayList<>(threshold + 2);
shortened.addAll(reactions.subList(0, threshold + 2));
return shortened;
} else {
return reactions;
}
}
private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) {
View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false);
EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji);
TextView countView = root.findViewById(R.id.reactions_pill_count);
View spacer = root.findViewById(R.id.reactions_pill_spacer);
if (isCompact) {
root.setPaddingRelative(1,1,1,1);
ViewGroup.LayoutParams layoutParams = root.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
root.setLayoutParams(layoutParams);
}
if (reaction.emoji != null) {
emojiView.setImageEmoji(reaction.emoji);
if (reaction.count >= 1) {
countView.setText(NumberUtil.getFormattedNumber(reaction.count));
} else {
countView.setVisibility(GONE);
spacer.setVisibility(GONE);
}
} else {
emojiView.setVisibility(GONE);
spacer.setVisibility(GONE);
countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count));
}
if (reaction.userWasSender && !isCompact) {
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected));
countView.setTextColor(ContextCompat.getColor(context, R.color.reactions_pill_selected_text_color));
} else {
if (!isCompact) {
root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background));
}
}
return root;
}
private void onDown(MessageId messageId) {
removeLongPressCallback();
Runnable newLongPressCallback = () -> {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
if (delegate != null) {
delegate.onReactionLongClicked(messageId);
}
};
this.longPressCallback = newLongPressCallback;
gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold);
onDownTimestamp = new Date().getTime();
}
private void removeLongPressCallback() {
if (longPressCallback != null) {
gestureHandler.removeCallbacks(longPressCallback);
}
}
private void onUp(Reaction reaction) {
if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) {
removeLongPressCallback();
if (pressCallback != null) {
gestureHandler.removeCallbacks(pressCallback);
this.pressCallback = null;
} else {
Runnable newPressCallback = () -> {
onReactionClicked(reaction);
pressCallback = null;
};
this.pressCallback = newPressCallback;
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval);
}
}
}
private static class Reaction implements Comparable<Reaction> {
private final long messageId;
private final boolean isMms;
private String emoji;
private long count;
private long sortIndex;
private long lastSeen;
private boolean userWasSender;
Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) {
this.messageId = messageId;
this.isMms = isMms;
this.emoji = emoji;
this.count = count;
this.sortIndex = sortIndex;
this.lastSeen = lastSeen;
this.userWasSender = userWasSender;
}
void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) {
if (!this.userWasSender) {
if (userWasSender || lastSeen > this.lastSeen) {
this.emoji = emoji;
}
}
this.count = this.count + count;
this.lastSeen = Math.max(this.lastSeen, lastSeen);
this.userWasSender = this.userWasSender || userWasSender;
}
@NonNull Reaction merge(@NonNull Reaction other) {
this.count = this.count + other.count;
this.lastSeen = Math.max(this.lastSeen, other.lastSeen);
this.userWasSender = this.userWasSender || other.userWasSender;
return this;
}
@Override
public int compareTo(Reaction rhs) {
Reaction lhs = this;
if (lhs.count == rhs.count ) {
return Long.compare(lhs.sortIndex, rhs.sortIndex);
} else {
return Long.compare(lhs.count, rhs.count);
}
}
}
}

View File

@ -67,18 +67,11 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient,
isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long,
isOriginalMissing: Boolean, glide: GlideRequests) {
// Reduce the max body text view line count to 2 if this is a group thread because
// we'll be showing the author text view and we don't want the overall quote view height
// to get too big.
binding.quoteViewBodyTextView.maxLines = if (thread.isGroupRecipient) 2 else 3
// Author
if (thread.isGroupRecipient) {
val author = contactDb.getContactWithSessionID(authorPublicKey)
val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: "${authorPublicKey.take(4)}...${authorPublicKey.takeLast(4)}"
binding.quoteViewAuthorTextView.text = authorDisplayName
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
}
binding.quoteViewAuthorTextView.isVisible = thread.isGroupRecipient
val author = contactDb.getContactWithSessionID(authorPublicKey)
val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: "${authorPublicKey.take(4)}...${authorPublicKey.takeLast(4)}"
binding.quoteViewAuthorTextView.text = authorDisplayName
binding.quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage))
// Body
binding.quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context)
binding.quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage))

View File

@ -13,11 +13,11 @@ import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
@ -46,7 +46,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.toPx
import java.util.Locale
import kotlin.math.roundToInt
@ -54,7 +53,7 @@ class VisibleMessageContentView : LinearLayout {
private lateinit var binding: ViewVisibleMessageContentBinding
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
var onContentDoubleTap: (() -> Unit)? = null
var delegate: VisibleMessageContentViewDelegate? = null
var delegate: VisibleMessageViewDelegate? = null
var indexInAdapter: Int = -1
// region Lifecycle
@ -76,7 +75,7 @@ class VisibleMessageContentView : LinearLayout {
val color = ThemeUtil.getThemedColor(context, colorID)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN)
background.colorFilter = filter
setBackground(background)
binding.contentParent.background = background
val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
@ -88,7 +87,7 @@ class VisibleMessageContentView : LinearLayout {
if (message.isDeleted) {
binding.deletedMessageView.root.isVisible = true
binding.deletedMessageView.root.bind(message, VisibleMessageContentView.getTextColor(context,message))
binding.deletedMessageView.root.bind(message, getTextColor(context, message))
return
} else {
binding.deletedMessageView.root.isVisible = false
@ -101,10 +100,6 @@ class VisibleMessageContentView : LinearLayout {
binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty()
val linkPreviewLayout = binding.linkPreviewView.layoutParams
linkPreviewLayout.width = if (mediaThumbnailMessage) 0 else ViewGroup.LayoutParams.WRAP_CONTENT
binding.linkPreviewView.layoutParams = linkPreviewLayout
binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty()
binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null
binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null
@ -131,9 +126,7 @@ class VisibleMessageContentView : LinearLayout {
delegate?.scrollToMessageIfPossible(quote.id)
}
}
val layoutParams = binding.quoteView.root.layoutParams as MarginLayoutParams
val hasMedia = message.slideDeck.asAttachments().isNotEmpty()
binding.quoteView.root.minWidth = if (hasMedia) 0 else toPx(300,context.resources)
}
if (message is MmsMessageRecord) {
@ -156,70 +149,74 @@ class VisibleMessageContentView : LinearLayout {
}
}
if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) {
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) }
// Body text view is inside the link preview for layout convenience
} else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) {
hideBody = true
// Audio attachment
if (contactIsTrusted || message.isOutgoing) {
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
// We have to use onContentClick (rather than a click listener directly on the voice
// message view) so as to not interfere with all the other gestures.
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
} else {
// TODO: move this out to its own area
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
when {
message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) }
// Body text view is inside the link preview for layout convenience
}
} else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) {
hideBody = true
// Document attachment
if (contactIsTrusted || message.isOutgoing) {
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
} else {
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
hideBody = true
// Audio attachment
if (contactIsTrusted || message.isOutgoing) {
binding.voiceMessageView.root.indexInAdapter = indexInAdapter
binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
// We have to use onContentClick (rather than a click listener directly on the voice
// message view) so as to not interfere with all the other gestures.
onContentClick.add { binding.voiceMessageView.root.togglePlayback() }
onContentDoubleTap = { binding.voiceMessageView.root.handleDoubleTap() }
} else {
// TODO: move this out to its own area
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.AUDIO, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
}
}
} else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) {
/*
* Images / Video attachment
*/
if (contactIsTrusted || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind
binding.albumThumbnailView.bind(
message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
hideBody = true
// Document attachment
if (contactIsTrusted || message.isOutgoing) {
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
} else {
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
}
}
message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> {
/*
* Images / Video attachment
*/
if (contactIsTrusted || message.isOutgoing) {
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups
// bind after add view because views are inflated and calculated during bind
binding.albumThumbnailView.bind(
glideRequests = glide,
message = message,
isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster
)
onContentClick.add { event ->
binding.albumThumbnailView.calculateHitObject(event, message, thread)
)
val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.albumThumbnailView.layoutParams = layoutParams
onContentClick.add { event ->
binding.albumThumbnailView.calculateHitObject(event, message, thread)
}
} else {
hideBody = true
binding.albumThumbnailView.clearViews()
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
}
} else {
hideBody = true
binding.albumThumbnailView.clearViews()
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message))
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
}
} else if (message.isOpenGroupInvitation) {
hideBody = true
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
message.isOpenGroupInvitation -> {
hideBody = true
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
}
}
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
// set it to use constraints if not only a text message, otherwise wrap content to whatever width it wants
val params = binding.bodyTextView.layoutParams
params.width = if (onlyBodyMessage || binding.barrierViewsGone()) ViewGroup.LayoutParams.MATCH_PARENT else 0
binding.bodyTextView.layoutParams = params
if (message.body.isNotEmpty() && !hideBody) {
val color = getTextColor(context, message)
binding.bodyTextView.setTextColor(color)
@ -232,6 +229,9 @@ class VisibleMessageContentView : LinearLayout {
}
}
}
val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.contentParent.layoutParams = layoutParams
}
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
@ -318,8 +318,3 @@ class VisibleMessageContentView : LinearLayout {
}
// endregion
}
interface VisibleMessageContentViewDelegate {
fun scrollToMessageIfPossible(timestamp: Long)
}

View File

@ -18,18 +18,22 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ViewUtil
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
@ -45,6 +49,7 @@ import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.min
@ -56,6 +61,7 @@ class VisibleMessageView : LinearLayout {
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var mmsSmsDb: MmsSmsDatabase
@Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase
@ -80,7 +86,7 @@ class VisibleMessageView : LinearLayout {
var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null
var contentViewDelegate: VisibleMessageContentViewDelegate? = null
val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView }
companion object {
const val swipeToReplyThreshold = 64.0f // dp
@ -102,14 +108,21 @@ class VisibleMessageView : LinearLayout {
private fun initialize() {
isHapticFeedbackEnabled = true
setWillNotDraw(false)
binding.expirationTimerViewContainer.disableClipping()
binding.messageInnerContainer.disableClipping()
binding.messageContentView.disableClipping()
}
// endregion
// region Updating
fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?,
glide: GlideRequests, searchQuery: String?, contact: Contact?, senderSessionID: String,
fun bind(
message: MessageRecord,
previous: MessageRecord?,
next: MessageRecord?,
glide: GlideRequests,
searchQuery: String?,
contact: Contact?,
senderSessionID: String,
delegate: VisibleMessageViewDelegate?,
) {
val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
@ -129,9 +142,9 @@ class VisibleMessageView : LinearLayout {
else ViewUtil.dpToPx(context,2)
if (binding.profilePictureView.root.visibility == View.GONE) {
val expirationParams = binding.expirationTimerViewContainer.layoutParams as MarginLayoutParams
val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
expirationParams.bottomMargin = bottomMargin
binding.expirationTimerViewContainer.layoutParams = expirationParams
binding.messageInnerContainer.layoutParams = expirationParams
} else {
val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams
avatarLayoutParams.bottomMargin = bottomMargin
@ -168,18 +181,15 @@ class VisibleMessageView : LinearLayout {
binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator
}
}
binding.senderNameTextView.isVisible = isStartOfMessageCluster
val context =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(context) ?: senderSessionID
} else {
binding.senderNameTextView.visibility = View.GONE
}
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Date break
binding.dateBreakTextView.showDateBreak(message, previous)
// Timestamp
// binding.messageTimestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp)
// Set inter-message spacing
val showDateBreak = isStartOfMessageCluster || snIsSelected
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
binding.dateBreakTextView.isVisible = showDateBreak
// Message status indicator
val (iconID, iconColor) = getMessageStatusImage(message)
if (iconID != null) {
@ -198,7 +208,20 @@ class VisibleMessageView : LinearLayout {
}
// Expiration timer
updateExpirationTimer(message)
// Calculate max message bubble width
// Emoji Reactions
val emojiLayoutParams = binding.emojiReactionsView.layoutParams as ConstraintLayout.LayoutParams
emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.emojiReactionsView.layoutParams = emojiLayoutParams
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
if (message.reactions.isNotEmpty() &&
(capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase()))
) {
binding.emojiReactionsView.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
binding.emojiReactionsView.isVisible = true
} else {
binding.emojiReactionsView.isVisible = false
}
// Populate content view
binding.messageContentView.indexInAdapter = indexInAdapter
binding.messageContentView.bind(
@ -210,7 +233,7 @@ class VisibleMessageView : LinearLayout {
searchQuery,
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false)
)
binding.messageContentView.delegate = contentViewDelegate
binding.messageContentView.delegate = delegate
onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() }
}
@ -245,7 +268,7 @@ class VisibleMessageView : LinearLayout {
}
private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.expirationTimerViewContainer
val container = binding.messageInnerContainer
val content = binding.messageContentView
val expiration = binding.expirationTimerView
val spacing = binding.messageContentSpacing
@ -258,7 +281,7 @@ class VisibleMessageView : LinearLayout {
container.layoutParams = containerParams
if (message.expiresIn > 0 && !message.isPending) {
binding.expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme))
binding.expirationTimerView.isVisible = true
binding.expirationTimerView.isInvisible = false
binding.expirationTimerView.setPercentComplete(0.0f)
if (message.expireStarted > 0) {
binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn)
@ -281,7 +304,7 @@ class VisibleMessageView : LinearLayout {
binding.expirationTimerView.setPercentComplete(0.0f)
}
} else {
binding.expirationTimerView.isVisible = false
binding.expirationTimerView.isInvisible = true
}
container.requestLayout()
}
@ -295,15 +318,19 @@ class VisibleMessageView : LinearLayout {
}
override fun onDraw(canvas: Canvas) {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val iconSize = toPx(24, context.resources)
val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
val right = left + iconSize
val bottom = top + iconSize
swipeToReplyIconRect.left = left
swipeToReplyIconRect.top = top
swipeToReplyIconRect.right = right
swipeToReplyIconRect.bottom = bottom
if (translationX < 0 && !binding.expirationTimerView.isVisible) {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val threshold = swipeToReplyThreshold
val iconSize = toPx(24, context.resources)
val bottomVOffset = paddingBottom + binding.messageStatusImageView.height + (binding.messageContentView.height - iconSize) / 2
swipeToReplyIconRect.left = binding.messageContentView.right - binding.messageContentView.paddingEnd + spacing
swipeToReplyIconRect.top = height - bottomVOffset - iconSize
swipeToReplyIconRect.right = binding.messageContentView.right - binding.messageContentView.paddingEnd + iconSize + spacing
swipeToReplyIconRect.bottom = height - bottomVOffset
swipeToReplyIcon.bounds = swipeToReplyIconRect
swipeToReplyIcon.alpha = (255.0f * (min(abs(translationX), threshold) / threshold)).roundToInt()
} else {
@ -384,7 +411,7 @@ class VisibleMessageView : LinearLayout {
} else {
val newPressCallback = Runnable { onPress(event) }
this.pressCallback = newPressCallback
gestureHandler.postDelayed(newPressCallback, VisibleMessageView.maxDoubleTapInterval)
gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval)
}
}
resetPosition()

View File

@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.conversation.v2.messages
import org.thoughtcrime.securesms.database.model.MessageId
interface VisibleMessageViewDelegate {
fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int)
fun scrollToMessageIfPossible(timestamp: Long)
fun onReactionClicked(emoji: String, messageId: MessageId, userWasSender: Boolean)
fun onReactionLongClicked(messageId: MessageId)
}

View File

@ -35,7 +35,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
private var progress = 0.0
private var duration = 0L
private var player: AudioSlidePlayer? = null
var delegate: VoiceMessageViewDelegate? = null
var delegate: VisibleMessageViewDelegate? = null
var indexInAdapter = -1
// region Lifecycle
@ -141,8 +141,3 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
}
// endregion
}
interface VoiceMessageViewDelegate {
fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int)
}

View File

@ -74,6 +74,8 @@ import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
@ -674,7 +676,7 @@ public class AttachmentDatabase extends Database {
return new LinkedList<>();
}
List<DatabaseAttachment> result = new LinkedList<>();
Set<DatabaseAttachment> result = new TreeSet<>((o1, o2) -> o1.getAttachmentId().equals(o2.getAttachmentId()) ? 0 : 1);
JSONArray array = new JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(ATTACHMENT_JSON_ALIAS)));
for (int i=0;i<array.length();i++) {
@ -703,7 +705,7 @@ public class AttachmentDatabase extends Database {
}
}
return result;
return new ArrayList<>(result);
} else {
int urlIndex = cursor.getColumnIndex(URL);
return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),

View File

@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import androidx.core.content.contentValuesOf
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.EmojiSearchData
import org.thoughtcrime.securesms.util.CursorUtil
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Contains all info necessary for full-text search of emoji tags.
*/
class EmojiSearchDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
companion object {
const val TABLE_NAME = "emoji_search"
const val LABEL = "label"
const val EMOJI = "emoji"
const val CREATE_EMOJI_SEARCH_TABLE_COMMAND = "CREATE VIRTUAL TABLE $TABLE_NAME USING fts5($LABEL, $EMOJI UNINDEXED)"
}
/**
* @param query A search query. Doesn't need any special formatted -- it'll be sanitized.
* @return A list of emoji that are related to the search term, ordered by relevance.
*/
fun query(originalQuery: String, originalLimit: Int): List<String> {
val query: String = originalQuery.trim()
if (query.isEmpty()) {
return emptyList()
}
val limit: Int = max(originalLimit, 100)
val entries = mutableListOf<Entry>()
readableDatabase.query(TABLE_NAME, arrayOf(LABEL, EMOJI), "$LABEL LIKE ?", arrayOf("%$query%"), null, null, null, "$limit")
.use { cursor ->
while (cursor.moveToNext()) {
entries += Entry(
label = CursorUtil.requireString(cursor, LABEL),
emoji = CursorUtil.requireString(cursor, EMOJI)
)
}
}
return entries
.sortedWith { lhs, rhs ->
similarityScore(query, lhs.label) - similarityScore(query, rhs.label)
}
.distinctBy { it.emoji }
.take(originalLimit)
.map { it.emoji }
}
/**
* Deletes the content of the current search index and replaces it with the new one.
*/
fun setSearchIndex(searchIndex: List<EmojiSearchData>) {
writableDatabase.beginTransaction()
writableDatabase.delete(TABLE_NAME, null, null)
for (searchData in searchIndex) {
for (label in searchData.tags) {
val values = contentValuesOf(
LABEL to label,
EMOJI to searchData.emoji
)
writableDatabase.insert(TABLE_NAME, null, values)
}
}
writableDatabase.setTransactionSuccessful()
writableDatabase.endTransaction()
}
/**
* Ranks how "similar" a match is to the original search term.
* A lower score means more similar, with 0 being a perfect match.
*
* We know that the `searchTerm` must be a substring of the `match`.
* We determine similarity by how many letters appear before or after the `searchTerm` in the `match`.
* We give letters that come before the term a bigger weight than those that come after as a way to prefer matches that are prefixed by the `searchTerm`.
*/
private fun similarityScore(searchTerm: String, match: String): Int {
if (searchTerm == match) {
return 0
}
val startIndex = match.indexOf(searchTerm)
val prefixCount = startIndex
val suffixCount = match.length - (startIndex + searchTerm.length)
val prefixRankWeight = 1.5f
val suffixRankWeight = 1f
return ((prefixCount * prefixRankWeight) + (suffixCount * suffixRankWeight)).roundToInt()
}
private data class Entry(val label: String, val emoji: String)
}

View File

@ -13,6 +13,8 @@ import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.IdentityKeyMismatchList;
import org.session.libsignal.crypto.IdentityKey;
import org.session.libsignal.utilities.JsonUtil;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@ -43,6 +45,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void updateThreadId(long fromId, long toId);
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,
@ -63,6 +67,30 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
}
}
void updateReactionsUnread(SQLiteDatabase db, long messageId, boolean hasReactions, boolean isRemoval) {
try {
MessageRecord message = getMessageRecord(messageId);
ContentValues values = new ContentValues();
if (!hasReactions) {
values.put(REACTIONS_UNREAD, 0);
} else if (!isRemoval) {
values.put(REACTIONS_UNREAD, 1);
}
if (message.isOutgoing() && hasReactions) {
values.put(NOTIFIED, 0);
}
if (values.size() > 0) {
db.update(getTableName(), values, ID_WHERE, SqlUtil.buildArgs(messageId));
}
notifyConversationListeners(message.getThreadId());
} catch (NoSuchMessageException e) {
Log.w(TAG, "Failed to find message " + messageId);
}
}
protected <D extends Document<I>, I> void removeFromDocument(long messageId, String column, I object, Class<D> clazz) throws IOException {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.beginTransaction();

View File

@ -19,7 +19,6 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.text.TextUtils
import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.NotificationInd
import com.google.android.mms.pdu_alt.PduHeaders
@ -56,13 +55,10 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils.queue
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.NoSuchMessageException
import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
@ -269,9 +265,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
val database = databaseHelper.readableDatabase
return database.rawQuery(
"SELECT " + MMS_PROJECTION.joinToString(",")+
" FROM " + TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME +
" ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
"SELECT " + MMS_PROJECTION.joinToString(",") + " FROM " + TABLE_NAME +
" LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
" LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1)" +
" WHERE " + where + " GROUP BY " + TABLE_NAME + "." + ID, arguments
)
}
@ -403,7 +399,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> {
return setMessagesRead(
THREAD_ID + " = ? AND " + READ + " = 0",
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)",
arrayOf(threadId.toString())
)
}
@ -442,6 +438,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val contentValues = ContentValues()
contentValues.put(READ, 1)
contentValues.put(REACTIONS_UNREAD, 0)
database.update(TABLE_NAME, contentValues, where, arguments)
database.setTransactionSuccessful()
} finally {
@ -479,7 +476,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID))
val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR))
val quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY))
val quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)) // TODO: this should be the referenced quote
val quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1
val quoteAttachments = associatedAttachments
.filter { obj: DatabaseAttachment -> obj.isQuote }
@ -498,16 +495,16 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
var networkFailures: List<NetworkFailure?>? = LinkedList()
var mismatches: List<IdentityKeyMismatch?>? = LinkedList()
var quote: QuoteModel? = null
if (quoteId > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachments.isNotEmpty())) {
if (quoteId > 0 && (!quoteText.isNullOrEmpty() || quoteAttachments.isNotEmpty())) {
quote = QuoteModel(
quoteId,
fromSerialized(quoteAuthor),
quoteText,
quoteText, // TODO: refactor this to use referenced quote
quoteMissing,
quoteAttachments
)
}
if (!TextUtils.isEmpty(mismatchDocument)) {
if (!mismatchDocument.isNullOrEmpty()) {
try {
mismatches = JsonUtil.fromJson(
mismatchDocument,
@ -517,7 +514,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
Log.w(TAG, e)
}
}
if (!TextUtils.isEmpty(networkDocument)) {
if (!networkDocument.isNullOrEmpty()) {
try {
networkFailures =
JsonUtil.fromJson(networkDocument, NetworkFailureList::class.java).list
@ -554,7 +551,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
attachments: List<DatabaseAttachment>
): List<Contact> {
val serializedContacts = cursor.getString(cursor.getColumnIndexOrThrow(SHARED_CONTACTS))
if (TextUtils.isEmpty(serializedContacts)) {
if (serializedContacts.isNullOrEmpty()) {
return emptyList()
}
val attachmentIdMap: MutableMap<AttachmentId?, DatabaseAttachment> = HashMap()
@ -592,7 +589,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
attachments: List<DatabaseAttachment>
): List<LinkPreview> {
val serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS))
if (TextUtils.isEmpty(serializedPreviews)) {
if (serializedPreviews.isNullOrEmpty()) {
return emptyList()
}
val attachmentIdMap: MutableMap<AttachmentId?, DatabaseAttachment> = HashMap()
@ -669,7 +666,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
var quoteAttachments: List<Attachment?>? = LinkedList()
if (retrieved.quote != null) {
contentValues.put(QUOTE_ID, retrieved.quote.id)
contentValues.put(QUOTE_BODY, retrieved.quote.text)
contentValues.put(QUOTE_AUTHOR, retrieved.quote.author.serialize())
contentValues.put(QUOTE_MISSING, if (retrieved.quote.missing) 1 else 0)
quoteAttachments = retrieved.quote.attachments
@ -816,7 +812,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
if (message.outgoingQuote != null) {
contentValues.put(QUOTE_ID, message.outgoingQuote!!.id)
contentValues.put(QUOTE_AUTHOR, message.outgoingQuote!!.author.serialize())
contentValues.put(QUOTE_BODY, message.outgoingQuote!!.text)
contentValues.put(QUOTE_MISSING, if (message.outgoingQuote!!.missing) 1 else 0)
quoteAttachments.addAll(message.outgoingQuote!!.attachments!!)
}
@ -901,7 +896,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val serializedContacts =
getSerializedSharedContacts(insertedAttachments, sharedContacts)
val serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews)
if (!TextUtils.isEmpty(serializedContacts)) {
if (!serializedContacts.isNullOrEmpty()) {
val contactValues = ContentValues()
contactValues.put(SHARED_CONTACTS, serializedContacts)
val database = databaseHelper.readableDatabase
@ -915,7 +910,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
Log.w(TAG, "Failed to update message with shared contact data.")
}
}
if (!TextUtils.isEmpty(serializedPreviews)) {
if (!serializedPreviews.isNullOrEmpty()) {
val contactValues = ContentValues()
contactValues.put(LINK_PREVIEWS, serializedPreviews)
val database = databaseHelper.readableDatabase
@ -949,31 +944,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val query = queryBuilder.toString()
val db = databaseHelper.writableDatabase
val values = ContentValues(3)
val values = ContentValues(2)
values.put(QUOTE_MISSING, 1)
values.put(QUOTE_BODY, "")
values.put(QUOTE_AUTHOR, "")
db!!.update(TABLE_NAME, values, query, null)
}
fun deleteQuotedFromMessages(toDeleteRecord: MessageRecord?) {
if (toDeleteRecord == null) {
return
}
val query = "$THREAD_ID = ?"
rawQuery(query, arrayOf(toDeleteRecord.threadId.toString())).use { threadMmsCursor ->
val reader = readerFor(threadMmsCursor)
var messageRecord: MmsMessageRecord? = reader.next as MmsMessageRecord?
while (messageRecord != null) {
if (messageRecord.quote != null && toDeleteRecord.dateSent == messageRecord.quote?.id) {
setQuoteMissing(messageRecord.id)
}
messageRecord = reader.next as MmsMessageRecord?
}
reader.close()
}
}
/**
* Delete all the messages in single queries where possible
* @param messageIds a String array representation of regularly Long types representing message IDs
@ -997,13 +973,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessages(messageIds)
val toDeleteList: MutableList<MessageRecord> = ArrayList()
getMessages(idsAsString).use { messageCursor ->
while (messageCursor.moveToNext()) {
toDeleteList.add(readerFor(messageCursor).current)
}
}
deleteQuotedFromMessages(toDeleteList)
val database = databaseHelper.writableDatabase
database.delete(TABLE_NAME, idsAsString, null)
notifyConversationListListeners()
@ -1017,13 +986,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
val groupReceiptDatabase = get(context).groupReceiptDatabase()
groupReceiptDatabase.deleteRowsForMessage(messageId)
var toDelete: MessageRecord?
getMessage(messageId).use { messageCursor ->
toDelete = readerFor(messageCursor).next
}
deleteQuotedFromMessages(toDelete)
val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf<String>(messageId.toString()))
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false)
notifyConversationListeners(threadId)
notifyStickerListeners()
@ -1041,6 +1005,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyConversationListListeners()
}
@Throws(NoSuchMessageException::class)
override fun getMessageRecord(messageId: Long): MessageRecord {
rawQuery(RAW_ID_WHERE, arrayOf("$messageId")).use { cursor ->
return Reader(cursor).next ?: throw NoSuchMessageException("No message for ID: $messageId")
}
}
fun deleteThread(threadId: Long) {
deleteThreads(setOf(threadId))
}
@ -1297,11 +1268,11 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
if (message.outgoingQuote != null) Quote(
message.outgoingQuote!!.id,
message.outgoingQuote!!.author,
message.outgoingQuote!!.text,
message.outgoingQuote!!.text, // TODO: use the referenced message's content
message.outgoingQuote!!.missing,
SlideDeck(context, message.outgoingQuote!!.attachments!!)
) else null,
message.sharedContacts, message.linkPreviews, false
message.sharedContacts, message.linkPreviews, listOf(), false
)
}
@ -1350,11 +1321,11 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
var contentLocationBytes: ByteArray? = null
var transactionIdBytes: ByteArray? = null
if (!TextUtils.isEmpty(contentLocation)) contentLocationBytes = toIsoBytes(
contentLocation!!
if (!contentLocation.isNullOrEmpty()) contentLocationBytes = toIsoBytes(
contentLocation
)
if (!TextUtils.isEmpty(transactionId)) transactionIdBytes = toIsoBytes(
transactionId!!
if (!transactionId.isNullOrEmpty()) transactionIdBytes = toIsoBytes(
transactionId
)
val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize))
return NotificationMmsMessageRecord(
@ -1426,26 +1397,27 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
.toList()
)
val quote = getQuote(cursor)
val reactions = get(context).reactionDatabase().getReactions(cursor)
return MediaMmsMessageRecord(
id, recipient, recipient,
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
threadId, body, slideDeck!!, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted,
readReceiptCount, quote, contacts, previews, unidentified
readReceiptCount, quote, contacts, previews, reactions, unidentified
)
}
private fun getRecipientFor(serialized: String?): Recipient {
val address: Address = if (TextUtils.isEmpty(serialized) || "insert-address-token" == serialized) {
val address: Address = if (serialized.isNullOrEmpty() || "insert-address-token" == serialized) {
UNKNOWN
} else {
fromSerialized(serialized!!)
fromSerialized(serialized)
}
return Recipient.from(context, address, true)
}
private fun getMismatchedIdentities(document: String?): List<IdentityKeyMismatch?>? {
if (!TextUtils.isEmpty(document)) {
if (!document.isNullOrEmpty()) {
try {
return JsonUtil.fromJson(document, IdentityKeyMismatchList::class.java).list
} catch (e: IOException) {
@ -1456,7 +1428,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
private fun getFailures(document: String?): List<NetworkFailure?>? {
if (!TextUtils.isEmpty(document)) {
if (!document.isNullOrEmpty()) {
try {
return JsonUtil.fromJson(document, NetworkFailureList::class.java).list
} catch (ioe: IOException) {
@ -1476,24 +1448,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
private fun getQuote(cursor: Cursor): Quote? {
val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID))
val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR))
val quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY))
val quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1
if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null
val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor)
val quoteText = retrievedQuote?.body
val quoteMissing = retrievedQuote == null
val attachments = get(context).attachmentDatabase().getAttachment(cursor)
val quoteAttachments: List<Attachment?>? =
Stream.of(attachments).filter { obj: DatabaseAttachment? -> obj!!.isQuote }
.toList()
val quoteDeck = SlideDeck(context, quoteAttachments!!)
return if (quoteId > 0 && !TextUtils.isEmpty(quoteAuthor)) {
Quote(
quoteId,
fromExternal(context, quoteAuthor),
quoteText,
quoteMissing,
quoteDeck
)
} else {
null
}
return Quote(
quoteId,
fromExternal(context, quoteAuthor),
quoteText,
quoteMissing,
quoteDeck
)
}
override fun close() {
@ -1608,10 +1578,23 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
"'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " +
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID +
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
"json_group_array(json_object(" +
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
"'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " +
"'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " +
"'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " +
"'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " +
"'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " +
"'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " +
"'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " +
"'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED +
")) AS " + ReactionDatabase.REACTION_JSON_ALIAS
)
private const val RAW_ID_WHERE: String = "$TABLE_NAME._id = ?"
private const val RAW_ID_IN: String = "$TABLE_NAME._id IN (?)"
const val createMessageRequestResponseCommand: String = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;"
const val CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;"
const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;"
const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;"
}
}

View File

@ -21,6 +21,8 @@ public interface MmsSmsColumns {
public static final String NOTIFIED = "notified";
public static final String UNIDENTIFIED = "unidentified";
public static final String MESSAGE_REQUEST_RESPONSE = "message_request_response";
public static final String REACTIONS_UNREAD = "reactions_unread";
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
public static class Types {
protected static final long TOTAL_MASK = 0xFFFFFFFF;

View File

@ -74,7 +74,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS};
MmsDatabase.LINK_PREVIEWS,
ReactionDatabase.REACTION_JSON_ALIAS};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
@ -145,7 +146,7 @@ public class MmsSmsDatabase extends Database {
public Cursor getUnread() {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC";
String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0";
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";
return queryTables(PROJECTION, selection, order, null);
}
@ -219,6 +220,18 @@ public class MmsSmsDatabase extends Database {
}
private Cursor queryTables(String[] projection, String selection, String order, String limit) {
String reactionsColumn = "json_group_array(json_object(" +
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
"'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " +
"'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " +
"'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " +
"'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " +
"'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " +
"'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " +
"'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " +
"'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED +
")) AS " + ReactionDatabase.REACTION_JSON_ALIAS;
String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID,
@ -248,6 +261,7 @@ public class MmsSmsDatabase extends Database {
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID +
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
reactionsColumn,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
@ -274,6 +288,7 @@ public class MmsSmsDatabase extends Database {
+ " || '::' || " + SmsDatabase.DATE_SENT
+ " AS " + MmsSmsColumns.UNIQUE_ROW_ID,
"NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
reactionsColumn,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
@ -299,10 +314,14 @@ public class MmsSmsDatabase extends Database {
mmsQueryBuilder.setDistinct(true);
smsQueryBuilder.setDistinct(true);
smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME);
mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " +
AttachmentDatabase.TABLE_NAME +
" ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID);
smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME +
" LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
" ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0");
mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME +
" LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME +
" ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID +
" LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
" ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1");
Set<String> mmsColumnsPresent = new HashSet<>();
@ -362,6 +381,16 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
mmsColumnsPresent.add(ReactionDatabase.MESSAGE_ID);
mmsColumnsPresent.add(ReactionDatabase.IS_MMS);
mmsColumnsPresent.add(ReactionDatabase.AUTHOR_ID);
mmsColumnsPresent.add(ReactionDatabase.EMOJI);
mmsColumnsPresent.add(ReactionDatabase.SERVER_ID);
mmsColumnsPresent.add(ReactionDatabase.COUNT);
mmsColumnsPresent.add(ReactionDatabase.SORT_ID);
mmsColumnsPresent.add(ReactionDatabase.DATE_SENT);
mmsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED);
mmsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS);
Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID);
@ -383,11 +412,22 @@ public class MmsSmsDatabase extends Database {
smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED);
smsColumnsPresent.add(SmsDatabase.STATUS);
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
smsColumnsPresent.add(ReactionDatabase.ROW_ID);
smsColumnsPresent.add(ReactionDatabase.MESSAGE_ID);
smsColumnsPresent.add(ReactionDatabase.IS_MMS);
smsColumnsPresent.add(ReactionDatabase.AUTHOR_ID);
smsColumnsPresent.add(ReactionDatabase.EMOJI);
smsColumnsPresent.add(ReactionDatabase.SERVER_ID);
smsColumnsPresent.add(ReactionDatabase.COUNT);
smsColumnsPresent.add(ReactionDatabase.SORT_ID);
smsColumnsPresent.add(ReactionDatabase.DATE_SENT);
smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED);
smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS);
@SuppressWarnings("deprecation")
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null);
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null);
@SuppressWarnings("deprecation")
String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 4, SMS_TRANSPORT, selection, null, null, null);
String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 5, SMS_TRANSPORT, selection, null, SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, null);
SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder();
String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit);

View File

@ -0,0 +1,258 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.json.JSONArray
import org.json.JSONException
import org.session.libsignal.utilities.JsonUtil.SaneJSONObject
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.CursorUtil
/**
* Store reactions on messages.
*/
class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
companion object {
const val TABLE_NAME = "reaction"
const val REACTION_JSON_ALIAS = "reaction_json"
const val ROW_ID = "reaction_id"
const val MESSAGE_ID = "message_id"
const val IS_MMS = "is_mms"
const val AUTHOR_ID = "author_id"
const val SERVER_ID = "server_id"
const val COUNT = "count"
const val SORT_ID = "sort_id"
const val EMOJI = "emoji"
const val DATE_SENT = "reaction_date_sent"
const val DATE_RECEIVED = "reaction_date_received"
@JvmField
val CREATE_REACTION_TABLE_COMMAND = """
CREATE TABLE $TABLE_NAME (
$ROW_ID INTEGER PRIMARY KEY,
$MESSAGE_ID INTEGER NOT NULL,
$IS_MMS INTEGER NOT NULL,
$AUTHOR_ID INTEGER NOT NULL REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}) ON DELETE CASCADE,
$EMOJI TEXT NOT NULL,
$SERVER_ID TEXT NOT NULL,
$COUNT INTEGER NOT NULL,
$SORT_ID INTEGER NOT NULL,
$DATE_SENT INTEGER NOT NULL,
$DATE_RECEIVED INTEGER NOT NULL,
UNIQUE($MESSAGE_ID, $IS_MMS, $EMOJI, $AUTHOR_ID) ON CONFLICT REPLACE
)
""".trimIndent()
@JvmField
val CREATE_REACTION_TRIGGERS = arrayOf(
"""
CREATE TRIGGER reactions_sms_delete AFTER DELETE ON ${SmsDatabase.TABLE_NAME}
BEGIN
DELETE FROM $TABLE_NAME WHERE $MESSAGE_ID = old.${MmsSmsColumns.ID} AND $IS_MMS = 0;
END
""",
"""
CREATE TRIGGER reactions_mms_delete AFTER DELETE ON ${MmsDatabase.TABLE_NAME}
BEGIN
DELETE FROM $TABLE_NAME WHERE $MESSAGE_ID = old.${MmsSmsColumns.ID} AND $IS_MMS = 1;
END
"""
)
private fun readReaction(cursor: Cursor): ReactionRecord {
return ReactionRecord(
messageId = CursorUtil.requireLong(cursor, MESSAGE_ID),
isMms = CursorUtil.requireInt(cursor, IS_MMS) == 1,
emoji = CursorUtil.requireString(cursor, EMOJI),
author = CursorUtil.requireString(cursor, AUTHOR_ID),
serverId = CursorUtil.requireString(cursor, SERVER_ID),
count = CursorUtil.requireLong(cursor, COUNT),
sortId = CursorUtil.requireLong(cursor, SORT_ID),
dateSent = CursorUtil.requireLong(cursor, DATE_SENT),
dateReceived = CursorUtil.requireLong(cursor, DATE_RECEIVED)
)
}
}
fun getReactions(messageId: MessageId): List<ReactionRecord> {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ? ORDER BY $SORT_ID"
val args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}")
val reactions: MutableList<ReactionRecord> = mutableListOf()
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
reactions += readReaction(cursor)
}
}
return reactions
}
fun addReaction(messageId: MessageId, reaction: ReactionRecord) {
writableDatabase.beginTransaction()
try {
val values = ContentValues().apply {
put(MESSAGE_ID, messageId.id)
put(IS_MMS, if (messageId.mms) 1 else 0)
put(EMOJI, reaction.emoji)
put(AUTHOR_ID, reaction.author)
put(SERVER_ID, reaction.serverId)
put(COUNT, reaction.count)
put(SORT_ID, reaction.sortId)
put(DATE_SENT, reaction.dateSent)
put(DATE_RECEIVED, reaction.dateReceived)
}
writableDatabase.insert(TABLE_NAME, null, values)
if (messageId.mms) {
DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false)
} else {
DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), false)
}
writableDatabase.setTransactionSuccessful()
} finally {
writableDatabase.endTransaction()
}
}
fun deleteReaction(emoji: String, messageId: MessageId, author: String) {
deleteReactions(
messageId = messageId,
query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ? AND $AUTHOR_ID = ?",
args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}", emoji, author)
)
}
fun deleteEmojiReactions(emoji: String, messageId: MessageId) {
deleteReactions(
messageId = messageId,
query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $EMOJI = ?",
args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}", emoji)
)
}
fun deleteMessageReactions(messageId: MessageId) {
deleteReactions(
messageId = messageId,
query = "$MESSAGE_ID = ? AND $IS_MMS = ?",
args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}")
)
}
private fun deleteReactions(messageId: MessageId, query: String, args: Array<String>) {
writableDatabase.beginTransaction()
try {
writableDatabase.delete(TABLE_NAME, query, args)
if (messageId.mms) {
DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true)
} else {
DatabaseComponent.get(context).smsDatabase().updateReactionsUnread(writableDatabase, messageId.id, hasReactions(messageId), true)
}
writableDatabase.setTransactionSuccessful()
} finally {
writableDatabase.endTransaction()
}
}
private fun hasReactions(messageId: MessageId): Boolean {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ?"
val args = arrayOf("${messageId.id}", "${if (messageId.mms) 1 else 0}")
readableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
return cursor.moveToFirst()
}
}
fun getReactions(cursor: Cursor): List<ReactionRecord> {
return try {
if (cursor.getColumnIndex(REACTION_JSON_ALIAS) != -1) {
if (cursor.isNull(cursor.getColumnIndexOrThrow(REACTION_JSON_ALIAS))) {
return listOf()
}
val result = mutableSetOf<ReactionRecord>()
val array = JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(REACTION_JSON_ALIAS)))
for (i in 0 until array.length()) {
val `object` = SaneJSONObject(array.getJSONObject(i))
if (!`object`.isNull(ROW_ID)) {
result.add(
ReactionRecord(
`object`.getLong(ROW_ID),
`object`.getLong(MESSAGE_ID),
`object`.getInt(IS_MMS) == 1,
`object`.getString(AUTHOR_ID),
`object`.getString(EMOJI),
`object`.getString(SERVER_ID),
`object`.getLong(COUNT),
`object`.getLong(SORT_ID),
`object`.getLong(DATE_SENT),
`object`.getLong(DATE_RECEIVED)
)
)
}
}
result.sortedBy { it.dateSent }
} else {
listOf(
ReactionRecord(
cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),
cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_ID)),
cursor.getInt(cursor.getColumnIndexOrThrow(IS_MMS)) == 1,
cursor.getString(cursor.getColumnIndexOrThrow(AUTHOR_ID)),
cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)),
cursor.getString(cursor.getColumnIndexOrThrow(SERVER_ID)),
cursor.getLong(cursor.getColumnIndexOrThrow(COUNT)),
cursor.getLong(cursor.getColumnIndexOrThrow(SORT_ID)),
cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)),
cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED))
)
)
}
} catch (e: JSONException) {
throw AssertionError(e)
}
}
fun getReactionFor(timestamp: Long, sender: String): ReactionRecord? {
val query = "$DATE_SENT = ? AND $AUTHOR_ID = ?"
val args = arrayOf("$timestamp", sender)
readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
return if (cursor.moveToFirst()) readReaction(cursor) else null
}
}
fun updateReaction(reaction: ReactionRecord) {
writableDatabase.beginTransaction()
try {
val values = ContentValues().apply {
put(EMOJI, reaction.emoji)
put(AUTHOR_ID, reaction.author)
put(SERVER_ID, reaction.serverId)
put(COUNT, reaction.count)
put(SORT_ID, reaction.sortId)
put(DATE_SENT, reaction.dateSent)
put(DATE_RECEIVED, reaction.dateReceived)
}
val query = "$ROW_ID = ?"
val args = arrayOf("${reaction.id}")
writableDatabase.update(TABLE_NAME, values, query, args)
writableDatabase.setTransactionSuccessful()
} finally {
writableDatabase.endTransaction()
}
}
}

View File

@ -34,7 +34,7 @@ public class RecipientDatabase extends Database {
private static final String TAG = RecipientDatabase.class.getSimpleName();
static final String TABLE_NAME = "recipient_preferences";
private static final String ID = "_id";
static final String ID = "_id";
public static final String ADDRESS = "recipient_ids";
static final String BLOCK = "block";
static final String APPROVED = "approved";

View File

@ -23,6 +23,9 @@ import android.database.Cursor;
import android.text.TextUtils;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
@ -43,11 +46,13 @@ import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ -98,9 +103,24 @@ public class SmsDatabase extends MessagingDatabase {
PROTOCOL, READ, STATUS, TYPE,
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED,
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED,
"json_group_array(json_object(" +
"'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " +
"'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " +
"'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " +
"'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " +
"'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " +
"'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " +
"'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " +
"'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " +
"'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " +
"'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED +
")) AS " + ReactionDatabase.REACTION_JSON_ALIAS
};
public static String CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " +
"ADD COLUMN " + REACTIONS_UNREAD + " INTEGER DEFAULT 0;";
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache();
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache();
@ -294,7 +314,7 @@ public class SmsDatabase extends MessagingDatabase {
}
public List<MarkedMessageInfo> setMessagesRead(long threadId) {
return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0", new String[] {String.valueOf(threadId)});
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)});
}
public List<MarkedMessageInfo> setAllMessagesRead() {
@ -321,6 +341,7 @@ public class SmsDatabase extends MessagingDatabase {
ContentValues contentValues = new ContentValues();
contentValues.put(READ, 1);
contentValues.put(REACTIONS_UNREAD, 0);
database.update(TABLE_NAME, contentValues, where, arguments);
database.setTransactionSuccessful();
@ -533,15 +554,21 @@ public class SmsDatabase extends MessagingDatabase {
return messageId;
}
private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
return database.rawQuery("SELECT " + Util.join(MESSAGE_PROJECTION, ",") +
" FROM " + SmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
" ON (" + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " = " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0)" +
" WHERE " + where + " GROUP BY " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, arguments);
}
public Cursor getExpirationStartedMessages() {
String where = EXPIRE_STARTED + " > 0";
SQLiteDatabase db = databaseHelper.getReadableDatabase();
return db.query(TABLE_NAME, MESSAGE_PROJECTION, where, null, null, null, null);
return rawQuery(where, null);
}
public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[]{messageId + ""}, null, null, null);
Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""});
Reader reader = new Reader(cursor);
SmsMessageRecord record = reader.getNext();
@ -563,12 +590,6 @@ public class SmsDatabase extends MessagingDatabase {
Log.i("MessageDatabase", "Deleting: " + messageId);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = getThreadIdForMessage(messageId);
try {
SmsMessageRecord toDelete = getMessage(messageId);
DatabaseComponent.get(context).mmsDatabase().deleteQuotedFromMessages(toDelete);
} catch (NoSuchMessageException e) {
Log.e(TAG, "Couldn't find message record for messageId "+messageId, e);
}
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false);
notifyConversationListeners(threadId);
@ -586,6 +607,11 @@ public class SmsDatabase extends MessagingDatabase {
notifyConversationListListeners();
}
@Override
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
return getMessage(messageId);
}
private boolean isDuplicate(IncomingTextMessage message, long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?",
@ -710,7 +736,7 @@ public class SmsDatabase extends MessagingDatabase {
0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(),
threadId, 0, new LinkedList<IdentityKeyMismatch>(),
message.getExpiresIn(),
System.currentTimeMillis(), 0, false);
System.currentTimeMillis(), 0, false, Collections.emptyList());
}
}
@ -758,12 +784,13 @@ public class SmsDatabase extends MessagingDatabase {
List<IdentityKeyMismatch> mismatches = getMismatches(mismatchDocument);
Recipient recipient = Recipient.from(context, address, true);
List<ReactionRecord> reactions = DatabaseComponent.get(context).reactionDatabase().getReactions(cursor);
return new SmsMessageRecord(messageId, body, recipient,
recipient,
dateSent, dateReceived, deliveryReceiptCount, type,
threadId, status, mismatches,
expiresIn, expireStarted, readReceiptCount, unidentified);
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
}
private List<IdentityKeyMismatch> getMismatches(String document) {

View File

@ -12,6 +12,7 @@ import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage
@ -22,6 +23,7 @@ import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessag
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Reaction
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.OpenGroup
@ -49,6 +51,8 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
@ -887,4 +891,57 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
db.addBlindedIdMapping(mapping)
return mapping
}
override fun addReaction(reaction: Reaction) {
val timestamp = reaction.timestamp
val localId = reaction.localId
val isMms = reaction.isMms
val messageId = if (localId != null && localId > 0 && isMms != null) {
MessageId(localId, isMms)
} else if (timestamp != null && timestamp > 0) {
val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(timestamp) ?: return
MessageId(messageRecord.id, messageRecord.isMms)
} else return
DatabaseComponent.get(context).reactionDatabase().addReaction(
messageId,
ReactionRecord(
messageId = messageId.id,
isMms = messageId.mms,
author = reaction.publicKey!!,
emoji = reaction.emoji!!,
serverId = reaction.serverId!!,
count = reaction.count!!,
sortId = reaction.index!!,
dateSent = reaction.dateSent!!,
dateReceived = reaction.dateReceived!!
)
)
}
override fun removeReaction(emoji: String, messageTimestamp: Long, author: String) {
val messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageForTimestamp(messageTimestamp) ?: return
val messageId = MessageId(messageRecord.id, messageRecord.isMms)
DatabaseComponent.get(context).reactionDatabase().deleteReaction(emoji, messageId, author)
}
override fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) {
val database = DatabaseComponent.get(context).reactionDatabase()
var reaction = database.getReactionFor(message.sentTimestamp!!, sender) ?: return
if (openGroupSentTimestamp != -1L) {
addReceivedMessageTimestamp(openGroupSentTimestamp)
reaction = reaction.copy(dateSent = openGroupSentTimestamp)
}
message.serverHash?.let {
reaction = reaction.copy(serverId = it)
}
message.openGroupServerMessageID?.let {
reaction = reaction.copy(serverId = "$it")
}
database.updateReaction(reaction)
}
override fun deleteReactions(messageId: Long, mms: Boolean) {
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
}
}

View File

@ -847,7 +847,11 @@ public class ThreadDatabase extends Database {
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
}
public interface ProgressListener {
public void notifyThreadUpdated(long threadId) {
notifyConversationListeners(threadId);
}
public interface ProgressListener {
void onProgress(int complete, int total);
}

View File

@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupMemberDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.database.LokiThreadDatabase;
import org.thoughtcrime.securesms.database.LokiUserDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SessionContactDatabase;
@ -71,9 +73,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV34 = 55;
private static final int lokiV35 = 56;
private static final int lokiV36 = 57;
private static final int lokiV37 = 58;
private static final int lokiV38 = 59;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV36;
private static final int DATABASE_VERSION = lokiV38;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -159,7 +163,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand());
db.execSQL(RecipientDatabase.getCreateApprovedCommand());
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
db.execSQL(MmsDatabase.createMessageRequestResponseCommand);
db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
db.execSQL(MmsDatabase.CREATE_REACTIONS_LAST_SEEN_COMMAND);
db.execSQL(LokiAPIDatabase.CREATE_FORK_INFO_TABLE_COMMAND);
db.execSQL(LokiAPIDatabase.CREATE_DEFAULT_FORK_INFO_COMMAND);
db.execSQL(LokiAPIDatabase.UPDATE_HASHES_INCLUDE_NAMESPACE_COMMAND);
@ -171,6 +178,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(BlindedIdMappingDatabase.CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND);
db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND);
db.execSQL(LokiAPIDatabase.RESET_SEQ_NO); // probably not needed but consistent with all migrations
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
db.execSQL(ReactionDatabase.CREATE_REACTION_TABLE_COMMAND);
executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -179,6 +188,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, DraftDatabase.CREATE_INDEXS);
executeStatements(db, GroupDatabase.CREATE_INDEXS);
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
}
@Override
@ -357,7 +368,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(RecipientDatabase.getCreateApprovedCommand());
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
db.execSQL(RecipientDatabase.getUpdateApprovedCommand());
db.execSQL(MmsDatabase.createMessageRequestResponseCommand);
db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
}
if (oldVersion < lokiV32) {
@ -391,6 +402,18 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.RESET_SEQ_NO);
}
if (oldVersion < lokiV37) {
db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND);
db.execSQL(MmsDatabase.CREATE_REACTIONS_LAST_SEEN_COMMAND);
db.execSQL(ReactionDatabase.CREATE_REACTION_TABLE_COMMAND);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
}
if (oldVersion < lokiV38) {
db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Ties together an emoji with it's associated search tags.
*/
public final class EmojiSearchData {
@JsonProperty
private String emoji;
@JsonProperty
private List<String> tags;
public EmojiSearchData() {}
public @NonNull String getEmoji() {
return emoji;
}
public @NonNull List<String> getTags() {
return tags;
}
}

View File

@ -18,8 +18,10 @@ package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
import org.session.libsession.utilities.Contact;
import org.session.libsession.utilities.IdentityKeyMismatch;
@ -28,7 +30,9 @@ import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.mms.SlideDeck;
import java.util.List;
import network.loki.messenger.R;
/**
@ -43,21 +47,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
private final int partCount;
public MediaMmsMessageRecord(long id, Recipient conversationRecipient,
Recipient individualRecipient, int recipientDeviceId,
long dateSent, long dateReceived, int deliveryReceiptCount,
long threadId, String body,
@NonNull SlideDeck slideDeck,
int partCount, long mailbox,
List<IdentityKeyMismatch> mismatches,
List<NetworkFailure> failures, int subscriptionId,
long expiresIn, long expireStarted, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
Recipient individualRecipient, int recipientDeviceId,
long dateSent, long dateReceived, int deliveryReceiptCount,
long threadId, String body,
@NonNull SlideDeck slideDeck,
int partCount, long mailbox,
List<IdentityKeyMismatch> mismatches,
List<NetworkFailure> failures, int subscriptionId,
long expiresIn, long expireStarted, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews,
@NonNull List<ReactionRecord> reactions, boolean unidentified)
{
super(id, body, conversationRecipient, individualRecipient, dateSent,
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts,
linkPreviews, unidentified);
linkPreviews, unidentified, reactions);
this.partCount = partCount;
}

View File

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.database.model
/**
* Represents a pair of values that can be used to find a message. Because we have two tables,
* that means this has both the primary key and a boolean indicating which table it's in.
*/
data class MessageId(
val id: Long,
@get:JvmName("isMms") val mms: Boolean
) {
fun serialize(): String {
return "$id|$mms"
}
companion object {
/**
* Returns null for invalid IDs. Useful when pulling a possibly-unset ID from a database, or something like that.
*/
@JvmStatic
fun fromNullable(id: Long, mms: Boolean): MessageId? {
return if (id > 0) {
MessageId(id, mms)
} else {
null
}
}
@JvmStatic
fun deserialize(serialized: String): MessageId {
val parts: List<String> = serialized.split("|")
return MessageId(parts[0].toLong(), parts[1].toBoolean())
}
}
}

View File

@ -50,6 +50,7 @@ public abstract class MessageRecord extends DisplayRecord {
private final long expireStarted;
private final boolean unidentified;
public final long id;
private final List<ReactionRecord> reactions;
public abstract boolean isMms();
public abstract boolean isMmsNotification();
@ -61,7 +62,7 @@ public abstract class MessageRecord extends DisplayRecord {
List<IdentityKeyMismatch> mismatches,
List<NetworkFailure> networkFailures,
long expiresIn, long expireStarted,
int readReceiptCount, boolean unidentified)
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions)
{
super(body, conversationRecipient, dateSent, dateReceived,
threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount);
@ -72,6 +73,7 @@ public abstract class MessageRecord extends DisplayRecord {
this.expiresIn = expiresIn;
this.expireStarted = expireStarted;
this.unidentified = unidentified;
this.reactions = reactions;
}
public long getId() {
@ -147,4 +149,9 @@ public abstract class MessageRecord extends DisplayRecord {
public int hashCode() {
return (int)getId();
}
public @NonNull List<ReactionRecord> getReactions() {
return reactions;
}
}

View File

@ -25,9 +25,9 @@ public abstract class MmsMessageRecord extends MessageRecord {
List<NetworkFailure> networkFailures, long expiresIn,
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
@NonNull List<LinkPreview> linkPreviews, boolean unidentified, List<ReactionRecord> reactions)
{
super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified);
super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
this.slideDeck = slideDeck;
this.quote = quote;
this.contacts.addAll(contacts);

View File

@ -16,17 +16,18 @@
*/
package org.thoughtcrime.securesms.database.model;
import static java.util.Collections.emptyList;
import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.mms.SlideDeck;
import java.util.Collections;
import java.util.LinkedList;
import network.loki.messenger.R;
/**
@ -53,8 +54,8 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
{
super(id, "", conversationRecipient, individualRecipient,
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new LinkedList<IdentityKeyMismatch>(), new LinkedList<NetworkFailure>(),
0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false);
emptyList(), emptyList(),
0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList());
this.contentLocation = contentLocation;
this.messageSize = messageSize;

View File

@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.database.model
data class ReactionRecord(
val id: Long = 0,
val messageId: Long,
val isMms: Boolean,
val author: String,
val emoji: String,
val serverId: String = "",
val count: Long = 0,
val sortId: Long = 0,
val dateSent: Long = 0,
val dateReceived: Long = 0
)

View File

@ -43,12 +43,12 @@ public class SmsMessageRecord extends MessageRecord {
long type, long threadId,
int status, List<IdentityKeyMismatch> mismatches,
long expiresIn, long expireStarted,
int readReceiptCount, boolean unidentified)
int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions)
{
super(id, body, recipient, individualRecipient,
dateSent, dateReceived, threadId, status, deliveryReceiptCount, type,
mismatches, new LinkedList<>(),
expiresIn, expireStarted, readReceiptCount, unidentified);
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
}
public long getType() {

View File

@ -40,6 +40,8 @@ interface DatabaseComponent {
fun lokiBackupFilesDatabase(): LokiBackupFilesDatabase
fun sessionJobDatabase(): SessionJobDatabase
fun sessionContactDatabase(): SessionContactDatabase
fun reactionDatabase(): ReactionDatabase
fun emojiSearchDatabase(): EmojiSearchDatabase
fun storage(): Storage
fun attachmentProvider(): MessageDataProvider
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase

View File

@ -125,6 +125,14 @@ object DatabaseModule {
@Singleton
fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupMemberDatabase(context, openHelper)
@Provides
@Singleton
fun provideReactionDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ReactionDatabase(context, openHelper)
@Provides
@Singleton
fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper)
@Provides
@Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper)

View File

@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.emoji
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import network.loki.messenger.R
/**
* All the different Emoji categories the app is aware of in the order we want to display them.
*/
enum class EmojiCategory(val priority: Int, val key: String, @AttrRes val icon: Int) {
PEOPLE(0, "People", R.attr.emoji_category_people),
NATURE(1, "Nature", R.attr.emoji_category_nature),
FOODS(2, "Foods", R.attr.emoji_category_foods),
ACTIVITY(3, "Activity", R.attr.emoji_category_activity),
PLACES(4, "Places", R.attr.emoji_category_places),
OBJECTS(5, "Objects", R.attr.emoji_category_objects),
SYMBOLS(6, "Symbols", R.attr.emoji_category_symbol),
FLAGS(7, "Flags", R.attr.emoji_category_flags),
EMOTICONS(8, "Emoticons", R.attr.emoji_category_emoticons);
@StringRes
fun getCategoryLabel(): Int {
return getCategoryLabel(icon)
}
companion object {
@JvmStatic
fun forKey(key: String) = values().first { it.key == key }
@JvmStatic
@StringRes
fun getCategoryLabel(@AttrRes iconAttr: Int): Int {
return when (iconAttr) {
R.attr.emoji_category_people -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people
R.attr.emoji_category_nature -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__nature
R.attr.emoji_category_foods -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__food
R.attr.emoji_category_activity -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__activities
R.attr.emoji_category_places -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__places
R.attr.emoji_category_objects -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__objects
R.attr.emoji_category_symbol -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__symbols
R.attr.emoji_category_flags -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__flags
R.attr.emoji_category_emoticons -> R.string.ReactWithAnyEmojiBottomSheetDialogFragment__emoticons
else -> throw AssertionError()
}
}
}
}

View File

@ -0,0 +1,138 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.session.libsignal.utilities.Hex
import org.thoughtcrime.securesms.components.emoji.CompositeEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
import java.io.InputStream
import java.nio.charset.Charset
typealias UriFactory = (sprite: String, format: String) -> Uri
/**
* Takes an emoji_data.json file data and parses it into an EmojiSource
*/
object EmojiJsonParser {
private val OBJECT_MAPPER = ObjectMapper()
private const val ESTIMATED_EMOJI_COUNT = 3500
@JvmStatic
fun verify(body: InputStream) {
parse(body) { _, _ -> Uri.EMPTY }.getOrThrow()
}
fun parse(body: InputStream, uriFactory: UriFactory): Result<ParsedEmojiData> {
return try {
Result.success(buildEmojiSourceFromNode(OBJECT_MAPPER.readTree(body), uriFactory))
} catch (e: Exception) {
Result.failure(e)
}
}
private fun buildEmojiSourceFromNode(node: JsonNode, uriFactory: UriFactory): ParsedEmojiData {
val format: String = node["format"].textValue()
val obsolete: List<ObsoleteEmoji> = node["obsolete"].toObseleteList()
val dataPages: List<EmojiPageModel> = getDataPages(format, node["emoji"], uriFactory)
val jumboPages: Map<String, String> = getJumboPages(node["jumbomoji"])
val displayPages: List<EmojiPageModel> = mergeToDisplayPages(dataPages)
val metrics: EmojiMetrics = node["metrics"].toEmojiMetrics()
val densities: List<String> = node["densities"].toDensityList()
return ParsedEmojiData(metrics, densities, format, displayPages, dataPages, jumboPages, obsolete)
}
private fun getDataPages(format: String, emoji: JsonNode, uriFactory: UriFactory): List<EmojiPageModel> {
return emoji.fields()
.asSequence()
.sortedWith { lhs, rhs ->
val lhsCategory = EmojiCategory.forKey(lhs.key.asCategoryKey())
val rhsCategory = EmojiCategory.forKey(rhs.key.asCategoryKey())
val comp = lhsCategory.priority.compareTo(rhsCategory.priority)
if (comp == 0) {
val lhsIndex = lhs.key.getPageIndex()
val rhsIndex = rhs.key.getPageIndex()
lhsIndex.compareTo(rhsIndex)
} else {
comp
}
}
.map { createPage(it.key, format, it.value, uriFactory) }
.toList()
}
private fun getJumboPages(jumbo: JsonNode?): Map<String, String> {
if (jumbo != null) {
return jumbo.fields()
.asSequence()
.map { (page: String, node: JsonNode) ->
node.associate { it.textValue() to page }
}
.flatMap { it.entries }
.associateTo(HashMap(ESTIMATED_EMOJI_COUNT)) { it.key to it.value }
}
return emptyMap()
}
private fun createPage(pageName: String, format: String, page: JsonNode, uriFactory: UriFactory): EmojiPageModel {
val category = EmojiCategory.forKey(pageName.asCategoryKey())
val pageList = page.mapIndexed { i, data ->
if (data.size() == 0) {
throw IllegalStateException("Page index $pageName.$i had no data")
} else {
val variations: MutableList<String> = mutableListOf()
val rawVariations: MutableList<String> = mutableListOf()
data.forEach {
variations += it.textValue().encodeAsUtf16()
rawVariations += it.textValue()
}
Emoji(variations, rawVariations)
}
}
return StaticEmojiPageModel(category, pageList, uriFactory(pageName, format))
}
private fun mergeToDisplayPages(dataPages: List<EmojiPageModel>): List<EmojiPageModel> {
return dataPages.groupBy { it.iconAttr }
.map { (icon, pages) -> if (pages.size <= 1) pages.first() else CompositeEmojiPageModel(icon, pages) }
}
}
private fun JsonNode?.toObseleteList(): List<ObsoleteEmoji> {
return if (this == null) {
listOf()
} else {
map { node ->
ObsoleteEmoji(node["obsoleted"].textValue().encodeAsUtf16(), node["replace_with"].textValue().encodeAsUtf16())
}.toList()
}
}
private fun JsonNode.toEmojiMetrics(): EmojiMetrics {
return EmojiMetrics(this["raw_width"].asInt(), this["raw_height"].asInt(), this["per_row"].asInt())
}
private fun JsonNode.toDensityList(): List<String> {
return map { it.textValue() }
}
private fun String.encodeAsUtf16() = String(Hex.fromStringCondensed(this), Charset.forName("UTF-16"))
private fun String.asCategoryKey() = replace("(_\\d+)*$".toRegex(), "")
private fun String.getPageIndex() = "^.*_(\\d+)+$".toRegex().find(this)?.let { it.groupValues[1] }?.toInt() ?: throw IllegalStateException("No index.")
data class ParsedEmojiData(
override val metrics: EmojiMetrics,
override val densities: List<String>,
override val format: String,
override val displayPages: List<EmojiPageModel>,
override val dataPages: List<EmojiPageModel>,
override val jumboPages: Map<String, String>,
override val obsolete: List<ObsoleteEmoji>
) : EmojiData

View File

@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import com.bumptech.glide.load.Key
import java.security.MessageDigest
typealias EmojiPageFactory = (Uri) -> EmojiPage
sealed class EmojiPage(open val uri: Uri) : Key {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update("EmojiPage".encodeToByteArray())
messageDigest.update(uri.toString().encodeToByteArray())
}
data class Asset(override val uri: Uri) : EmojiPage(uri)
}

View File

@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.emoji
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import org.session.libsession.utilities.ListenableFutureTask
import org.session.libsession.utilities.SoftHashMap
import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsignal.utilities.Log
import java.io.IOException
import java.io.InputStream
object EmojiPageCache {
private val TAG = Log.tag(EmojiPageCache::class.java)
private val cache: SoftHashMap<EmojiPageRequest, Bitmap> = SoftHashMap()
private val tasks: HashMap<EmojiPageRequest, ListenableFutureTask<Bitmap>> = hashMapOf()
@MainThread
fun load(context: Context, emojiPage: EmojiPage, inSampleSize: Int): LoadResult {
val applicationContext = context.applicationContext
val emojiPageRequest = EmojiPageRequest(emojiPage, inSampleSize)
val bitmap: Bitmap? = cache[emojiPageRequest]
val task: ListenableFutureTask<Bitmap>? = tasks[emojiPageRequest]
return when {
bitmap != null -> LoadResult.Immediate(bitmap)
task != null -> LoadResult.Async(task)
else -> {
val newTask = ListenableFutureTask<Bitmap> {
try {
Log.i(TAG, "Loading page $emojiPageRequest")
loadInternal(applicationContext, emojiPageRequest)
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
tasks[emojiPageRequest] = newTask
SimpleTask.run(newTask::run) {
try {
val newBitmap: Bitmap? = newTask.get()
if (newBitmap == null) {
Log.w(TAG, "Failed to load emoji bitmap for request $emojiPageRequest")
} else {
cache[emojiPageRequest] = newBitmap
}
} finally {
tasks.remove(emojiPageRequest)
}
}
LoadResult.Async(newTask)
}
}
}
fun clear() {
cache.clear()
}
@WorkerThread
private fun loadInternal(context: Context, emojiPageRequest: EmojiPageRequest): Bitmap? {
val inputStream: InputStream = when (emojiPageRequest.emojiPage) {
is EmojiPage.Asset -> context.assets.open(emojiPageRequest.emojiPage.uri.toString().replace("file:///android_asset/", ""))
}
val bitmapOptions = BitmapFactory.Options()
bitmapOptions.inSampleSize = emojiPageRequest.inSampleSize
return inputStream.use { BitmapFactory.decodeStream(it, null, bitmapOptions) }
}
private data class EmojiPageRequest(val emojiPage: EmojiPage, val inSampleSize: Int)
sealed class LoadResult {
data class Immediate(val bitmap: Bitmap) : LoadResult()
data class Async(val task: ListenableFutureTask<Bitmap>) : LoadResult()
}
}

View File

@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import androidx.annotation.WorkerThread
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree
import org.thoughtcrime.securesms.util.ScreenDensity
import java.io.InputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
/**
* The entry point for the application to request Emoji data for custom emojis.
*/
class EmojiSource(
val decodeScale: Float,
private val emojiData: EmojiData,
private val emojiPageFactory: EmojiPageFactory
) : EmojiData by emojiData {
val variationsToCanonical: Map<String, String> by lazy {
val map = mutableMapOf<String, String>()
for (page: EmojiPageModel in dataPages) {
for (emoji: Emoji in page.displayEmoji) {
for (variation: String in emoji.variations) {
map[variation] = emoji.value
}
}
}
map
}
val canonicalToVariations: Map<String, List<String>> by lazy {
val map = mutableMapOf<String, List<String>>()
for (page: EmojiPageModel in dataPages) {
for (emoji: Emoji in page.displayEmoji) {
map[emoji.value] = emoji.variations
}
}
map
}
val maxEmojiLength: Int by lazy {
dataPages.map { it.emoji.map(String::length) }
.flatten()
.maxOrZero()
}
val emojiTree: EmojiTree by lazy {
val tree = EmojiTree()
dataPages
.filter { it.spriteUri != null }
.forEach { page ->
val emojiPage = emojiPageFactory(page.spriteUri!!)
var overallIndex = 0
page.displayEmoji.forEach { emoji: Emoji ->
emoji.variations.forEachIndexed { variationIndex, variation ->
val raw = emoji.getRawVariation(variationIndex)
tree.add(variation, EmojiDrawInfo(emojiPage, overallIndex++, variation, raw, jumboPages[raw]))
}
}
}
obsolete.forEach {
tree.add(it.obsolete, tree.getEmoji(it.replaceWith, 0, it.replaceWith.length))
}
tree
}
companion object {
private val emojiSource = AtomicReference<EmojiSource>()
private val emojiLatch = CountDownLatch(1)
@JvmStatic
val latest: EmojiSource
get() {
emojiLatch.await()
return emojiSource.get()
}
@JvmStatic
@WorkerThread
fun refresh() {
emojiSource.set(getEmojiSource())
emojiLatch.countDown()
}
private fun getEmojiSource(): EmojiSource {
return loadAssetBasedEmojis()
}
private fun loadAssetBasedEmojis(): EmojiSource {
val context = MessagingModuleConfiguration.shared.context
val emojiData: InputStream = ApplicationContext.getInstance(context).assets.open("emoji/emoji_data.json")
emojiData.use {
val parsedData: ParsedEmojiData = EmojiJsonParser.parse(it, ::getAssetsUri).getOrThrow()
return EmojiSource(
ScreenDensity.xhdpiRelativeDensityScaleFactor("xhdpi"),
parsedData.copy(
displayPages = parsedData.displayPages + PAGE_EMOTICONS,
dataPages = parsedData.dataPages + PAGE_EMOTICONS
)
) { uri: Uri -> EmojiPage.Asset(uri) }
}
}
}
}
private fun List<Int>.maxOrZero(): Int = maxOrNull() ?: 0
interface EmojiData {
val metrics: EmojiMetrics
val densities: List<String>
val format: String
val displayPages: List<EmojiPageModel>
val dataPages: List<EmojiPageModel>
val jumboPages: Map<String, String>
val obsolete: List<ObsoleteEmoji>
}
data class ObsoleteEmoji(val obsolete: String, val replaceWith: String)
data class EmojiMetrics(val rawHeight: Int, val rawWidth: Int, val perRow: Int)
private fun getAssetsUri(name: String, format: String): Uri = Uri.parse("file:///android_asset/emoji/$name.$format")
private val PAGE_EMOTICONS: EmojiPageModel = StaticEmojiPageModel(
EmojiCategory.EMOTICONS,
arrayOf(
":-)", ";-)", "(-:", ":->", ":-D", "\\o/",
":-P", "B-)", ":-$", ":-*", "O:-)", "=-O",
"O_O", "O_o", "o_O", ":O", ":-!", ":-x",
":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(",
"^.^", "^_^", "\\(\u02c6\u02da\u02c6)/",
"\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af",
"\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)",
"(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e",
"\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e",
"(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b",
"\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)",
"(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05",
"\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)",
" \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)",
"\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b"
),
null
)

Some files were not shown because too many files have changed in this diff Show More