Add emoji reacts support ()

* feat: Add emoji reacts support

* Remove message multi-selection

* Add emoji reaction model

* Add emoji reaction panel

* Blur reacts panel background

* Show emoji keyboard

* Add emoji sprites

* Update reaction proto

* Emoji database updates

* Emoji database refactor

* Emoji reaction persistence

* Optimize reactions retrieval

* Fix emoji group query

* Display emojis

* Fix emoji persistence

* Cleanup

* Persistence refactor

* Add reactions bottom sheet

* Cleanup

* Ui tweaks

* React with any emoji

* Show emoji react notifications

* Remove reaction

* Show reactions modal on long press

* Click to react (+1) with an emoji

* Click to react with an emoji

* Enable emoji expand/collapse

* fix: some compile issues from merge conflicts

* fix: compile issues merging quote and media message UI

* fix: xml IDs and adding in legacy is selected for future inclusion

* Fix view constraints

* Fix merge issue

* Add message selection option in conversation context menu

* Add sogs emoji integration

* Handle sogs emoji reactions

* Enable sending/deleting sogs emojis

* fix: improve the visible message layout

* fix: add file IDs to request parameters for message send ()

* Fix open group polling from seqno instead of last hash ()

* fix: reset seqno to get recent messages from open groups

* build: upgrade build numbers

* fix: actually run the migration

* Using StringBuilder to construct request url

* Fix reaction filter

* fix: is_mms added in second projection query

* Update default emojis

* fix: include legacy and new open groups in server ID tracking ()

* feat: add hidden moderator and admin roles, separated as they may be used independently in future ()

* Cleanup

* Fix view constraints

* Add reactions capability check

* Fix reactions alignment

* Ui fixes

* Display reactions list

* feat: add formatted count strings

* fix: account for negatives and add tests

* Migrate old official open group locations for polling and adding ()

* feat: adding in first part of open group migrations and tests for migration logic / helpers

* feat: test code and migration logic for open groups in the case of no conflicts

* feat: add in extra test cases and refactor code for migrator

* refactor: migrate open group join URLs and references to server in adding new open groups to catch legacy and re-write it

* refactor: joining open groups using OpenGroupUrlParser.kt now

* fix: add in compile issues for renamed OpenGroupApi.kt from OpenGroupV2

* fix: prevent duplicates of http/https for new open group DNS and prevent adding new groups based on public key

* fix: room and server swapped parameters

* fix: replace default server for config messages

* fix: actually using public key to de-dupe didn't work for rooms

* build: bump version code and name

* Display reactions list on open groups for moderators

* Ui tweaks

* Ui tweaks for moderation

* Refactor

* fix: compile issue

* fix: de-duping joined queries in the get X from cursor

* Restore import

* fix: colouring the reaction overlay scrubber

* fix: highlight colour, show reaction count if 1 or above

* Cleanup

* fix: light mode accent

* fix: light / dark mode themeing in reactions dialog fragment

* Emoji notification blinded id check

* fix: show reaction list correctly and pass isUserModerator to bind methods

* fix: remove unnecessary places for the moderator

* fix: X button for removing own react not showing up properly

* feat: add clear all header view

* fix: migrate the clear all to the correct location

* fix: use display instead of base

* Truncate emoji sender ids

* feat: add notify thread function in thread db

* Notify threads on reaction received

* fix: design fixes for the reaction list

* fix: emoji reactions bottom sheet dialog UI designs

* feat: add unsupported emoji reaction

* fix: crash and doing vector properly

* Fix reaction database queries

* Fix background open group adder job

* Show new open group reactions

* Fetch a maximum of 5 reactors

* Handle open group reactions polling conflicts

* Add count to user reaction

* Show number of additional reactors

* fix: unreads set same as the unread query

* fix: design changes

* fix: update dependency to improve flexboxlayout behaviour, design consistencies

* Add select message icon and update long press menu items order and wording

* Fix crash on reactors dialog

* fix: colours and backgrounds to match designs

* fix: add header in recipient item

* fix: margins

* fix: alignments and layout issues for emoji reactions view

* feat: add overflow previews and logic for overflow

* Dim action bar

* Add emoji search

* Search index fix

* Set count for 1:1 and closed group reactions when inserting in local database

* Use on screen toolbar to allow overlaying

* Show/hide scroll to bottom button

* feat: add extended properties so it doesn't collapse on re-bind

* Cleanup

* feat: prevent keeping extended on rebinding if we get a new message ID

* fix: long press works on devices now, fix release lint issue and crash for emoji search DBs from emoji builds

* Display message timestamp

* Fix modal items alignment

* fix: sort order and emoji count in compareTo

* Scale down really large messages to fit

* Prevent closed group crash

* Fix reaction author

Co-authored-by: charles <charles@oxen.io>
Co-authored-by: jubb <hjubb@users.noreply.github.com>
This commit is contained in:
ceokot 2022-09-04 21:03:32 +10:00 committed by GitHub
parent 2bfc8215d4
commit 16ca97d2d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
230 changed files with 11280 additions and 1004 deletions
app
build.gradle
src/main
AndroidManifest.xml
assets/emoji
java/org/thoughtcrime/securesms
ApplicationContext.java
animation
components
conversation/v2
database
dependencies
emoji

@ -90,10 +90,7 @@ dependencies {
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
} }
implementation 'com.annimon:stream:1.1.8' implementation 'com.annimon:stream:1.1.8'
implementation ('com.takisoft.fix:colorpicker:0.9.1') { implementation 'com.takisoft.fix:colorpicker:1.0.1'
exclude group: 'com.android.support', module: 'appcompat-v7'
exclude group: 'com.android.support', module: 'recyclerview-v7'
}
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'org.signal:android-database-sqlcipher:3.5.9-S3' implementation 'org.signal:android-database-sqlcipher:3.5.9-S3'
@ -159,8 +156,8 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
} }
def canonicalVersionCode = 292 def canonicalVersionCode = 294
def canonicalVersionName = "1.14.0" def canonicalVersionName = "1.14.2"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,

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

Binary file not shown.

After

(image error) Size: 88 KiB

Binary file not shown.

After

(image error) Size: 47 KiB

Binary file not shown.

After

(image error) Size: 44 KiB

Binary file not shown.

After

(image error) Size: 125 KiB

Binary file not shown.

After

(image error) Size: 166 KiB

Binary file not shown.

After

(image error) Size: 238 KiB

Binary file not shown.

After

(image error) Size: 176 KiB

Binary file not shown.

After

(image error) Size: 99 KiB

Binary file not shown.

After

(image error) Size: 150 KiB

Binary file not shown.

After

(image error) Size: 149 KiB

Binary file not shown.

After

(image error) Size: 150 KiB

Binary file not shown.

After

(image error) Size: 148 KiB

Binary file not shown.

After

(image error) Size: 142 KiB

Binary file not shown.

After

(image error) Size: 174 KiB

Binary file not shown.

After

(image error) Size: 87 KiB

Binary file not shown.

After

(image error) Size: 81 KiB

Binary file not shown.

After

(image error) Size: 200 KiB

Binary file not shown.

After

(image error) 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

@ -47,17 +47,22 @@ import org.session.libsession.utilities.Util;
import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.WindowDebouncer;
import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper;
import org.session.libsession.utilities.dynamiclanguage.LocaleParser; import org.session.libsession.utilities.dynamiclanguage.LocaleParser;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.ThreadUtils; import org.session.libsignal.utilities.ThreadUtils;
import org.signal.aesgcmprovider.AesGcmProvider; import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.JobDatabase;
import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.model.EmojiSearchData;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.groups.OpenGroupManager; import org.thoughtcrime.securesms.groups.OpenGroupManager;
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
@ -89,12 +94,16 @@ import org.webrtc.voiceengine.WebRtcAudioManager;
import org.webrtc.voiceengine.WebRtcAudioUtils; import org.webrtc.voiceengine.WebRtcAudioUtils;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.security.Security; import java.security.Security;
import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.Timer; import java.util.Timer;
import java.util.concurrent.Executors;
import javax.inject.Inject; import javax.inject.Inject;
@ -191,6 +200,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
storage, storage,
messageDataProvider, messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)); ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this));
// migrate session open group data
OpenGroupMigrator.migrate(getDatabaseComponent());
// end migration
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); Log.i(TAG, "onCreate()");
startKovenant(); startKovenant();
@ -220,6 +232,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
initializeWebRtc(); initializeWebRtc();
initializeBlobProvider(); initializeBlobProvider();
resubmitProfilePictureIfNeeded(); resubmitProfilePictureIfNeeded();
loadEmojiSearchIndexIfNeeded();
EmojiSource.refresh();
} }
@Override @Override
@ -489,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) { public void clearAllData(boolean isMigratingToV2KeyPair) {
String token = TextSecurePreferences.getFCMToken(this); String token = TextSecurePreferences.getFCMToken(this);
if (token != null && !token.isEmpty()) { if (token != null && !token.isEmpty()) {

@ -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) {}
}

@ -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;
}
}

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

@ -1,14 +1,27 @@
package org.thoughtcrime.securesms.components.emoji; package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.Nullable;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
public class Emoji { public class Emoji {
private final List<String> variations; private final List<String> variations;
private final List<String> rawVariations;
public Emoji(String... variations) { 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() { public String getValue() {
@ -18,4 +31,15 @@ public class Emoji {
public List<String> getVariations() { public List<String> getVariations() {
return variations; 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;
}
} }

@ -1,20 +1,23 @@
package org.thoughtcrime.securesms.components.emoji; package org.thoughtcrime.securesms.components.emoji;
import android.content.Context; import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatEditText;
import android.text.InputFilter; import android.text.InputFilter;
import android.util.AttributeSet; 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.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.session.libsession.utilities.TextSecurePreferences;
import network.loki.messenger.R;
public class EmojiEditText extends AppCompatEditText { 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) { public EmojiEditText(Context context) {
this(context, null); this(context, null);
@ -26,8 +29,14 @@ public class EmojiEditText extends AppCompatEditText {
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) { public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, 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); else super.invalidateDrawable(drawable);
} }
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) { private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) {
InputFilter[] result; InputFilter[] result;
if (originalFilters != null) { if (originalFilters != null) {
@ -55,7 +64,7 @@ public class EmojiEditText extends AppCompatEditText {
result = new InputFilter[1]; result = new InputFilter[1];
} }
result[0] = new EmojiFilter(this); result[0] = new EmojiFilter(this, jumboEmoji);
return result; return result;
} }

@ -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);
}

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

@ -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);
}
}
}
}

@ -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
}
}
}

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

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

@ -1,60 +1,136 @@
package org.thoughtcrime.securesms.components.emoji; package org.thoughtcrime.securesms.components.emoji;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import android.graphics.drawable.Drawable;
import androidx.recyclerview.widget.GridLayoutManager; import android.util.AttributeSet;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; 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.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; import network.loki.messenger.R;
public class EmojiPageView extends FrameLayout implements VariationSelectorListener { public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
private static final String TAG = EmojiPageView.class.getSimpleName(); private AdapterFactory adapterFactory;
private LinearLayoutManager layoutManager;
private EmojiPageModel model;
private EmojiPageViewGridAdapter adapter;
private RecyclerView recyclerView;
private GridLayoutManager layoutManager;
private RecyclerView.OnItemTouchListener scrollDisabler; private RecyclerView.OnItemTouchListener scrollDisabler;
private VariationSelectorListener variationSelectorListener; private VariationSelectorListener variationSelectorListener;
private EmojiVariationSelectorPopup popup; 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, public EmojiPageView(@NonNull Context context,
@NonNull EmojiKeyboardProvider.EmojiEventListener emojiSelectionListener, @NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener) @NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations)
{ {
super(context); 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; this.variationSelectorListener = variationSelectorListener;
recyclerView = view.findViewById(R.id.emoji); this.layoutManager = layoutManager;
layoutManager = new GridLayoutManager(context, 8); this.scrollDisabler = new ScrollDisabler();
scrollDisabler = new ScrollDisabler(); this.popup = new EmojiVariationSelectorPopup(getContext(), emojiSelectionListener);
popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener); this.adapterFactory = () -> new EmojiPageViewGridAdapter(popup,
adapter = new EmojiPageViewGridAdapter(EmojiProvider.getInstance(context), emojiSelectionListener,
popup, this,
emojiSelectionListener, allowVariations,
this); displayEmojiLayoutResId,
displayEmoticonLayoutResId);
recyclerView.setLayoutManager(layoutManager); if (this.layoutManager instanceof GridLayoutManager) {
recyclerView.setAdapter(adapter); 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() { public void onSelected() {
if (model.isDynamic() && adapter != null) { if (getAdapter() != null) {
adapter.notifyDataSetChanged(); getAdapter().notifyDataSetChanged();
} }
} }
public void setModel(EmojiPageModel model) { public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
this.model = model; EmojiPageViewGridAdapter adapter = adapterFactory.create();
adapter.setEmoji(model.getDisplayEmoji()); setAdapter(adapter);
adapter.submitList(list, commitCallback);
} }
@Override @Override
@ -66,16 +142,21 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
@Override @Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) { protected void onSizeChanged(int w, int h, int oldw, int oldh) {
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width); if (layoutManager instanceof GridLayoutManager) {
layoutManager.setSpanCount(Math.max(w / idealWidth, 1)); 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 @Override
public void onVariationSelectorStateChanged(boolean open) { public void onVariationSelectorStateChanged(boolean open) {
if (open) { if (open) {
recyclerView.addOnItemTouchListener(scrollDisabler); addOnItemTouchListener(scrollDisabler);
} else { } else {
post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler)); post(() -> removeOnItemTouchListener(scrollDisabler));
} }
if (variationSelectorListener != null) { 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 { private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {
@Override @Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) { public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
@ -95,4 +202,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
@Override @Override
public void onRequestDisallowInterceptTouchEvent(boolean b) { } public void onRequestDisallowInterceptTouchEvent(boolean b) { }
} }
private interface AdapterFactory {
EmojiPageViewGridAdapter create();
}
} }

@ -1,94 +1,40 @@
package org.thoughtcrime.securesms.components.emoji; package org.thoughtcrime.securesms.components.emoji;
import android.graphics.drawable.Drawable; 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.View;
import android.view.ViewGroup;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.PopupWindow; 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 network.loki.messenger.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import java.util.ArrayList; public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
import java.util.List;
public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageViewGridAdapter.EmojiViewHolder> implements PopupWindow.OnDismissListener { private final VariationSelectorListener variationSelectorListener;
private final List<Emoji> emojiList; public EmojiPageViewGridAdapter(@NonNull EmojiVariationSelectorPopup popup,
private final EmojiProvider emojiProvider;
private final EmojiVariationSelectorPopup popup;
private final VariationSelectorListener variationSelectorListener;
private final EmojiEventListener emojiEventListener;
public EmojiPageViewGridAdapter(@NonNull EmojiProvider emojiProvider,
@NonNull EmojiVariationSelectorPopup popup,
@NonNull EmojiEventListener emojiEventListener, @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; this.variationSelectorListener = variationSelectorListener;
popup.setOnDismissListener(this); popup.setOnDismissListener(this);
}
@NonNull registerFactory(EmojiHeader.class, new LayoutFactory<>(EmojiHeaderViewHolder::new, R.layout.emoji_grid_header));
@Override registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayEmojiLayoutResId));
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), displayEmoticonLayoutResId));
return new EmojiViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.emoji_display_item, viewGroup, false)); registerFactory(EmojiNoResultsModel.class, new LayoutFactory<>(MappingViewHolder.SimpleViewHolder::new, R.layout.emoji_grid_no_results));
}
@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();
} }
@Override @Override
@ -96,18 +42,196 @@ public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageView
variationSelectorListener.onVariationSelectorStateChanged(false); variationSelectorListener.onVariationSelectorStateChanged(false);
} }
static class EmojiViewHolder extends RecyclerView.ViewHolder { public static class EmojiHeader implements MappingModel<EmojiHeader>, HasKey {
private final ImageView imageView; private final String key;
private final AsciiEmojiView textView; private final int title;
private final ImageView hintCorner;
public EmojiViewHolder(@NonNull View itemView) { public EmojiHeader(@NonNull String key, int title) {
super(itemView); this.key = key;
this.imageView = itemView.findViewById(R.id.emoji_image); this.title = title;
this.textView = itemView.findViewById(R.id.emoji_text);
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
} }
@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 { public interface VariationSelectorListener {

File diff suppressed because one or more lines are too long

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.emoji; package org.thoughtcrime.securesms.components.emoji;
import android.annotation.TargetApi; import static org.session.libsession.utilities.Util.runOnMain;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.Canvas; import android.graphics.Canvas;
@ -9,145 +10,156 @@ import android.graphics.Paint;
import android.graphics.PixelFormat; import android.graphics.PixelFormat;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.drawable.Drawable; 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.Spannable;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.widget.TextView; import android.widget.TextView;
import network.loki.messenger.R; import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo; import androidx.annotation.Nullable;
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 org.session.libsession.utilities.FutureTaskListener; import org.session.libsession.utilities.FutureTaskListener;
import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.Pair; 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.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
class EmojiProvider { public class EmojiProvider {
private static final String TAG = EmojiProvider.class.getSimpleName(); private static final String TAG = Log.tag(EmojiProvider.class);
private static volatile EmojiProvider instance = null; private static final Paint PAINT = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
private static final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
private final EmojiTree emojiTree = new EmojiTree(); public static @Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
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) {
if (text == null) return null; 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) { static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv, boolean jumboEmoji) {
return emojify(getCandidates(text), text, tv); if (tv.isInEditMode()) {
return null;
} else {
return emojify(getCandidates(text), text, tv, jumboEmoji);
}
} }
@Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches, static @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text, @Nullable CharSequence text,
@NonNull TextView tv) { @NonNull TextView tv,
if (matches == null || text == null) return null; boolean jumboEmoji)
SpannableStringBuilder builder = new SpannableStringBuilder(text); {
if (matches == null || text == null || tv.isInEditMode()) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) { for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(candidate.getDrawInfo()); Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout, jumboEmoji);
if (drawable != null) { if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(), builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
} }
} }
return builder; return builder;
} }
@Nullable Drawable getEmojiDrawable(CharSequence emoji) { static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
EmojiDrawInfo drawInfo = emojiTree.getEmoji(emoji, 0, emoji.length()); return getEmojiDrawable(context, emoji, false);
return getEmojiDrawable(drawInfo);
} }
private @Nullable Drawable getEmojiDrawable(@Nullable EmojiDrawInfo drawInfo) { static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji, boolean jumboEmoji) {
if (drawInfo == null) { if (TextUtils.isEmpty(emoji)) {
return null; return null;
} }
final EmojiDrawable drawable = new EmojiDrawable(drawInfo, decodeScale); EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
drawInfo.getPage().get().addListener(new FutureTaskListener<Bitmap>() { return getEmojiDrawable(context, drawInfo, null, jumboEmoji);
@Override public void onSuccess(final Bitmap result) { }
Util.runOnMain(() -> drawable.setBitmap(result));
} /**
* 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; return drawable;
} }
class EmojiDrawable extends Drawable { static final class EmojiDrawable extends Drawable {
private final EmojiDrawInfo info; private final float intrinsicWidth;
private Bitmap bmp; private final float intrinsicHeight;
private float intrinsicWidth; private final Rect emojiBounds;
private float intrinsicHeight;
private Bitmap bmp;
private boolean isSingleBitmap;
@Override @Override
public int getIntrinsicWidth() { public int getIntrinsicWidth() {
return (int)intrinsicWidth; return (int) intrinsicWidth;
} }
@Override @Override
public int getIntrinsicHeight() { public int getIntrinsicHeight() {
return (int)intrinsicHeight; return (int) intrinsicHeight;
} }
EmojiDrawable(EmojiDrawInfo info, float decodeScale) { EmojiDrawable(@NonNull EmojiSource source, @NonNull EmojiDrawInfo info, int lowMemoryDecodeScale) {
this.info = info; this.intrinsicWidth = (source.getMetrics().getRawWidth() * source.getDecodeScale()) / lowMemoryDecodeScale;
this.intrinsicWidth = EMOJI_RAW_WIDTH * decodeScale; this.intrinsicHeight = (source.getMetrics().getRawHeight() * source.getDecodeScale()) / lowMemoryDecodeScale;
this.intrinsicHeight = EMOJI_RAW_HEIGHT * decodeScale;
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 @Override
@ -156,22 +168,23 @@ class EmojiProvider {
return; return;
} }
final int row = info.getIndex() / EMOJI_PER_ROW;
final int row_index = info.getIndex() % EMOJI_PER_ROW;
canvas.drawBitmap(bmp, canvas.drawBitmap(bmp,
new Rect((int)(row_index * intrinsicWidth), isSingleBitmap ? null : emojiBounds,
(int)(row * intrinsicHeight + row * verticalPad)+1, getBounds(),
(int)(((row_index + 1) * intrinsicWidth)-1), PAINT);
(int)((row + 1) * intrinsicHeight + row * verticalPad)-1),
getBounds(),
paint);
} }
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
public void setBitmap(Bitmap bitmap) { public void setBitmap(Bitmap bitmap) {
Util.assertMainThread(); setBitmap(bitmap, false);
if (VERSION.SDK_INT < VERSION_CODES.HONEYCOMB_MR1 || bmp == null || !bmp.sameAs(bitmap)) { }
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; bmp = bitmap;
invalidateSelf(); invalidateSelf();
} }

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

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

@ -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));
}
}

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

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

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

@ -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 +
'}';
}
}

@ -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?)

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

@ -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; EmojiTreeNode tree = root;
for (int i=startPosition; i<endPostiion; i++) { for (int i=startPosition; i<endPosition; i++) {
char character = unicode.charAt(i); char character = unicode.charAt(i);
if (!tree.hasChild(character)) { if (!tree.hasChild(character)) {
@ -88,7 +88,7 @@ public class EmojiTree {
} }
if (tree.getEmoji() != null) return tree.getEmoji(); 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; else return null;
} }

@ -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
)

@ -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)
}
}
}
}

@ -34,6 +34,7 @@ import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.DimenRes import androidx.annotation.DimenRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.drawToBitmap
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -47,18 +48,20 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityConversationV2ActionBarBinding
import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ActivityConversationV2Binding
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import nl.komponents.kovenant.ui.successUi import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.Mention
import org.session.libsession.messaging.mentions.MentionsManager import org.session.libsession.messaging.mentions.MentionsManager
import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage 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.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi 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.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview 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.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.MediaTypes import org.session.libsession.utilities.MediaTypes
import org.session.libsession.utilities.Stub
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsession.utilities.recipients.Recipient 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.audio.AudioRecorder
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher 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.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog 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.ConversationActionModeCallback
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper 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.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.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager 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.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase 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.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord 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.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel 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.SlideDeck
import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions 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.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils 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. // price we pay is a bit of back and forth between the input bar and the conversation activity.
@AndroidEntryPoint @AndroidEntryPoint
class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate,
InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher,
ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener,
SearchBottomBar.EventListener, VoiceMessageViewDelegate, LoaderManager.LoaderCallbacks<Cursor> { SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>,
OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback {
private var binding: ActivityConversationV2Binding? = null private var binding: ActivityConversationV2Binding? = null
private var actionBarBinding: ActivityConversationV2ActionBarBinding? = null
@Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@ -172,6 +183,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@Inject lateinit var lokiMessageDb: LokiMessageDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase
@Inject lateinit var storage: Storage @Inject lateinit var storage: Storage
@Inject lateinit var reactionDb: ReactionDatabase
@Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
@ -203,7 +215,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} ?: finish() } ?: finish()
} }
viewModelFactory.create(threadId) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var unreadCount = 0 private var unreadCount = 0
@ -252,21 +264,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
onItemPress = { message, position, view, event -> onItemPress = { message, position, view, event ->
handlePress(message, position, view, event) handlePress(message, position, view, event)
}, },
onItemSwipeToReply = { message, position -> onItemSwipeToReply = { message, _ ->
handleSwipeToReply(message, position) handleSwipeToReply(message)
}, },
onItemLongPress = { message, position -> onItemLongPress = { message, position, view ->
handleLongPress(message, position) if (!isMessageRequestThread() &&
(viewModel.openGroup == null || Capability.REACTIONS.name.lowercase() in viewModel.serverCapabilities)
) {
showEmojiPicker(message, view)
} else {
handleLongPress(message, position)
}
}, },
glide,
onDeselect = { message, position -> onDeselect = { message, position ->
actionMode?.let { actionMode?.let {
onDeselect(message, position, it) onDeselect(message, position, it)
} }
}, },
glide = glide,
lifecycleCoroutineScope = lifecycleScope lifecycleCoroutineScope = lifecycleScope
) )
adapter.visibleMessageContentViewDelegate = this adapter.visibleMessageViewDelegate = this
adapter adapter
} }
@ -279,6 +297,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null) private val messageToScrollAuthor = AtomicReference<Address?>(null)
private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1
// region Settings // region Settings
companion object { companion object {
// Extras // Extras
@ -294,8 +315,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_FROM_LIBRARY = 12 const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124 const val INVITE_CONTACTS = 124
//flag
const val IS_UNSEND_REQUESTS_ENABLED = true
} }
// endregion // endregion
@ -339,14 +358,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showOrHideInputIfNeeded() showOrHideInputIfNeeded()
setUpMessageRequestsBar() setUpMessageRequestsBar()
viewModel.recipient?.let { recipient -> viewModel.recipient?.let { recipient ->
if (recipient.isOpenGroupRecipient) { if (recipient.isOpenGroupRecipient && viewModel.openGroup == null) {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
if (openGroup == null) { return finish()
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() { override fun onResume() {
@ -413,22 +434,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpToolBar() { private fun setUpToolBar() {
setSupportActionBar(binding?.toolbar)
val actionBar = supportActionBar ?: return val actionBar = supportActionBar ?: return
actionBarBinding = ActivityConversationV2ActionBarBinding.inflate(layoutInflater)
actionBar.title = "" actionBar.title = ""
actionBar.customView = actionBarBinding!!.root actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayShowCustomEnabled(true) actionBar.setHomeButtonEnabled(true)
actionBarBinding!!.conversationTitleView.text = viewModel.recipient?.toShortString() binding!!.toolbarContent.conversationTitleView.text = viewModel.recipient?.toShortString()
@DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { @DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) {
R.dimen.medium_profile_picture_size R.dimen.medium_profile_picture_size
} else { } else {
R.dimen.small_profile_picture_size R.dimen.small_profile_picture_size
} }
val size = resources.getDimension(sizeID).roundToInt() val size = resources.getDimension(sizeID).roundToInt()
actionBarBinding!!.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size) binding!!.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size)
actionBarBinding!!.profilePictureView.root.glide = glide binding!!.toolbarContent.profilePictureView.root.glide = glide
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
val profilePictureView = actionBarBinding!!.profilePictureView.root val profilePictureView = binding!!.toolbarContent.profilePictureView.root
viewModel.recipient?.let { recipient -> viewModel.recipient?.let { recipient ->
profilePictureView.update(recipient) profilePictureView.update(recipient)
} }
@ -529,8 +550,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun getLatestOpenGroupInfoIfNeeded() { private fun getLatestOpenGroupInfoIfNeeded() {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) ?: return viewModel.openGroup?.let {
OpenGroupApi.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() }
}
} }
// called from onCreate // called from onCreate
@ -609,7 +631,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
tearDownRecipientObserver() tearDownRecipientObserver()
super.onDestroy() super.onDestroy()
binding = null binding = null
actionBarBinding = null // actionBarBinding = null
} }
// endregion // endregion
@ -625,9 +647,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updateSubtitle() updateSubtitle()
showOrHideInputIfNeeded() showOrHideInputIfNeeded()
if (recipient != null) { 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 val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
binding.typingIndicatorViewContainer.isVisible binding.typingIndicatorViewContainer.isVisible
binding.scrollToBottomButton.isVisible = !isScrolledToBottom && adapter.itemCount > 0 showOrHidScrollToBottomButton()
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1 val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0) unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0)
updateUnreadCountIndicator() updateUnreadCountIndicator()
} }
private fun showOrHidScrollToBottomButton(show: Boolean = true) {
binding?.scrollToBottomButton?.isVisible = show && !isScrolledToBottom && adapter.itemCount > 0
}
private fun updateUnreadCountIndicator() { private fun updateUnreadCountIndicator() {
val binding = binding ?: return val binding = binding ?: return
val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+" val formattedUnreadCount = if (unreadCount < 10000) unreadCount.toString() else "9999+"
@ -882,7 +908,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun updateSubtitle() { private fun updateSubtitle() {
val actionBarBinding = actionBarBinding ?: return val actionBarBinding = binding?.toolbarContent ?: return
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
actionBarBinding.muteIconImageView.isVisible = recipient.isMuted actionBarBinding.muteIconImageView.isVisible = recipient.isMuted
actionBarBinding.conversationSubtitleView.isVisible = true actionBarBinding.conversationSubtitleView.isVisible = true
@ -893,11 +919,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever)
} }
} else if (recipient.isGroupRecipient) { } else if (recipient.isGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(viewModel.threadId) viewModel.openGroup?.let { openGroup ->
if (openGroup != null) {
val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0
actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount)
} else { } ?: run {
actionBarBinding.conversationSubtitleView.isVisible = false actionBarBinding.conversationSubtitleView.isVisible = false
} }
} else { } else {
@ -942,7 +967,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
// `position` is the adapter position; not the visual position // `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 val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, message, glide) 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) { override fun onMicrophoneButtonMove(event: MotionEvent) {
val rawX = event.rawX val rawX = event.rawX
val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return val chevronImageView = binding?.inputBarRecordingView?.chevronImageView ?: return
@ -1047,6 +1230,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) 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) { override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) {
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return } if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
@ -1303,34 +1510,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask)
} }
// Remove this after the unsend request is enabled override fun selectMessages(messages: Set<MessageRecord>) {
fun deleteMessagesWithoutUnsendRequest(messages: Set<MessageRecord>) { handleLongPress(messages.first(), 0) //TODO: begin selection mode
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 deleteMessages(messages: Set<MessageRecord>) { override fun deleteMessages(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
if (!IS_UNSEND_REQUESTS_ENABLED) {
deleteMessagesWithoutUnsendRequest(messages)
return
}
val allSentByCurrentUser = messages.all { it.isOutgoing } val allSentByCurrentUser = messages.all { it.isOutgoing }
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null }
if (recipient.isOpenGroupRecipient) { if (recipient.isOpenGroupRecipient) {
val messageCount = messages.size val messageCount = 1
val builder = AlertDialog.Builder(this) val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) 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.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) bottomSheet.show(supportFragmentManager, bottomSheet.tag)
} else { } else {
val messageCount = messages.size val messageCount = 1
val builder = AlertDialog.Builder(this) val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) 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.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>) { override fun showMessageDetail(messages: Set<MessageRecord>) {
val message = messages.first()
val intent = Intent(this, MessageDetailActivity::class.java) val intent = Intent(this, MessageDetailActivity::class.java)
intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, message.timestamp) intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp)
push(intent) push(intent)
endActionMode() endActionMode()
} }
@ -1549,8 +1737,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (result == null) return@Observer if (result == null) return@Observer
if (result.getResults().isNotEmpty()) { if (result.getResults().isNotEmpty()) {
result.getResults()[result.position]?.let { result.getResults()[result.position]?.let {
jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs, jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs) {
{ searchViewModel.onMissingResult() }) searchViewModel.onMissingResult() }
} }
} }
binding?.searchBottomBar?.setData(result.position, result.getResults().size) binding?.searchBottomBar?.setData(result.position, result.getResults().size)
@ -1600,4 +1788,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
// endregion // 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)
}
}
}
} }

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

@ -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)
}
}

@ -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
}
}

@ -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,
}
}

@ -3,21 +3,29 @@ package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
private val repository: ConversationRepository val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationUiState()) private val _uiState = MutableStateFlow(ConversationUiState())
@ -26,6 +34,18 @@ class ConversationViewModel(
val recipient: Recipient? val recipient: Recipient?
get() = repository.maybeGetRecipientForThreadId(threadId) 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 { init {
_uiState.update { _uiState.update {
it.copy(isOxenHostedOpenGroup = repository.isOxenHostedOpenGroup(threadId)) it.copy(isOxenHostedOpenGroup = repository.isOxenHostedOpenGroup(threadId))
@ -137,17 +157,19 @@ class ConversationViewModel(
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long): Factory fun create(threadId: Long, edKeyPair: KeyPair?): Factory
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor( class Factory @AssistedInject constructor(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
private val repository: ConversationRepository @Assisted private val edKeyPair: KeyPair?,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, repository) as T return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
} }
} }
} }

@ -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);
}

@ -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;
}
}

@ -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?,
)

@ -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;
}
}
}

@ -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;
}
}

@ -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);
}
}

@ -40,7 +40,7 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B
ThreadUtils.queue { ThreadUtils.queue {
try { try {
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity) OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity)
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(url) MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()

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

@ -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)
}
}

@ -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);
}
}
}
}

@ -53,7 +53,7 @@ class VisibleMessageContentView : LinearLayout {
private lateinit var binding: ViewVisibleMessageContentBinding private lateinit var binding: ViewVisibleMessageContentBinding
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
var onContentDoubleTap: (() -> Unit)? = null var onContentDoubleTap: (() -> Unit)? = null
var delegate: VisibleMessageContentViewDelegate? = null var delegate: VisibleMessageViewDelegate? = null
var indexInAdapter: Int = -1 var indexInAdapter: Int = -1
// region Lifecycle // region Lifecycle
@ -87,13 +87,13 @@ class VisibleMessageContentView : LinearLayout {
if (message.isDeleted) { if (message.isDeleted) {
binding.deletedMessageView.root.isVisible = true binding.deletedMessageView.root.isVisible = true
binding.deletedMessageView.root.bind(message, VisibleMessageContentView.getTextColor(context,message)) binding.deletedMessageView.root.bind(message, getTextColor(context, message))
return return
} else { } else {
binding.deletedMessageView.root.isVisible = false binding.deletedMessageView.root.isVisible = false
} }
// clear the // clear the
binding.bodyTextView.text = "" binding.bodyTextView.text = null
binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null
@ -149,64 +149,70 @@ class VisibleMessageContentView : LinearLayout {
} }
} }
if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { when {
binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> {
onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) } binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster)
// Body text view is inside the link preview for layout convenience onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) }
} else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) { // Body text view is inside the link preview for layout convenience
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.documentSlide != null) { message is MmsMessageRecord && message.slideDeck.audioSlide != null -> {
hideBody = true hideBody = true
// Document attachment // Audio attachment
if (contactIsTrusted || message.isOutgoing) { if (contactIsTrusted || message.isOutgoing) {
binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) binding.voiceMessageView.root.indexInAdapter = indexInAdapter
} else { binding.voiceMessageView.root.delegate = context as? ConversationActivityV2
binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message)) binding.voiceMessageView.root.bind(message, isStartOfMessageCluster, isEndOfMessageCluster)
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } // 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()) { message is MmsMessageRecord && message.slideDeck.documentSlide != null -> {
/* hideBody = true
* Images / Video attachment // Document attachment
*/ if (contactIsTrusted || message.isOutgoing) {
if (contactIsTrusted || message.isOutgoing) { binding.documentView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
// isStart and isEnd of cluster needed for calculating the mask for full bubble image groups } else {
// bind after add view because views are inflated and calculated during bind binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.DOCUMENT, VisibleMessageContentView.getTextColor(context,message))
binding.albumThumbnailView.bind( 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, glideRequests = glide,
message = message, message = message,
isStart = isStartOfMessageCluster, isStart = isStartOfMessageCluster,
isEnd = isEndOfMessageCluster isEnd = isEndOfMessageCluster
) )
val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams
layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
binding.albumThumbnailView.layoutParams = layoutParams binding.albumThumbnailView.layoutParams = layoutParams
onContentClick.add { event -> onContentClick.add { event ->
binding.albumThumbnailView.calculateHitObject(event, message, thread) 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) { message.isOpenGroupInvitation -> {
hideBody = true hideBody = true
binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message)) binding.openGroupInvitationView.root.bind(message, VisibleMessageContentView.getTextColor(context, message))
onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() } onContentClick.add { binding.openGroupInvitationView.root.joinOpenGroup() }
}
} }
binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody
@ -312,8 +318,3 @@ class VisibleMessageContentView : LinearLayout {
} }
// endregion // endregion
} }
interface VisibleMessageContentViewDelegate {
fun scrollToMessageIfPossible(timestamp: Long)
}

@ -26,12 +26,14 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageBinding import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.contacts.Contact.ContactContext 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.Address
import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.ViewUtil
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
@ -59,6 +61,7 @@ class VisibleMessageView : LinearLayout {
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var mmsSmsDb: MmsSmsDatabase @Inject lateinit var mmsSmsDb: MmsSmsDatabase
@Inject lateinit var smsDb: SmsDatabase @Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
@ -83,7 +86,7 @@ class VisibleMessageView : LinearLayout {
var onPress: ((event: MotionEvent) -> Unit)? = null var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null var onLongPress: (() -> Unit)? = null
var contentViewDelegate: VisibleMessageContentViewDelegate? = null val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView }
companion object { companion object {
const val swipeToReplyThreshold = 64.0f // dp const val swipeToReplyThreshold = 64.0f // dp
@ -105,14 +108,21 @@ class VisibleMessageView : LinearLayout {
private fun initialize() { private fun initialize() {
isHapticFeedbackEnabled = true isHapticFeedbackEnabled = true
setWillNotDraw(false) setWillNotDraw(false)
binding.expirationTimerViewContainer.disableClipping() binding.messageInnerContainer.disableClipping()
binding.messageContentView.disableClipping() binding.messageContentView.disableClipping()
} }
// endregion // endregion
// region Updating // region Updating
fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, fun bind(
glide: GlideRequests, searchQuery: String?, contact: Contact?, senderSessionID: String, message: MessageRecord,
previous: MessageRecord?,
next: MessageRecord?,
glide: GlideRequests,
searchQuery: String?,
contact: Contact?,
senderSessionID: String,
delegate: VisibleMessageViewDelegate?,
) { ) {
val threadID = message.threadId val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return val thread = threadDb.getRecipientForThreadId(threadID) ?: return
@ -132,9 +142,9 @@ class VisibleMessageView : LinearLayout {
else ViewUtil.dpToPx(context,2) else ViewUtil.dpToPx(context,2)
if (binding.profilePictureView.root.visibility == View.GONE) { if (binding.profilePictureView.root.visibility == View.GONE) {
val expirationParams = binding.expirationTimerViewContainer.layoutParams as MarginLayoutParams val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
expirationParams.bottomMargin = bottomMargin expirationParams.bottomMargin = bottomMargin
binding.expirationTimerViewContainer.layoutParams = expirationParams binding.messageInnerContainer.layoutParams = expirationParams
} else { } else {
val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams
avatarLayoutParams.bottomMargin = bottomMargin avatarLayoutParams.bottomMargin = bottomMargin
@ -198,7 +208,20 @@ class VisibleMessageView : LinearLayout {
} }
// Expiration timer // Expiration timer
updateExpirationTimer(message) 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 // Populate content view
binding.messageContentView.indexInAdapter = indexInAdapter binding.messageContentView.indexInAdapter = indexInAdapter
binding.messageContentView.bind( binding.messageContentView.bind(
@ -210,7 +233,7 @@ class VisibleMessageView : LinearLayout {
searchQuery, searchQuery,
message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false) message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false)
) )
binding.messageContentView.delegate = contentViewDelegate binding.messageContentView.delegate = delegate
onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() } onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() }
} }
@ -245,7 +268,7 @@ class VisibleMessageView : LinearLayout {
} }
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.expirationTimerViewContainer val container = binding.messageInnerContainer
val content = binding.messageContentView val content = binding.messageContentView
val expiration = binding.expirationTimerView val expiration = binding.expirationTimerView
val spacing = binding.messageContentSpacing val spacing = binding.messageContentSpacing
@ -297,8 +320,8 @@ class VisibleMessageView : LinearLayout {
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val iconSize = toPx(24, context.resources) val iconSize = toPx(24, context.resources)
val left = binding.expirationTimerViewContainer.left + binding.messageContentView.right + spacing val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing
val top = height - (binding.expirationTimerViewContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2) val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2)
val right = left + iconSize val right = left + iconSize
val bottom = top + iconSize val bottom = top + iconSize
swipeToReplyIconRect.left = left swipeToReplyIconRect.left = left
@ -388,7 +411,7 @@ class VisibleMessageView : LinearLayout {
} else { } else {
val newPressCallback = Runnable { onPress(event) } val newPressCallback = Runnable { onPress(event) }
this.pressCallback = newPressCallback this.pressCallback = newPressCallback
gestureHandler.postDelayed(newPressCallback, VisibleMessageView.maxDoubleTapInterval) gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval)
} }
} }
resetPosition() resetPosition()

@ -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)
}

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

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

@ -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)
}

@ -14,6 +14,7 @@ import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.GroupRecord; import org.session.libsession.utilities.GroupRecord;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
@ -441,7 +442,15 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
} }
} }
public static class Reader implements Closeable { public void migrateEncodedGroup(@NotNull String legacyEncodedGroupId, @NotNull String newEncodedGroupId) {
String query = GROUP_ID+" = ?";
ContentValues contentValues = new ContentValues(1);
contentValues.put(GROUP_ID, newEncodedGroupId);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, query, new String[]{legacyEncodedGroupId});
}
public static class Reader implements Closeable {
private final Cursor cursor; private final Cursor cursor;

@ -59,9 +59,9 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
private val token = "token" private val token = "token"
@JvmStatic val createOpenGroupAuthTokenTableCommand = "CREATE TABLE $openGroupAuthTokenTable ($server TEXT PRIMARY KEY, $token TEXT);" @JvmStatic val createOpenGroupAuthTokenTableCommand = "CREATE TABLE $openGroupAuthTokenTable ($server TEXT PRIMARY KEY, $token TEXT);"
// Last message server IDs // Last message server IDs
private val lastMessageServerIDTable = "loki_api_last_message_server_id_cache" private const val lastMessageServerIDTable = "loki_api_last_message_server_id_cache"
private val lastMessageServerIDTableIndex = "loki_api_last_message_server_id_cache_index" private val lastMessageServerIDTableIndex = "loki_api_last_message_server_id_cache_index"
private val lastMessageServerID = "last_message_server_id" private const val lastMessageServerID = "last_message_server_id"
@JvmStatic val createLastMessageServerIDTableCommand = "CREATE TABLE $lastMessageServerIDTable ($lastMessageServerIDTableIndex STRING PRIMARY KEY, $lastMessageServerID INTEGER DEFAULT 0);" @JvmStatic val createLastMessageServerIDTableCommand = "CREATE TABLE $lastMessageServerIDTable ($lastMessageServerIDTableIndex STRING PRIMARY KEY, $lastMessageServerID INTEGER DEFAULT 0);"
// Last deletion server IDs // Last deletion server IDs
private val lastDeletionServerIDTable = "loki_api_last_deletion_server_id_cache" private val lastDeletionServerIDTable = "loki_api_last_deletion_server_id_cache"
@ -153,6 +153,9 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
"$requestSignature STRING NULLABLE DEFAULT NULL, $authorizationSignature STRING NULLABLE DEFAULT NULL, PRIMARY KEY ($masterPublicKey, $slavePublicKey));" "$requestSignature STRING NULLABLE DEFAULT NULL, $authorizationSignature STRING NULLABLE DEFAULT NULL, PRIMARY KEY ($masterPublicKey, $slavePublicKey));"
private val sessionRequestTimestampCache = "session_request_timestamp_cache" private val sessionRequestTimestampCache = "session_request_timestamp_cache"
@JvmStatic val createSessionRequestTimestampCacheCommand = "CREATE TABLE $sessionRequestTimestampCache ($publicKey STRING PRIMARY KEY, $timestamp STRING);" @JvmStatic val createSessionRequestTimestampCacheCommand = "CREATE TABLE $sessionRequestTimestampCache ($publicKey STRING PRIMARY KEY, $timestamp STRING);"
const val RESET_SEQ_NO = "UPDATE $lastMessageServerIDTable SET $lastMessageServerID = 0;"
// endregion // endregion
} }
@ -377,18 +380,29 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.delete(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index)) database.delete(lastDeletionServerIDTable, "$lastDeletionServerIDTableIndex = ?", wrap(index))
} }
fun removeLastDeletionServerID(group: Long, server: String) { override fun migrateLegacyOpenGroup(legacyServerId: String, newServerId: String) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val index = "$server.$group" database.beginTransaction()
database.delete(lastDeletionServerIDTable,"$lastDeletionServerIDTableIndex = ?", wrap(index)) val authRow = wrap(mapOf(server to newServerId))
} database.update(openGroupAuthTokenTable, authRow, "$server = ?", wrap(legacyServerId))
val lastMessageRow = wrap(mapOf(lastMessageServerIDTableIndex to newServerId))
fun getUserCount(group: Long, server: String): Int? { database.update(lastMessageServerIDTable, lastMessageRow,
val database = databaseHelper.readableDatabase "$lastMessageServerIDTableIndex = ?", wrap(legacyServerId))
val index = "$server.$group" val lastDeletionRow = wrap(mapOf(lastDeletionServerIDTableIndex to newServerId))
return database.get(userCountTable, "$publicChatID = ?", wrap(index)) { cursor -> database.update(
cursor.getInt(userCount) lastDeletionServerIDTable, lastDeletionRow,
}?.toInt() "$lastDeletionServerIDTableIndex = ?", wrap(legacyServerId))
val userCountRow = wrap(mapOf(publicChatID to newServerId))
database.update(
userCountTable, userCountRow,
"$publicChatID = ?", wrap(legacyServerId)
)
val publicKeyRow = wrap(mapOf(server to newServerId))
database.update(
openGroupPublicKeyTable, publicKeyRow,
"$server = ?", wrap(legacyServerId)
)
database.endTransaction()
} }
fun getUserCount(room: String, server: String): Int? { fun getUserCount(room: String, server: String): Int? {
@ -399,13 +413,6 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
}?.toInt() }?.toInt()
} }
override fun setUserCount(group: Long, server: String, newValue: Int) {
val database = databaseHelper.writableDatabase
val index = "$server.$group"
val row = wrap(mapOf( publicChatID to index, Companion.userCount to newValue.toString() ))
database.insertOrUpdate(userCountTable, row, "$publicChatID = ?", wrap(index))
}
override fun setUserCount(room: String, server: String, newValue: Int) { override fun setUserCount(room: String, server: String, newValue: Int) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val index = "$server.$room" val index = "$server.$room"

@ -177,4 +177,12 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString()))
} }
fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) {
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(1)
contentValues.put(threadID, newThreadId)
database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString()))
}
} }

@ -7,15 +7,16 @@ import android.text.TextUtils;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Document; import org.session.libsession.utilities.Document;
import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.IdentityKeyMismatchList; import org.session.libsession.utilities.IdentityKeyMismatchList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.crypto.IdentityKey;
import org.session.libsession.utilities.Address;
import org.session.libsignal.utilities.JsonUtil; 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;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@ -44,6 +45,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void updateThreadId(long fromId, long toId); 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) { public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
try { try {
addToDocument(messageId, MISMATCHED_IDENTITIES, addToDocument(messageId, MISMATCHED_IDENTITIES,
@ -64,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 { protected <D extends Document<I>, I> void removeFromDocument(long messageId, String column, I object, Class<D> clazz) throws IOException {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.beginTransaction(); database.beginTransaction();
@ -159,6 +186,15 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
} }
} }
public void migrateThreadId(long oldThreadId, long newThreadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID+" = ?";
String[] args = new String[]{oldThreadId+""};
ContentValues contentValues = new ContentValues();
contentValues.put(THREAD_ID, newThreadId);
db.update(getTableName(), contentValues, where, args);
}
public static class SyncMessageId { public static class SyncMessageId {
private final Address address; private final Address address;

@ -55,8 +55,6 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.ThreadUtils.queue
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment 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.SmsDatabase.InsertListener
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
@ -267,9 +265,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
private fun rawQuery(where: String, arguments: Array<String>?): Cursor { private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.rawQuery( return database.rawQuery(
"SELECT " + MMS_PROJECTION.joinToString(",")+ "SELECT " + MMS_PROJECTION.joinToString(",") + " FROM " + TABLE_NAME +
" FROM " + TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + " ON (" + TABLE_NAME + "." + ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" +
" 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 " WHERE " + where + " GROUP BY " + TABLE_NAME + "." + ID, arguments
) )
} }
@ -401,7 +399,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> { fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> {
return setMessagesRead( return setMessagesRead(
THREAD_ID + " = ? AND " + READ + " = 0", THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)",
arrayOf(threadId.toString()) arrayOf(threadId.toString())
) )
} }
@ -440,6 +438,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(READ, 1) contentValues.put(READ, 1)
contentValues.put(REACTIONS_UNREAD, 0)
database.update(TABLE_NAME, contentValues, where, arguments) database.update(TABLE_NAME, contentValues, where, arguments)
database.setTransactionSuccessful() database.setTransactionSuccessful()
} finally { } finally {
@ -1006,6 +1005,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyConversationListListeners() 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) { fun deleteThread(threadId: Long) {
deleteThreads(setOf(threadId)) deleteThreads(setOf(threadId))
} }
@ -1266,7 +1272,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
message.outgoingQuote!!.missing, message.outgoingQuote!!.missing,
SlideDeck(context, message.outgoingQuote!!.attachments!!) SlideDeck(context, message.outgoingQuote!!.attachments!!)
) else null, ) else null,
message.sharedContacts, message.linkPreviews, false message.sharedContacts, message.linkPreviews, listOf(), false
) )
} }
@ -1391,12 +1397,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
.toList() .toList()
) )
val quote = getQuote(cursor) val quote = getQuote(cursor)
val reactions = get(context).reactionDatabase().getReactions(cursor)
return MediaMmsMessageRecord( return MediaMmsMessageRecord(
id, recipient, recipient, id, recipient, recipient,
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, addressDeviceId, dateSent, dateReceived, deliveryReceiptCount,
threadId, body, slideDeck!!, partCount, box, mismatches, threadId, body, slideDeck!!, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted, networkFailures, subscriptionId, expiresIn, expireStarted,
readReceiptCount, quote, contacts, previews, unidentified readReceiptCount, quote, contacts, previews, reactions, unidentified
) )
} }
@ -1571,9 +1578,23 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
"'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " +
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + "'" + 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_WHERE: String = "$TABLE_NAME._id = ?"
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;"
} }
} }

@ -21,6 +21,8 @@ public interface MmsSmsColumns {
public static final String NOTIFIED = "notified"; public static final String NOTIFIED = "notified";
public static final String UNIDENTIFIED = "unidentified"; public static final String UNIDENTIFIED = "unidentified";
public static final String MESSAGE_REQUEST_RESPONSE = "message_request_response"; 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 { public static class Types {
protected static final long TOTAL_MASK = 0xFFFFFFFF; protected static final long TOTAL_MASK = 0xFFFFFFFF;

@ -74,7 +74,8 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.SHARED_CONTACTS, MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS}; MmsDatabase.LINK_PREVIEWS,
ReactionDatabase.REACTION_JSON_ALIAS};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper); super(context, databaseHelper);
@ -145,7 +146,7 @@ public class MmsSmsDatabase extends Database {
public Cursor getUnread() { public Cursor getUnread() {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC"; 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); 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) { 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, String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID, 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_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID +
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
reactionsColumn,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID, SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
@ -274,6 +288,7 @@ public class MmsSmsDatabase extends Database {
+ " || '::' || " + SmsDatabase.DATE_SENT + " || '::' || " + SmsDatabase.DATE_SENT
+ " AS " + MmsSmsColumns.UNIQUE_ROW_ID, + " AS " + MmsSmsColumns.UNIQUE_ROW_ID,
"NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, "NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
reactionsColumn,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID, SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
@ -299,10 +314,14 @@ public class MmsSmsDatabase extends Database {
mmsQueryBuilder.setDistinct(true); mmsQueryBuilder.setDistinct(true);
smsQueryBuilder.setDistinct(true); smsQueryBuilder.setDistinct(true);
smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME); smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME +
mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME +
AttachmentDatabase.TABLE_NAME + " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0");
" ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID); 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<>(); Set<String> mmsColumnsPresent = new HashSet<>();
@ -362,6 +381,16 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); 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<>(); Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID); smsColumnsPresent.add(MmsSmsColumns.ID);
@ -383,11 +412,22 @@ public class MmsSmsDatabase extends Database {
smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED); smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED);
smsColumnsPresent.add(SmsDatabase.STATUS); smsColumnsPresent.add(SmsDatabase.STATUS);
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED); 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") @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") @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(); SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder();
String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit); String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit);

@ -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()
}
}
}

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

@ -23,6 +23,9 @@ import android.database.Cursor;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Pair; import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
@ -43,11 +46,13 @@ import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.guava.Optional; import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord; 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.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.io.IOException; import java.io.IOException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -98,9 +103,24 @@ public class SmsDatabase extends MessagingDatabase {
PROTOCOL, READ, STATUS, TYPE, PROTOCOL, READ, STATUS, TYPE,
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT, REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, 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 earlyDeliveryReceiptCache = new EarlyReceiptCache();
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache();
@ -294,7 +314,7 @@ public class SmsDatabase extends MessagingDatabase {
} }
public List<MarkedMessageInfo> setMessagesRead(long threadId) { 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() { public List<MarkedMessageInfo> setAllMessagesRead() {
@ -321,6 +341,7 @@ public class SmsDatabase extends MessagingDatabase {
ContentValues contentValues = new ContentValues(); ContentValues contentValues = new ContentValues();
contentValues.put(READ, 1); contentValues.put(READ, 1);
contentValues.put(REACTIONS_UNREAD, 0);
database.update(TABLE_NAME, contentValues, where, arguments); database.update(TABLE_NAME, contentValues, where, arguments);
database.setTransactionSuccessful(); database.setTransactionSuccessful();
@ -533,15 +554,21 @@ public class SmsDatabase extends MessagingDatabase {
return messageId; 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() { public Cursor getExpirationStartedMessages() {
String where = EXPIRE_STARTED + " > 0"; String where = EXPIRE_STARTED + " > 0";
SQLiteDatabase db = databaseHelper.getReadableDatabase(); return rawQuery(where, null);
return db.query(TABLE_NAME, MESSAGE_PROJECTION, where, null, null, null, null);
} }
public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException { public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""});
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[]{messageId + ""}, null, null, null);
Reader reader = new Reader(cursor); Reader reader = new Reader(cursor);
SmsMessageRecord record = reader.getNext(); SmsMessageRecord record = reader.getNext();
@ -580,6 +607,11 @@ public class SmsDatabase extends MessagingDatabase {
notifyConversationListListeners(); notifyConversationListListeners();
} }
@Override
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
return getMessage(messageId);
}
private boolean isDuplicate(IncomingTextMessage message, long threadId) { private boolean isDuplicate(IncomingTextMessage message, long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?",
@ -704,7 +736,7 @@ public class SmsDatabase extends MessagingDatabase {
0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), 0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(),
threadId, 0, new LinkedList<IdentityKeyMismatch>(), threadId, 0, new LinkedList<IdentityKeyMismatch>(),
message.getExpiresIn(), message.getExpiresIn(),
System.currentTimeMillis(), 0, false); System.currentTimeMillis(), 0, false, Collections.emptyList());
} }
} }
@ -752,12 +784,13 @@ public class SmsDatabase extends MessagingDatabase {
List<IdentityKeyMismatch> mismatches = getMismatches(mismatchDocument); List<IdentityKeyMismatch> mismatches = getMismatches(mismatchDocument);
Recipient recipient = Recipient.from(context, address, true); Recipient recipient = Recipient.from(context, address, true);
List<ReactionRecord> reactions = DatabaseComponent.get(context).reactionDatabase().getReactions(cursor);
return new SmsMessageRecord(messageId, body, recipient, return new SmsMessageRecord(messageId, body, recipient,
recipient, recipient,
dateSent, dateReceived, deliveryReceiptCount, type, dateSent, dateReceived, deliveryReceiptCount, type,
threadId, status, mismatches, threadId, status, mismatches,
expiresIn, expireStarted, readReceiptCount, unidentified); expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
} }
private List<IdentityKeyMismatch> getMismatches(String document) { private List<IdentityKeyMismatch> getMismatches(String document) {

@ -12,6 +12,7 @@ import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob 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.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage 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.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Attachment 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.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.GroupMember
import org.session.libsession.messaging.open_groups.OpenGroup 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.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper 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.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
@ -567,9 +571,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
OpenGroupManager.addOpenGroup(urlAsString, context) OpenGroupManager.addOpenGroup(urlAsString, context)
} }
override fun onOpenGroupAdded(urlAsString: String) { override fun onOpenGroupAdded(server: String) {
val server = OpenGroup.getServer(urlAsString) OpenGroupManager.restartPollerForServer(server.removeSuffix("/"))
OpenGroupManager.restartPollerForServer(server.toString().removeSuffix("/"))
} }
override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean {
@ -888,4 +891,57 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
db.addBlindedIdMapping(mapping) db.addBlindedIdMapping(mapping)
return 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))
}
} }

@ -34,6 +34,7 @@ import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.Contact;
import org.session.libsession.utilities.DelimiterUtil; import org.session.libsession.utilities.DelimiterUtil;
@ -56,12 +57,15 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.groups.OpenGroupMigrator;
import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.io.Closeable; import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -765,6 +769,88 @@ public class ThreadDatabase extends Database {
return query; return query;
} }
@NotNull
public List<ThreadRecord> getHttpOxenOpenGroups() {
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
String selection = OpenGroupMigrator.HTTP_PREFIX+OpenGroupMigrator.OPEN_GET_SESSION_TRAILING_DOT_ENCODED +"%";
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(where, 0);
Cursor cursor = db.rawQuery(query, new String[]{selection});
if (cursor == null) {
return Collections.emptyList();
}
List<ThreadRecord> threads = new ArrayList<>();
try {
Reader reader = readerFor(cursor);
ThreadRecord record;
while ((record = reader.getNext()) != null) {
threads.add(record);
}
} finally {
cursor.close();
}
return threads;
}
@NotNull
public List<ThreadRecord> getLegacyOxenOpenGroups() {
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
String selection = OpenGroupMigrator.LEGACY_GROUP_ENCODED_ID+"%";
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(where, 0);
Cursor cursor = db.rawQuery(query, new String[]{selection});
if (cursor == null) {
return Collections.emptyList();
}
List<ThreadRecord> threads = new ArrayList<>();
try {
Reader reader = readerFor(cursor);
ThreadRecord record;
while ((record = reader.getNext()) != null) {
threads.add(record);
}
} finally {
cursor.close();
}
return threads;
}
@NotNull
public List<ThreadRecord> getHttpsOxenOpenGroups() {
String where = TABLE_NAME+"."+ADDRESS+" LIKE ?";
String selection = OpenGroupMigrator.NEW_GROUP_ENCODED_ID+"%";
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(where, 0);
Cursor cursor = db.rawQuery(query, new String[]{selection});
if (cursor == null) {
return Collections.emptyList();
}
List<ThreadRecord> threads = new ArrayList<>();
try {
Reader reader = readerFor(cursor);
ThreadRecord record;
while ((record = reader.getNext()) != null) {
threads.add(record);
}
} finally {
cursor.close();
}
return threads;
}
public void migrateEncodedGroup(long threadId, @NotNull String newEncodedGroupId) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(ADDRESS, newEncodedGroupId);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
}
public void notifyThreadUpdated(long threadId) {
notifyConversationListeners(threadId);
}
public interface ProgressListener { public interface ProgressListener {
void onProgress(int complete, int total); void onProgress(int complete, int total);
} }

@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupMemberDatabase; import org.thoughtcrime.securesms.database.GroupMemberDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase; 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.LokiUserDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase; import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SessionContactDatabase; import org.thoughtcrime.securesms.database.SessionContactDatabase;
@ -70,9 +72,12 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV33 = 54; private static final int lokiV33 = 54;
private static final int lokiV34 = 55; private static final int lokiV34 = 55;
private static final int lokiV35 = 56; 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 // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV35; private static final int DATABASE_VERSION = lokiV38;
private static final String DATABASE_NAME = "signal.db"; private static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -158,7 +163,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand()); db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand());
db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedCommand());
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); 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_FORK_INFO_TABLE_COMMAND);
db.execSQL(LokiAPIDatabase.CREATE_DEFAULT_FORK_INFO_COMMAND); db.execSQL(LokiAPIDatabase.CREATE_DEFAULT_FORK_INFO_COMMAND);
db.execSQL(LokiAPIDatabase.UPDATE_HASHES_INCLUDE_NAMESPACE_COMMAND); db.execSQL(LokiAPIDatabase.UPDATE_HASHES_INCLUDE_NAMESPACE_COMMAND);
@ -169,6 +177,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES); db.execSQL(LokiAPIDatabase.DROP_LEGACY_RECEIVED_HASHES);
db.execSQL(BlindedIdMappingDatabase.CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND); db.execSQL(BlindedIdMappingDatabase.CREATE_BLINDED_ID_MAPPING_TABLE_COMMAND);
db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_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, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -177,6 +188,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, DraftDatabase.CREATE_INDEXS); executeStatements(db, DraftDatabase.CREATE_INDEXS);
executeStatements(db, GroupDatabase.CREATE_INDEXS); executeStatements(db, GroupDatabase.CREATE_INDEXS);
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
} }
@Override @Override
@ -355,7 +368,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedCommand());
db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); db.execSQL(RecipientDatabase.getCreateApprovedMeCommand());
db.execSQL(RecipientDatabase.getUpdateApprovedCommand()); db.execSQL(RecipientDatabase.getUpdateApprovedCommand());
db.execSQL(MmsDatabase.createMessageRequestResponseCommand); db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND);
} }
if (oldVersion < lokiV32) { if (oldVersion < lokiV32) {
@ -385,6 +398,22 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND); db.execSQL(GroupMemberDatabase.CREATE_GROUP_MEMBER_TABLE_COMMAND);
} }
if (oldVersion < lokiV36) {
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(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

@ -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;
}
}

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

@ -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())
}
}
}

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

@ -25,9 +25,9 @@ public abstract class MmsMessageRecord extends MessageRecord {
List<NetworkFailure> networkFailures, long expiresIn, List<NetworkFailure> networkFailures, long expiresIn,
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts, @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.slideDeck = slideDeck;
this.quote = quote; this.quote = quote;
this.contacts.addAll(contacts); this.contacts.addAll(contacts);

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

@ -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
)

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

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

@ -125,6 +125,14 @@ object DatabaseModule {
@Singleton @Singleton
fun provideGroupMemberDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = GroupMemberDatabase(context, openHelper) 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 @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper)

@ -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()
}
}
}
}

@ -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

@ -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)
}

@ -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()
}
}

@ -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